mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +02:00
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""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)
|