Files
SaraSuite/MacSupport.py
T

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)