mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +02:00
macOS support (code only, no build yet)
This commit is contained in:
1 parent
1feb6d4336
commit
ca04dcd4a7
17 files changed
+572
-126
No files matched your search
+257
@@ -0,0 +1,257 @@
|
||||
"""macOS stand-ins for what SaraSuite uses on Linux:
|
||||
|
||||
Linux macOS
|
||||
.desktop entry a small app bundle in ~/Applications (<Name>.app)
|
||||
systemd --user service a LaunchAgent (~/Library/LaunchAgents) that starts it
|
||||
at login, through Programs/ServiceRun.py (Services.py)
|
||||
system python3 a standalone Python installed by uv into SaraSuite's
|
||||
own folder (no admin, no Homebrew, no Xcode tools)
|
||||
fc-list the font files' own names
|
||||
xdg-open open
|
||||
|
||||
Only used on macOS, but nothing in here needs macOS to import.
|
||||
"""
|
||||
|
||||
import plistlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from Core import CacheHome, ChildEnvironment, DataDir, Home
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
PythonVersion = "3.14" # what uv installs
|
||||
# Apple Silicon only. uv's own release file, and so the Python it installs.
|
||||
UvTarget = "aarch64-apple-darwin"
|
||||
UvUrl = f"https://github.com/astral-sh/uv/releases/latest/download/uv-{UvTarget}.tar.gz"
|
||||
ToolsDir = DataDir / "Tools" # uv itself
|
||||
PythonsDir = DataDir / "Python" # the Pythons it installs (program venvs are built from these)
|
||||
|
||||
ApplicationsDir = Home / "Applications"
|
||||
LaunchAgentsDir = Home / "Library" / "LaunchAgents"
|
||||
LogsDir = Home / "Library" / "Logs"
|
||||
|
||||
|
||||
# --- Python -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _UvEnvironment() -> dict[str, str]:
|
||||
Environment = ChildEnvironment()
|
||||
Environment.update(
|
||||
UV_PYTHON_INSTALL_DIR=str(PythonsDir),
|
||||
UV_PYTHON_PREFERENCE="only-managed", # never the Xcode or Homebrew one
|
||||
UV_CACHE_DIR=str(CacheHome / "SaraSuite" / "uv"),
|
||||
UV_NO_MODIFY_PATH="1",
|
||||
)
|
||||
return Environment
|
||||
|
||||
|
||||
def _Uv() -> Path:
|
||||
return ToolsDir / "uv"
|
||||
|
||||
|
||||
def FindPython() -> Optional[str]:
|
||||
"""The Python uv installed for SaraSuite earlier, if any."""
|
||||
if not _Uv().is_file():
|
||||
return None
|
||||
try:
|
||||
Result = subprocess.run(
|
||||
[str(_Uv()), "python", "find", PythonVersion], capture_output=True, text=True, timeout=60,
|
||||
env=_UvEnvironment(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
Found = Result.stdout.strip()
|
||||
return Found if Result.returncode == 0 and Found and Path(Found).is_file() else None
|
||||
|
||||
|
||||
def _Stream(Command: list[str], Log: LogFn) -> int:
|
||||
Log(f"$ {' '.join(Command)}")
|
||||
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
errors="replace", env=_UvEnvironment())
|
||||
for Line in Process.stdout:
|
||||
if Line.strip():
|
||||
Log(f" {Line.rstrip()}")
|
||||
return Process.wait()
|
||||
|
||||
|
||||
def _InstallUv(Log: LogFn) -> bool:
|
||||
Log(f"Downloading uv ({UvUrl})")
|
||||
try:
|
||||
with urllib.request.urlopen(UvUrl, timeout=120) as Response, tempfile.TemporaryDirectory() as Temporary:
|
||||
Archive = Path(Temporary) / "uv.tar.gz"
|
||||
Archive.write_bytes(Response.read())
|
||||
with tarfile.open(Archive) as Tar:
|
||||
Member = next((Item for Item in Tar.getmembers() if Item.isfile() and Path(Item.name).name == "uv"), None)
|
||||
if Member is None:
|
||||
Log("The uv download has no uv in it.")
|
||||
return False
|
||||
Tar.extract(Member, Temporary, filter="data")
|
||||
ToolsDir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(Path(Temporary) / Member.name, _Uv())
|
||||
_Uv().chmod(0o755)
|
||||
return True
|
||||
except (OSError, tarfile.TarError) as Error:
|
||||
Log(f"Couldn't download uv: {Error}")
|
||||
return False
|
||||
|
||||
|
||||
def InstallPython(Log: LogFn) -> Optional[str]:
|
||||
"""Download uv, and with it a standalone Python, into SaraSuite's own
|
||||
folder. Nothing is installed system-wide and no password is asked."""
|
||||
Log(f"Python not found; installing Python {PythonVersion} with uv (just for SaraSuite, no admin)")
|
||||
if not _Uv().is_file() and not _InstallUv(Log):
|
||||
return None
|
||||
# --no-bin: no python3.14 command in ~/.local/bin; only SaraSuite uses this one.
|
||||
if _Stream([str(_Uv()), "python", "install", "--no-bin", PythonVersion], Log) != 0:
|
||||
return None
|
||||
return FindPython()
|
||||
|
||||
|
||||
# --- App bundles and icons -------------------------------------------------------------------
|
||||
|
||||
|
||||
def MakeIcns(Png: Path, Icns: Path) -> Optional[Path]:
|
||||
"""An .icns from one of the Sara icons (32x32 pixel art stored at 10x),
|
||||
each size a whole multiple of the 32x32 grid (nearest-neighbour), so
|
||||
the art stays crisp. Needs Pillow; None without it."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
Grid = Image.open(Png).convert("RGBA").resize((32, 32), Image.NEAREST)
|
||||
Sizes = [Grid.resize((Size, Size), Image.NEAREST) for Size in (16, 32, 64, 128, 256, 512, 1024)]
|
||||
Icns.parent.mkdir(parents=True, exist_ok=True)
|
||||
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
|
||||
return Icns
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def CreateAppBundle(
|
||||
Bundle: Path, Name: str, Identifier: str, Command: list[str], WorkingDir: Path,
|
||||
IconPng: Optional[Path] = None, Description: str = "",
|
||||
) -> None:
|
||||
"""~/Applications/<Name>.app: Launchpad, Spotlight and the Finder list it
|
||||
like any app. Its executable is a shell script that runs Command from
|
||||
WorkingDir. Written fresh each time (the old bundle is replaced)."""
|
||||
if Bundle.exists():
|
||||
shutil.rmtree(Bundle)
|
||||
Contents = Bundle / "Contents"
|
||||
(Contents / "MacOS").mkdir(parents=True)
|
||||
(Contents / "Resources").mkdir()
|
||||
Executable = Contents / "MacOS" / Name
|
||||
Executable.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Written by SaraSuite; replaced on every update.\n"
|
||||
f"cd {shlex.quote(str(WorkingDir))} || exit 1\n"
|
||||
f'exec {" ".join(shlex.quote(Part) for Part in Command)} "$@"\n'
|
||||
)
|
||||
Executable.chmod(0o755)
|
||||
Info = {
|
||||
"CFBundleName": Name,
|
||||
"CFBundleDisplayName": Name,
|
||||
"CFBundleIdentifier": Identifier,
|
||||
"CFBundleExecutable": Name,
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleShortVersionString": "1.0",
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
"NSHighResolutionCapable": True,
|
||||
"NSHumanReadableCopyright": Description,
|
||||
}
|
||||
if IconPng is not None and MakeIcns(IconPng, Contents / "Resources" / f"{Name}.icns") is not None:
|
||||
Info["CFBundleIconFile"] = f"{Name}.icns"
|
||||
with (Contents / "Info.plist").open("wb") as File:
|
||||
plistlib.dump(Info, File)
|
||||
# The Finder and Launchpad cache bundles; tell them this one changed.
|
||||
Register = Path("/System/Library/Frameworks/CoreServices.framework/Frameworks/"
|
||||
"LaunchServices.framework/Support/lsregister")
|
||||
if Register.is_file():
|
||||
subprocess.run([str(Register), "-f", str(Bundle)], capture_output=True, timeout=30)
|
||||
|
||||
|
||||
# --- Services ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def LaunchAgentPath(Name: str) -> Path:
|
||||
return LaunchAgentsDir / f"srhtnin.{Name}.plist"
|
||||
|
||||
|
||||
def LaunchAgent(Name: str, Command: list[str], WorkingDir: Path) -> bytes:
|
||||
"""The LaunchAgent that starts a service at login (macOS's equivalent of
|
||||
the Startup folder / an XDG autostart entry). Only that: ServiceRun.py
|
||||
keeps it running and logs it, like on the other systems, so SaraSuite
|
||||
and SaraSort can start and stop it the same way everywhere."""
|
||||
return plistlib.dumps({
|
||||
"Label": f"srhtnin.{Name}",
|
||||
"ProgramArguments": Command,
|
||||
"WorkingDirectory": str(WorkingDir),
|
||||
"RunAtLoad": True,
|
||||
"ProcessType": "Background",
|
||||
# Only what fails before ServiceRun.py has its own log (e.g. a missing venv).
|
||||
"StandardErrorPath": str(LogsDir / f"{Name}.launchd.log"),
|
||||
})
|
||||
|
||||
|
||||
# --- Fonts and files ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _FontFamilies(FilePath: Path) -> set[str]:
|
||||
"""Family names in one font file's name table (every font of a .ttc).
|
||||
The typographic family (name ID 16) where there is one, else ID 1."""
|
||||
import struct
|
||||
|
||||
Families: set[str] = set()
|
||||
try:
|
||||
Data = FilePath.read_bytes()
|
||||
except OSError:
|
||||
return Families
|
||||
Offsets = [0]
|
||||
if Data[:4] == b"ttcf":
|
||||
Count = struct.unpack_from(">I", Data, 8)[0]
|
||||
Offsets = list(struct.unpack_from(f">{Count}I", Data, 12))
|
||||
for Offset in Offsets:
|
||||
try:
|
||||
for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]):
|
||||
Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16)
|
||||
if Tag != b"name":
|
||||
continue
|
||||
_Format, Count, Strings = struct.unpack_from(">HHH", Data, Start)
|
||||
Found: dict[int, str] = {}
|
||||
for Record in range(Count):
|
||||
PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from(
|
||||
">6H", Data, Start + 6 + Record * 12)
|
||||
if NameId not in (1, 16) or NameId in Found:
|
||||
continue
|
||||
Raw = Data[Start + Strings + At:Start + Strings + At + Length]
|
||||
if PlatformId in (0, 3):
|
||||
Found[NameId] = Raw.decode("utf-16-be", "replace")
|
||||
elif PlatformId == 1 and Language == 0:
|
||||
Found[NameId] = Raw.decode("mac-roman", "replace")
|
||||
Name = (Found.get(16) or Found.get(1) or "").strip()
|
||||
if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal
|
||||
Families.add(Name)
|
||||
except struct.error:
|
||||
continue
|
||||
return Families
|
||||
|
||||
|
||||
def FontFamilies() -> set[str]:
|
||||
"""Installed font families: the system's, the shared and your own Fonts folders."""
|
||||
Families: set[str] = set()
|
||||
for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Home / "Library" / "Fonts"):
|
||||
if Folder.is_dir():
|
||||
for FilePath in Folder.rglob("*"):
|
||||
if FilePath.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
Families |= _FontFamilies(FilePath)
|
||||
return Families
|
||||
|
||||
|
||||
def OpenPath(Target: Path) -> None:
|
||||
subprocess.Popen(["open", str(Target)], env=ChildEnvironment(), start_new_session=True)
|
||||
Reference in new issue
Block a user