mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
873a2d9ba0 | ||
|
|
ca04dcd4a7 |
No files matched your search
@@ -1,11 +1,13 @@
|
||||
"""Shared app-wide constants: paths, palette, theme.
|
||||
|
||||
Paths follow each system's own per-user conventions (no admin needed):
|
||||
Linux Windows
|
||||
~/.config %APPDATA% (settings; roams with the user)
|
||||
~/.local/share %LOCALAPPDATA% (installed programs, SaraSuite's data)
|
||||
~/.cache %LOCALAPPDATA% (caches, in a Cache/Downloads subfolder)
|
||||
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on both.
|
||||
Linux Windows macOS
|
||||
~/.config %APPDATA% ~/Library/Application Support (settings)
|
||||
~/.local/share %LOCALAPPDATA% ~/Library/Application Support (programs, SaraSuite's data)
|
||||
~/.cache %LOCALAPPDATA% ~/Library/Caches (caches)
|
||||
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on all
|
||||
three. (On macOS settings and data share one folder, as Mac apps' do: the
|
||||
programs' config folders and SaraSuite's Programs, Receipts, ... sit side by side.)
|
||||
|
||||
The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's),
|
||||
and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme
|
||||
@@ -25,10 +27,16 @@ import tomlkit
|
||||
AppName = "SaraSuite"
|
||||
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
|
||||
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
|
||||
AppVersion = "1.2.5"
|
||||
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
|
||||
AppVersion = "1.3.0"
|
||||
# SaraSuite.app's launcher sets this to the app's path (Packaging/BuildMacApp.py).
|
||||
MacApp = os.environ.get("SARASUITE_APP", "")
|
||||
# Running packaged: from the AppImage / .exe (PyInstaller), or SaraSuite.app.
|
||||
IsFrozen = bool(getattr(sys, "frozen", False)) or bool(MacApp)
|
||||
IsWindows = sys.platform == "win32"
|
||||
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform
|
||||
IsMac = sys.platform == "darwin"
|
||||
IsLinux = not IsWindows and not IsMac
|
||||
# Matches manifests' Platforms / Platform.
|
||||
Platform = "Windows" if IsWindows else "macOS" if IsMac else "Linux"
|
||||
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
||||
# without being installed. Any other FontFamily is looked up among the
|
||||
# system's installed fonts.
|
||||
@@ -44,6 +52,10 @@ if IsWindows:
|
||||
# stand-in for a systemd user service).
|
||||
StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
||||
StartupDir = StartMenuDir / "Startup"
|
||||
elif IsMac:
|
||||
ConfigHome = DataHome = Home / "Library" / "Application Support"
|
||||
CacheHome = Home / "Library" / "Caches"
|
||||
StartMenuDir = StartupDir = None
|
||||
else:
|
||||
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
|
||||
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
|
||||
@@ -71,8 +83,9 @@ ServicesDir = DataDir / "Services"
|
||||
|
||||
# Linux: whether systemd runs the system, and so --user services. Without it
|
||||
# (runit on Void, OpenRC, s6, dinit, ...) services run the way they do on
|
||||
# Windows (Services.py), started at login from AutostartDir.
|
||||
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
|
||||
# Windows (Services.py), started at login from AutostartDir. macOS runs them
|
||||
# that way too, started at login by a LaunchAgent (MacSupport.py).
|
||||
HasSystemd = IsLinux and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
|
||||
AutostartDir = ConfigHome / "autostart"
|
||||
|
||||
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
|
||||
@@ -260,7 +273,7 @@ def ChildEnvironment() -> dict[str, str]:
|
||||
Environment["LD_LIBRARY_PATH"] = Original
|
||||
else:
|
||||
Environment.pop("LD_LIBRARY_PATH", None)
|
||||
for Key in ("PYTHONHOME", "PYTHONPATH"):
|
||||
for Key in ("PYTHONHOME", "PYTHONPATH", "SARASUITE_APP"):
|
||||
Environment.pop(Key, None)
|
||||
if IsWindows:
|
||||
# Python on Windows otherwise reads/writes text in the old ANSI code
|
||||
|
||||
+91
-41
@@ -10,6 +10,7 @@ import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -22,7 +23,7 @@ from typing import Callable, Optional
|
||||
from ConfigMerge import ApplySet, MergeConfig, MergeResult
|
||||
import Services
|
||||
from Core import (
|
||||
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsWindows, LoadConfig,
|
||||
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
|
||||
PristineDir, StartMenuDir, StartupDir,
|
||||
)
|
||||
from Manifests import Leftover, Program, Render
|
||||
@@ -89,6 +90,30 @@ def _BackupName(Target: str) -> str:
|
||||
return Target.strip("/").replace("/", "__")
|
||||
|
||||
|
||||
def _CopyPath(Source: Path, Destination: Path) -> None:
|
||||
"""A file, or a folder (a macOS .app bundle) with everything in it."""
|
||||
_RemovePath(Destination)
|
||||
if Source.is_dir() and not Source.is_symlink():
|
||||
shutil.copytree(Source, Destination, symlinks=True)
|
||||
else:
|
||||
shutil.copy2(Source, Destination)
|
||||
|
||||
|
||||
def _Displace(Program: Program, Target: Path, Kind: str, OldReceipt: Optional["Receipt"]) -> PlacedFile:
|
||||
"""The receipt entry for a file SaraSuite is about to place. One that's
|
||||
already there but wasn't placed by SaraSuite (e.g. a hand-made desktop
|
||||
entry from development) is moved aside, not lost: it comes back when the
|
||||
program is removed."""
|
||||
Previous = OldReceipt.FindFile(str(Target)) if OldReceipt is not None else None
|
||||
Placed = PlacedFile(str(Target), Kind, Displaced=Previous.Displaced if Previous else "")
|
||||
if Previous is None and (Target.exists() or Target.is_symlink()):
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Target))
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CopyPath(Target, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
return Placed
|
||||
|
||||
|
||||
def _Pkexec(Command: list[str], Log: LogFn, Check: bool = True) -> int:
|
||||
"""Run Command as root through pkexec, saying what went wrong in words."""
|
||||
if shutil.which("pkexec") is None:
|
||||
@@ -342,14 +367,17 @@ def _RequirementsText(Program: Program) -> str:
|
||||
|
||||
|
||||
def _SystemPython(Program: Program, Log: LogFn) -> str:
|
||||
if not IsWindows:
|
||||
if IsLinux:
|
||||
Python = shutil.which("python3")
|
||||
if Python is None:
|
||||
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
|
||||
return Python
|
||||
import WindowsSupport
|
||||
if IsMac:
|
||||
import MacSupport as Support
|
||||
else:
|
||||
import WindowsSupport as Support
|
||||
|
||||
Python = WindowsSupport.FindPython() or WindowsSupport.InstallPython(Log)
|
||||
Python = Support.FindPython() or Support.InstallPython(Log)
|
||||
if Python is None:
|
||||
raise InstallError(f"{Program.Name} needs Python, and it couldn't be installed. Get it from https://www.python.org/downloads/")
|
||||
return Python
|
||||
@@ -380,13 +408,13 @@ def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None:
|
||||
if not Placed.Displaced or not Path(Placed.Displaced).exists():
|
||||
return
|
||||
Current = Path(Placed.Path)
|
||||
if Current.exists() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
|
||||
if Current.is_file() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
|
||||
pass # already identical (typical for a root file that was never removed)
|
||||
elif Placed.Root:
|
||||
_WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log)
|
||||
else:
|
||||
shutil.copy2(Placed.Displaced, Placed.Path)
|
||||
Path(Placed.Displaced).unlink()
|
||||
_CopyPath(Path(Placed.Displaced), Current)
|
||||
_RemovePath(Path(Placed.Displaced))
|
||||
Log(f"Restored your previous {Placed.Path}")
|
||||
|
||||
|
||||
@@ -398,7 +426,7 @@ def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None:
|
||||
_RemoveRoot(Placed.Path, Log)
|
||||
Log(f"Removed {Placed.Path}")
|
||||
else:
|
||||
Path(Placed.Path).unlink(missing_ok=True)
|
||||
_RemovePath(Path(Placed.Path)) # a folder for a macOS app bundle
|
||||
Log(f"Removed {Placed.Path}")
|
||||
_PutBackDisplaced(Placed, Log)
|
||||
|
||||
@@ -417,17 +445,9 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
|
||||
for Template in Wanted:
|
||||
Target = Render(Template.Target, Context)
|
||||
Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context)
|
||||
Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None
|
||||
Root = _NeedsRoot(Template, Target)
|
||||
Placed = PlacedFile(Target, "Template", Root=Root, Displaced=Previous.Displaced if Previous else "")
|
||||
|
||||
# A file that's already there but wasn't placed by SaraSuite (e.g. a
|
||||
# hand-made desktop entry from development) is moved aside, not lost.
|
||||
if Previous is None and Path(Target).exists():
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(Target)
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Target, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
Placed = _Displace(Program, Path(Target), "Template", OldReceipt)
|
||||
Placed.Root = Root = _NeedsRoot(Template, Target)
|
||||
if Placed.Displaced and (OldReceipt is None or OldReceipt.FindFile(Target) is None):
|
||||
Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)")
|
||||
|
||||
NewReceipt.Files.append(Placed)
|
||||
@@ -513,27 +533,42 @@ def _PlaceShortcut(Program: Program, Link: Path, Arguments: str, WorkingDir: Pat
|
||||
and isn't ours is moved aside like a template, and comes back on removal."""
|
||||
import WindowsSupport
|
||||
|
||||
Previous = OldReceipt.FindFile(str(Link)) if OldReceipt is not None else None
|
||||
Placed = PlacedFile(str(Link), "Shortcut", Displaced=Previous.Displaced if Previous else "")
|
||||
if Previous is None and Link.exists():
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Link))
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Link, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
Placed = _Displace(Program, Link, "Shortcut", OldReceipt)
|
||||
Icon = WindowsSupport.MakeIcon(IconPng, Program.ProgramDir / f"{Link.stem}.ico") if IconPng and IconPng.is_file() else None
|
||||
WindowsSupport.CreateShortcut(Link, Program.PythonW, f"-X utf8 {Arguments}", WorkingDir, Icon, Description)
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Link}")
|
||||
|
||||
|
||||
def _PlaceAppBundle(Program: Program, Link, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt,
|
||||
Log: LogFn) -> None:
|
||||
"""macOS: ~/Applications/<Name>.app, running Script with the program's
|
||||
Python from the app folder (MacSupport.CreateAppBundle)."""
|
||||
import MacSupport
|
||||
|
||||
Bundle = MacSupport.ApplicationsDir / f"{Link.Name}.app"
|
||||
Placed = _Displace(Program, Bundle, "Shortcut", OldReceipt)
|
||||
Command = [str(Program.Python), str(Program.AppDir / Link.Script), *shlex.split(Render(Link.Arguments, Context))]
|
||||
MacSupport.CreateAppBundle(
|
||||
Bundle, Link.Name, f"srhtnin.{Link.Name}", Command, Program.AppDir,
|
||||
Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description,
|
||||
)
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Bundle}")
|
||||
|
||||
|
||||
def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Windows Start Menu shortcuts ([[Shortcuts]]); the Startup ones for
|
||||
services are placed by _ApplyServices."""
|
||||
if not IsWindows:
|
||||
"""Start Menu shortcuts (Windows) and app bundles (macOS), from
|
||||
[[Shortcuts]]; the ones starting services at login are placed by
|
||||
_ApplyServices."""
|
||||
if IsLinux:
|
||||
return
|
||||
for Link in Program.Shortcuts:
|
||||
if not Program.OptionEnabled(Link.Option, NewReceipt.Options):
|
||||
continue
|
||||
if IsMac:
|
||||
_PlaceAppBundle(Program, Link, Context, OldReceipt, NewReceipt, Log)
|
||||
continue
|
||||
Script = Program.AppDir / Link.Script
|
||||
Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip()
|
||||
_PlaceShortcut(
|
||||
@@ -549,22 +584,34 @@ def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optio
|
||||
login. Recorded like a shortcut, so it goes when the service is turned off
|
||||
or the program removed (and one that was already there comes back)."""
|
||||
Entry = AutostartDir / f"{Service.Name}.desktop"
|
||||
Previous = OldReceipt.FindFile(str(Entry)) if OldReceipt is not None else None
|
||||
Placed = PlacedFile(str(Entry), "Shortcut", Displaced=Previous.Displaced if Previous else "")
|
||||
if Previous is None and Entry.exists():
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Entry))
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Entry, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
Placed = _Displace(Program, Entry, "Shortcut", OldReceipt)
|
||||
Entry.parent.mkdir(parents=True, exist_ok=True)
|
||||
Entry.write_text(Services.AutostartEntry(Service.Name, *Command))
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Entry}")
|
||||
|
||||
|
||||
def _PlaceLaunchAgent(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
|
||||
NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""macOS: the LaunchAgent that starts a service at login, recorded like
|
||||
the autostart entry above."""
|
||||
import MacSupport
|
||||
|
||||
Agent = MacSupport.LaunchAgentPath(Service.Name)
|
||||
Placed = _Displace(Program, Agent, "Shortcut", OldReceipt)
|
||||
Python, Runner, Script, WorkingDir = Command
|
||||
Agent.parent.mkdir(parents=True, exist_ok=True)
|
||||
MacSupport.LogsDir.mkdir(parents=True, exist_ok=True)
|
||||
Agent.write_bytes(MacSupport.LaunchAgent(
|
||||
Service.Name, Services.Command(Python, Runner, Service.Name, Script, WorkingDir), WorkingDir,
|
||||
))
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Agent}")
|
||||
|
||||
|
||||
def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Services SaraSuite runs itself (Windows; Linux without systemd): at
|
||||
login from a Startup-folder shortcut / an autostart entry, through
|
||||
"""Services SaraSuite runs itself (Windows; macOS; Linux without systemd):
|
||||
at login from a Startup-folder shortcut / a LaunchAgent / an autostart entry, through
|
||||
ServiceRun.py; enabling also starts it now. An update restarts it, so
|
||||
it runs the new code."""
|
||||
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
||||
@@ -579,6 +626,8 @@ def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[
|
||||
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"',
|
||||
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
|
||||
)
|
||||
elif IsMac:
|
||||
_PlaceLaunchAgent(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log)
|
||||
else:
|
||||
_PlaceAutostart(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log)
|
||||
if Service.Id in WasEnabled and not IsWindows: # Windows stopped it before the update
|
||||
@@ -632,8 +681,8 @@ def _StopProcesses(Name: str, Log: LogFn) -> None:
|
||||
|
||||
|
||||
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
|
||||
if IsWindows:
|
||||
return # Start Menu shortcuts need no refresh
|
||||
if not IsLinux:
|
||||
return # Start Menu shortcuts and app bundles need no refresh
|
||||
Applications = Path(Program.Context()["ApplicationsDir"])
|
||||
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
|
||||
_Run(["update-desktop-database", str(Applications)], Log, Check=False)
|
||||
@@ -773,6 +822,7 @@ def PlanRemoval(Program: Program) -> RemovalPlan:
|
||||
|
||||
if Current is not None:
|
||||
ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows
|
||||
else "background service, stopped and its LaunchAgent removed" if IsMac
|
||||
else "systemd user service, stopped and disabled" if HasSystemd
|
||||
else "background service, stopped and taken out of autostart")
|
||||
for Unit in Current.EnabledServices:
|
||||
@@ -832,7 +882,7 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
|
||||
_RemovePlacedTemplate(Placed, Log)
|
||||
for Record in Current.IniEdits:
|
||||
_RevertIni(Record, Log)
|
||||
if not IsWindows and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)):
|
||||
if HasSystemd and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)):
|
||||
_Systemctl(Log, "daemon-reload")
|
||||
|
||||
Groups = {"Program"}
|
||||
|
||||
+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)
|
||||
@@ -14,7 +14,7 @@ import flet as ft
|
||||
import SelfUpdate
|
||||
from Catalog import UpdateCatalog
|
||||
from Core import (
|
||||
AppName, AppVersion, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||
AppName, AppVersion, IsLinux, IsMac, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||
BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette,
|
||||
)
|
||||
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
|
||||
@@ -26,7 +26,8 @@ from Settings import ShowSettingsDialog
|
||||
_MaxLogLines = 2000
|
||||
|
||||
# When running packaged (AppImage / .exe), SaraSuite can put itself in the
|
||||
# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows.
|
||||
# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. On
|
||||
# macOS SaraSuite.app already is an app wherever it's put (e.g. /Applications).
|
||||
_Packaged = SelfUpdate.RunningAppImage()
|
||||
_AppImage = str(_Packaged) if _Packaged else ""
|
||||
if IsWindows:
|
||||
@@ -43,7 +44,7 @@ def _KeepFallbackLibraries() -> None:
|
||||
~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet
|
||||
client of every installed program on systems without it."""
|
||||
Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs"
|
||||
if IsWindows or not Bundled.is_dir():
|
||||
if not IsLinux or not Bundled.is_dir():
|
||||
return
|
||||
Kept = DataDir / "Libraries"
|
||||
try:
|
||||
@@ -286,7 +287,7 @@ def Main(Page: ft.Page) -> None:
|
||||
MenuButton = ft.OutlinedButton(
|
||||
content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}",
|
||||
style=ChipButtonStyle(),
|
||||
visible=bool(_AppImage),
|
||||
visible=bool(_AppImage) and not IsMac,
|
||||
on_click=ToggleMenuEntry,
|
||||
)
|
||||
|
||||
@@ -355,7 +356,7 @@ if __name__ == "__main__":
|
||||
|
||||
sys.exit(Run(sys.argv[1:]))
|
||||
SelfUpdate.CleanUpOldExe()
|
||||
if not IsWindows:
|
||||
if IsLinux:
|
||||
# A Flet client copy with the window app ID srhtnin.SaraSuite, so the
|
||||
# desktop matches the window to SaraSuite.desktop (StartupWMClass) instead
|
||||
# of showing it as "com.appveyor.flet". See Programs/FletIdentity.py.
|
||||
|
||||
+19
-11
@@ -3,8 +3,9 @@ and Hooks.py next to it. See README.md for the full manifest format.
|
||||
|
||||
A manifest's Platforms (default ["Linux"]) says where the program runs; each
|
||||
SaraSuite only lists the programs for its own system. Any list item can also
|
||||
carry Platform = "Linux" or "Windows" to only apply there (e.g. desktop
|
||||
entries on Linux, Start Menu shortcuts on Windows); others are dropped at load."""
|
||||
carry Platform = "Linux", "Windows" or "macOS" to only apply there (e.g.
|
||||
desktop entries on Linux, Start Menu shortcuts on Windows, app bundles on
|
||||
macOS); others are dropped at load."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
@@ -16,7 +17,7 @@ from typing import Optional
|
||||
import tomlkit
|
||||
|
||||
from Core import (
|
||||
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig,
|
||||
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
|
||||
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported,
|
||||
)
|
||||
|
||||
@@ -86,10 +87,11 @@ class IniEdit:
|
||||
@dataclass
|
||||
class Service:
|
||||
"""A background service. Linux with systemd: a --user Unit (placed by a
|
||||
Template). Windows, and Linux without systemd (runit, OpenRC, ...):
|
||||
Template). Windows, macOS, and Linux without systemd (runit, OpenRC, ...):
|
||||
Script (relative to the app folder) run by ServiceRun.py in WorkingDir,
|
||||
at login through a Startup-folder shortcut / an XDG autostart entry
|
||||
(Services.py), so a Linux service gives both Unit and Name/Script."""
|
||||
at login through a Startup-folder shortcut / a LaunchAgent / an XDG
|
||||
autostart entry (Services.py), so a Linux service gives both Unit and
|
||||
Name/Script."""
|
||||
|
||||
Unit: str = ""
|
||||
Option: str = "" # enabled + started only while this option is ticked
|
||||
@@ -109,9 +111,12 @@ class Service:
|
||||
|
||||
@dataclass
|
||||
class Shortcut:
|
||||
"""Windows: a Start Menu shortcut running Script (relative to the app
|
||||
folder) with the program's pythonw.exe, from the app folder, with Icon
|
||||
(a PNG in the app folder, turned into an .ico)."""
|
||||
"""A launcher in the system's app list running Script (relative to the
|
||||
app folder) with the program's Python, from the app folder, with Icon (a
|
||||
PNG in the app folder):
|
||||
- Windows: a Start Menu shortcut (pythonw.exe; the icon becomes an .ico);
|
||||
- macOS: an app bundle, ~/Applications/<Name>.app (the icon becomes an .icns).
|
||||
Linux uses a desktop entry Template instead."""
|
||||
|
||||
Name: str
|
||||
Script: str
|
||||
@@ -235,6 +240,9 @@ class Program:
|
||||
}
|
||||
if IsWindows:
|
||||
Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir))
|
||||
elif IsMac:
|
||||
Context.update(ApplicationsDir=str(Home / "Applications"),
|
||||
LaunchAgentsDir=str(Home / "Library" / "LaunchAgents"))
|
||||
else:
|
||||
Context.update(
|
||||
SystemdUserDir=str(ConfigHome / "systemd" / "user"),
|
||||
@@ -306,7 +314,7 @@ def LoadProgram(Folder: Path) -> Program:
|
||||
Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))],
|
||||
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
|
||||
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
|
||||
RestartUnits=[] if IsWindows else Data.get("RestartUnits", []),
|
||||
RestartUnits=Data.get("RestartUnits", []) if IsLinux else [],
|
||||
Leftovers=[Leftover(**Item) for Item in _ForPlatform(Data.get("Leftovers", []))],
|
||||
Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))],
|
||||
Platforms=Data.get("Platforms", ["Linux"]),
|
||||
@@ -330,7 +338,7 @@ def LoadPrograms() -> list[Program]:
|
||||
Loaded = _LoadFrom(ManifestsDir())
|
||||
except Exception:
|
||||
Loaded = _LoadFrom(BundledManifestsDir)
|
||||
# Only the programs that run on this system (the .exe lists Windows ones).
|
||||
# Only the programs that run on this system (the .exe lists Windows ones, the Mac app macOS ones).
|
||||
Loaded = [Current for Current in Loaded if Platform in Current.Platforms]
|
||||
Names = {Current.Name for Current in Loaded}
|
||||
Ordered = []
|
||||
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build dist/SaraSuite-macOS.zip (SaraSuite.app, Apple Silicon) on Linux.
|
||||
|
||||
PyInstaller can only build a Mac app on a Mac, so this puts the app together
|
||||
from parts that need no compiling:
|
||||
- a standalone Python for macOS (python-build-standalone, the Pythons uv installs);
|
||||
- SaraSuite's requirements as macOS wheels (pip download for the Mac platform);
|
||||
- the Flet desktop client for macOS, bundled where flet-desktop looks for it,
|
||||
so the first start needs no download;
|
||||
- SaraSuite's own code, assets and Programs/;
|
||||
- Contents/MacOS/SaraSuite, a shell script that runs Main.py with that Python.
|
||||
|
||||
The app isn't signed by an Apple developer account, so the first start needs
|
||||
System Settings > Privacy & Security > Open Anyway (see the website's
|
||||
instructions). Its launcher then clears the download quarantine from the
|
||||
whole app, so the Python and Flet inside it run too.
|
||||
|
||||
Packaging/BuildMacApp.py # also run by Packaging/Release.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
RepoDir = Path(__file__).resolve().parent.parent
|
||||
BuildDir = RepoDir / "build" / "MacBuild"
|
||||
DownloadsDir = BuildDir / "Downloads" # kept between builds
|
||||
BuildVenv = BuildDir / "Venv" # Pillow, for the .icns
|
||||
Output = RepoDir / "dist" / "SaraSuite-macOS.zip"
|
||||
|
||||
PythonVersion = "3.14"
|
||||
MacPlatform = "macosx_11_0_arm64"
|
||||
PythonTarget = "aarch64-apple-darwin"
|
||||
StandaloneReleases = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"
|
||||
AppIdentifier = "srhtnin.SaraSuite"
|
||||
UserAgent = "SaraSuite-Build (Python)"
|
||||
|
||||
# The launcher won't run from the read-only copy macOS makes of a quarantined
|
||||
# app that wasn't moved out of Downloads (App Translocation): neither clearing
|
||||
# the quarantine nor self-updating would work there.
|
||||
Launcher = '''#!/bin/sh
|
||||
# SaraSuite.app's launcher (written by Packaging/BuildMacApp.py).
|
||||
Contents="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
App="$(dirname "$Contents")"
|
||||
case "$App" in
|
||||
*/AppTranslocation/*)
|
||||
/usr/bin/osascript -e 'display alert "Move SaraSuite to Applications first" message "Drag SaraSuite from your Downloads folder into Applications, then open it from there."' >/dev/null 2>&1
|
||||
exit 1 ;;
|
||||
esac
|
||||
# Downloaded apps are quarantined, the Python and Flet inside included. Once
|
||||
# macOS has let SaraSuite itself open, the rest of it may run too.
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$App" 2>/dev/null
|
||||
export SARASUITE_APP="$App"
|
||||
exec "$Contents/Resources/Python/bin/python3" "$Contents/Resources/SaraSuite/Main.py" "$@"
|
||||
'''
|
||||
|
||||
|
||||
def Download(Url: str, Name: str) -> Path:
|
||||
"""Into DownloadsDir, once."""
|
||||
Target = DownloadsDir / Name
|
||||
if Target.is_file():
|
||||
return Target
|
||||
print(f"Downloading {Url}")
|
||||
DownloadsDir.mkdir(parents=True, exist_ok=True)
|
||||
Request = urllib.request.Request(Url, headers={"User-Agent": UserAgent})
|
||||
with urllib.request.urlopen(Request, timeout=600) as Response:
|
||||
Partial = Target.with_suffix(".part")
|
||||
Partial.write_bytes(Response.read())
|
||||
Partial.replace(Target)
|
||||
return Target
|
||||
|
||||
|
||||
def StandalonePython() -> Path:
|
||||
"""The latest python-build-standalone release of PythonVersion for Apple Silicon."""
|
||||
Request = urllib.request.Request(StandaloneReleases, headers={"User-Agent": UserAgent})
|
||||
with urllib.request.urlopen(Request, timeout=60) as Response:
|
||||
Assets = json.load(Response)["assets"]
|
||||
Pattern = re.compile(rf"cpython-{re.escape(PythonVersion)}\.\d+\+\d+-{PythonTarget}-install_only_stripped\.tar\.gz")
|
||||
Asset = next((Item for Item in Assets if Pattern.fullmatch(Item["name"])), None)
|
||||
if Asset is None:
|
||||
sys.exit(f"BuildMacApp.py: no Python {PythonVersion} for {PythonTarget} in the latest python-build-standalone release")
|
||||
return Download(Asset["browser_download_url"], Asset["name"])
|
||||
|
||||
|
||||
def MakeIcns(Png: Path, Icns: Path) -> None:
|
||||
"""Same as MacSupport.MakeIcns: whole multiples of the 32x32 art grid."""
|
||||
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)]
|
||||
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
|
||||
|
||||
|
||||
def CopyCode(Destination: Path) -> None:
|
||||
Ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||
Destination.mkdir(parents=True)
|
||||
for Source in sorted(RepoDir.glob("*.py")):
|
||||
shutil.copy2(Source, Destination / Source.name)
|
||||
shutil.copytree(RepoDir / "assets", Destination / "assets", ignore=Ignore)
|
||||
shutil.copytree(RepoDir / "Programs", Destination / "Programs", ignore=Ignore)
|
||||
|
||||
|
||||
def Zip(Bundle: Path, Target: Path) -> None:
|
||||
"""Zip the app keeping what a Mac app needs: execute bits and symlinks
|
||||
(Python's bin/python3 is one), the way Finder's Archive Utility and
|
||||
ditto read them back."""
|
||||
Target.parent.mkdir(parents=True, exist_ok=True)
|
||||
Partial = Target.with_suffix(".zip.new")
|
||||
with zipfile.ZipFile(Partial, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as Archive:
|
||||
for Current in [Bundle, *sorted(Bundle.rglob("*"))]:
|
||||
Name = Current.relative_to(Bundle.parent).as_posix()
|
||||
Mode = Current.lstat().st_mode
|
||||
Stamp = time.localtime(max(Current.lstat().st_mtime, 315532800))[:6] # zip dates start in 1980
|
||||
if Current.is_symlink():
|
||||
Info = zipfile.ZipInfo(Name, Stamp)
|
||||
Info.create_system = 3 # Unix, so the mode below counts
|
||||
Info.external_attr = (stat.S_IFLNK | 0o755) << 16
|
||||
Archive.writestr(Info, os.readlink(Current))
|
||||
elif Current.is_dir():
|
||||
Info = zipfile.ZipInfo(Name + "/", Stamp)
|
||||
Info.create_system = 3
|
||||
Info.external_attr = (stat.S_IFDIR | 0o755) << 16 | 0x10
|
||||
Archive.writestr(Info, b"")
|
||||
else:
|
||||
Info = zipfile.ZipInfo.from_file(Current, Name)
|
||||
Info.create_system = 3
|
||||
Info.external_attr = (stat.S_IFREG | (0o755 if Mode & 0o111 else 0o644)) << 16
|
||||
Info.compress_type = zipfile.ZIP_DEFLATED
|
||||
Archive.writestr(Info, Current.read_bytes())
|
||||
Partial.replace(Target)
|
||||
|
||||
|
||||
def Main() -> None:
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
# Pillow for the icon: run this script again from a build venv that has it.
|
||||
if not (BuildVenv / "bin" / "python").exists():
|
||||
subprocess.run([sys.executable, "-m", "venv", str(BuildVenv)], check=True)
|
||||
subprocess.run([str(BuildVenv / "bin" / "python"), "-m", "pip", "install", "--quiet", "pillow"], check=True)
|
||||
os.execv(BuildVenv / "bin" / "python", [str(BuildVenv / "bin" / "python"), __file__, *sys.argv[1:]])
|
||||
|
||||
Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1]
|
||||
Stage = BuildDir / "Stage"
|
||||
shutil.rmtree(Stage, ignore_errors=True)
|
||||
Bundle = Stage / "SaraSuite.app"
|
||||
Contents = Bundle / "Contents"
|
||||
Resources = Contents / "Resources"
|
||||
(Contents / "MacOS").mkdir(parents=True)
|
||||
Resources.mkdir()
|
||||
|
||||
# Python, unpacked as Resources/Python.
|
||||
with tarfile.open(StandalonePython()) as Tar:
|
||||
Tar.extractall(Resources, filter="tar")
|
||||
(Resources / "python").rename(Resources / "Python")
|
||||
SitePackages = next((Resources / "Python" / "lib").glob("python3.*")) / "site-packages"
|
||||
Minor = SitePackages.parent.name.removeprefix("python")
|
||||
|
||||
# SaraSuite's requirements as macOS wheels, straight into that Python.
|
||||
# (Pillow and flet-desktop are only for macOS, so they're named here: pip
|
||||
# judges requirements.txt's markers by this Linux machine.)
|
||||
Requirements = [Line for Line in (RepoDir / "requirements.txt").read_text().splitlines()
|
||||
if Line.strip() and not Line.startswith("#") and "sys_platform" not in Line]
|
||||
print("Installing the macOS wheels")
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "--no-compile",
|
||||
"--target", str(SitePackages), "--platform", MacPlatform, "--python-version", Minor,
|
||||
"--implementation", "cp", "--abi", f"cp{Minor.replace('.', '')}", "--only-binary=:all:",
|
||||
*Requirements, "pillow", "flet-desktop",
|
||||
], check=True)
|
||||
FletVersion = re.search(r'version = "([^"]+)"', (SitePackages / "flet_desktop" / "version.py").read_text())[1]
|
||||
Wanted = re.search(r"^Version: (.+)$", next(SitePackages.glob("flet-*.dist-info/METADATA")).read_text(), re.MULTILINE)[1]
|
||||
if FletVersion != Wanted:
|
||||
sys.exit(f"BuildMacApp.py: flet {Wanted} but flet-desktop {FletVersion}")
|
||||
|
||||
# The Flet client, where flet-desktop looks for a bundled one first.
|
||||
Client = Download(f"https://github.com/flet-dev/flet/releases/download/v{FletVersion}/flet-macos.tar.gz",
|
||||
f"flet-macos-{FletVersion}.tar.gz")
|
||||
(SitePackages / "flet_desktop" / "app").mkdir(exist_ok=True)
|
||||
shutil.copy2(Client, SitePackages / "flet_desktop" / "app" / "flet-macos.tar.gz")
|
||||
|
||||
CopyCode(Resources / "SaraSuite")
|
||||
MakeIcns(RepoDir / "assets" / "Icon.png", Resources / "SaraSuite.icns")
|
||||
Executable = Contents / "MacOS" / "SaraSuite"
|
||||
Executable.write_text(Launcher)
|
||||
Executable.chmod(0o755)
|
||||
with (Contents / "Info.plist").open("wb") as File:
|
||||
plistlib.dump({
|
||||
"CFBundleName": "SaraSuite",
|
||||
"CFBundleDisplayName": "SaraSuite",
|
||||
"CFBundleIdentifier": AppIdentifier,
|
||||
"CFBundleExecutable": "SaraSuite",
|
||||
"CFBundleIconFile": "SaraSuite.icns",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleShortVersionString": Version,
|
||||
"CFBundleVersion": Version,
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
"LSArchitecturePriority": ["arm64"],
|
||||
"NSHighResolutionCapable": True,
|
||||
"NSHumanReadableCopyright": "Install, update and remove Sara programs",
|
||||
}, File)
|
||||
|
||||
Zip(Bundle, Output)
|
||||
print(f"Built {Output.relative_to(RepoDir)} ({Output.stat().st_size // 1_000_000} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Main()
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a SaraSuite release: build the AppImage (Linux) and SaraSuite.exe
|
||||
(Windows, built under Wine by BuildExe.sh), create the Gitea release
|
||||
v<AppVersion> (from Core.py) with both attached, and copy them to the Shared
|
||||
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py),
|
||||
"""Publish a SaraSuite release: build the AppImage (Linux), SaraSuite.exe
|
||||
(Windows, built under Wine by BuildExe.sh) and SaraSuite-macOS.zip (Apple
|
||||
Silicon, put together by BuildMacApp.py), create the Gitea release
|
||||
v<AppVersion> (from Core.py) with all three attached, and copy them to the
|
||||
Shared folder. Running SaraSuites then offer to update themselves (SelfUpdate.py),
|
||||
each to its own platform's file.
|
||||
|
||||
Packaging/Release.py # after bumping AppVersion in Core.py
|
||||
@@ -31,6 +32,7 @@ RepoDir = Path(__file__).resolve().parent.parent
|
||||
Builds = [
|
||||
("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"),
|
||||
("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"),
|
||||
("Packaging/BuildMacApp.py", RepoDir / "dist" / "SaraSuite-macOS.zip"),
|
||||
]
|
||||
SharedDir = Path("/mnt/files/Shared/SaraSuite")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Name = "SaraFem"
|
||||
Description = "Desktop file manager that understands SaraTag Books, and (on Linux) can stand in as the system file picker."
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
|
||||
# The portal reads its descriptors and preferences once at startup.
|
||||
RestartUnits = ["xdg-desktop-portal.service"]
|
||||
@@ -77,6 +77,13 @@ Name = "SaraFem"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraFem.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraFem"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
[[Leftovers]]
|
||||
Path = "{CacheHome}/SaraFem"
|
||||
Kind = "Runtime"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
|
||||
Name = "SaraSort"
|
||||
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
|
||||
|
||||
[Source]
|
||||
@@ -31,6 +31,13 @@ Name = "SaraSort"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraSort.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraSort"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Name = "SaraSortd"
|
||||
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background."
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
|
||||
[Version]
|
||||
File = "SaraSortd.py"
|
||||
@@ -46,10 +46,11 @@ Platform = "Linux"
|
||||
Source = "SaraSortd.service"
|
||||
Target = "{SystemdUserDir}/SaraSortd.service"
|
||||
|
||||
# Windows, and Linux without systemd (runit, OpenRC, s6, ...): run from
|
||||
# ConfigDir by ServiceRun.py, which restarts it if it fails, at login through
|
||||
# a shortcut in the Startup folder / an XDG autostart entry (SaraSuite's
|
||||
# Services.py). On Linux the same service has a Unit for systemd too.
|
||||
# Windows, macOS, and Linux without systemd (runit, OpenRC, s6, ...): run
|
||||
# from ConfigDir by ServiceRun.py, which restarts it if it fails, at login
|
||||
# through a shortcut in the Startup folder / a LaunchAgent / an XDG autostart
|
||||
# entry (SaraSuite's Services.py). On Linux the same service has a Unit for
|
||||
# systemd too.
|
||||
[[Templates]]
|
||||
Source = "../../ServiceRun.py"
|
||||
Target = "{ProgramDir}/ServiceRun.py"
|
||||
@@ -69,5 +70,12 @@ Script = "SaraSortd.py"
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
[[Services]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraSortd"
|
||||
Script = "SaraSortd.py"
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
# Everything SaraSortd makes inside the sorting folders is worked out from
|
||||
# GlobalConf.toml by Hooks.py.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/.
|
||||
Name = "SaraTag"
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "Tag files with Books (sidecars) defined by Lexicons: browse, filter and sort by tag, edit tags on many files at once."
|
||||
|
||||
[Version]
|
||||
@@ -40,6 +40,13 @@ Name = "SaraTag"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraTag.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraTag"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraTag itself. The Lexicons are your tag definitions: only tick
|
||||
# "remove configs" if you don't need them any more.
|
||||
[[Leftovers]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/.
|
||||
Name = "SaraTune"
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "A music player with two modes: Audio to open a file or folder and listen right away, Music for your library, a queue and saved playlists. Linux needs libmpv (Fedora: mpv-libs)."
|
||||
|
||||
[Source]
|
||||
@@ -30,6 +30,13 @@ Name = "SaraTune"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraTune.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraTune"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraTune itself.
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/.
|
||||
Name = "SaraView"
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server."
|
||||
|
||||
[Source]
|
||||
@@ -32,6 +32,13 @@ Name = "SaraView"
|
||||
Script = "Client/Main.py"
|
||||
Icon = "Client/assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraView.app (python Client/Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraView"
|
||||
Script = "Client/Main.py"
|
||||
Icon = "Client/assets/Icon.png"
|
||||
|
||||
# Made by SaraView itself: Config.toml and Session.toml (your saved login).
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
|
||||
+17
-8
@@ -1,8 +1,8 @@
|
||||
"""Runs a SaraSuite program as a background service where there's no systemd
|
||||
--user to do it: on Windows (started windowless with pythonw.exe), and on
|
||||
Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
|
||||
Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG
|
||||
autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
|
||||
--user to do it: on Windows (started windowless with pythonw.exe), on macOS,
|
||||
and on Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
|
||||
Started at login (Windows: a shortcut in the Startup folder; macOS: a
|
||||
LaunchAgent; Linux: an XDG autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
|
||||
for SaraSortd) starts it:
|
||||
|
||||
python ServiceRun.py <Name> <Script> [<WorkingDir>]
|
||||
@@ -12,7 +12,8 @@ seconds after it fails (an exception or a non-zero exit), not after a clean
|
||||
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
|
||||
anything can tell whether it runs, and stop it, without admin rights. Both
|
||||
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
|
||||
on Windows, ~/.local/share/SaraSuite/Services on Linux.
|
||||
on Windows, ~/Library/Application Support/SaraSuite/Services on macOS,
|
||||
~/.local/share/SaraSuite/Services on Linux.
|
||||
|
||||
Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
|
||||
"""
|
||||
@@ -20,6 +21,7 @@ Installed next to each program that has a service (Programs/<Name>/Manifest.toml
|
||||
import os
|
||||
import runpy
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
@@ -27,6 +29,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
IsWindows = sys.platform == "win32"
|
||||
IsMac = sys.platform == "darwin"
|
||||
RestartSeconds = 10 # the unit's RestartSec
|
||||
MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1
|
||||
Stopping = False
|
||||
@@ -35,22 +38,28 @@ Stopping = False
|
||||
def ServicesDir() -> Path:
|
||||
if IsWindows:
|
||||
Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
||||
elif IsMac:
|
||||
Base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
|
||||
return Base / "SaraSuite" / "Services"
|
||||
|
||||
|
||||
def _AlreadyRunning(PidFile: Path) -> bool:
|
||||
"""Linux: whether the PID file's process is another ServiceRun.py still
|
||||
running (not a reused PID). Windows relies on whoever starts it to check."""
|
||||
"""Linux and macOS: whether the PID file's process is another
|
||||
ServiceRun.py still running (not a reused PID). Windows relies on whoever
|
||||
starts it to check."""
|
||||
if IsWindows:
|
||||
return False
|
||||
try:
|
||||
Pid = int(PidFile.read_text().strip())
|
||||
if Pid == os.getpid():
|
||||
return False
|
||||
if IsMac: # no /proc
|
||||
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
|
||||
return "ServiceRun.py" in Output
|
||||
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
|
||||
except (OSError, ValueError):
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -79,34 +79,44 @@ an option off takes out what it placed and puts back any file it had replaced; t
|
||||
places it. It reapplies the installed commit, so the program itself isn't updated. The
|
||||
**Update** button only shows up when there's an update (or a broken install to repair).
|
||||
|
||||
## Windows
|
||||
## Windows and macOS
|
||||
|
||||
`SaraSuite.exe` is the Windows version. Each build only lists the programs for its own system
|
||||
(a manifest's `Platforms`), and does everything the Windows way:
|
||||
`SaraSuite.exe` is the Windows version, `SaraSuite-macOS.zip` (SaraSuite.app) the macOS one.
|
||||
Each one only lists the programs for its own system (a manifest's
|
||||
`Platforms`), and does everything that system's way:
|
||||
|
||||
| Linux | Windows |
|
||||
| --- | --- |
|
||||
| `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` |
|
||||
| `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` |
|
||||
| `~/.cache/...` | `%LOCALAPPDATA%\...` |
|
||||
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) |
|
||||
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder; its PID is in `%LOCALAPPDATA%\SaraSuite\Services\<Name>.pid` |
|
||||
| Linux | Windows | macOS |
|
||||
| --- | --- | --- |
|
||||
| `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` | `~/Library/Application Support/SaraSuite/` |
|
||||
| `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` | the same folder as the settings, as Mac apps do |
|
||||
| `~/.cache/...` | `%LOCALAPPDATA%\...` | `~/Library/Caches/...` |
|
||||
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) | `~/Applications/<Name>.app`, a small app bundle (`[[Shortcuts]]`) |
|
||||
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder | `ServiceRun.py`, started at login by a LaunchAgent, `~/Library/LaunchAgents/srhtnin.<Name>.plist` |
|
||||
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget | a standalone Python that uv installs into SaraSuite's own folder (`Python/`, uv itself in `Tools/`); no admin, Homebrew or Xcode tools |
|
||||
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) | `SaraSuite-macOS.zip` self-update (unpacked next to `SaraSuite.app`, then swapped in) |
|
||||
|
||||
A service run by `ServiceRun.py` restarts 10 seconds after it fails, like the unit's
|
||||
`Restart=on-failure`, and keeps its output in SaraSuite's `Services/<Name>.log`, its PID next to it
|
||||
in `<Name>.pid` (that's how SaraSuite and SaraSort tell it runs, and stop it).
|
||||
|
||||
Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs
|
||||
services like Windows does (`Services.py`): `ServiceRun.py` runs them, started at login by an XDG
|
||||
autostart entry, `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the
|
||||
init system is. It restarts a service 10 seconds after it fails, like the unit's
|
||||
`Restart=on-failure`, and keeps its output in `~/.local/share/SaraSuite/Services/<Name>.log` (its
|
||||
PID next to it, in `<Name>.pid`). Units aren't written there, and `RestartUnits` stops the process
|
||||
instead (D-Bus starts `xdg-desktop-portal` again when it's needed). A window manager that doesn't
|
||||
run XDG autostart entries needs to start them itself (e.g. `dex -a` in its startup config).
|
||||
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget |
|
||||
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) |
|
||||
services the same way (`Services.py`), started at login by an XDG autostart entry,
|
||||
`~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the init system is.
|
||||
Units aren't written there, and `RestartUnits` stops the process instead (D-Bus starts
|
||||
`xdg-desktop-portal` again when it's needed). A window manager that doesn't run XDG autostart
|
||||
entries needs to start them itself (e.g. `dex -a` in its startup config).
|
||||
|
||||
Programs run on Windows with `pythonw.exe -X utf8` (no console window; UTF-8 mode, since Python
|
||||
on Windows otherwise reads text files in the old ANSI code page). Nothing needs admin rights.
|
||||
Linux-only features (SaraFem's file picker portal and default-file-manager option) simply
|
||||
aren't offered there.
|
||||
on Windows otherwise reads text files in the old ANSI code page). Nothing needs admin rights on
|
||||
either system. Linux-only features (SaraFem's file picker portal and default-file-manager option)
|
||||
simply aren't offered there, and on macOS SaraTune has no media keys (see its README).
|
||||
|
||||
macOS is written but not tested on a real Mac yet (Apple Silicon only). Things to know there:
|
||||
- SaraSortd sorting `~/Downloads`, `~/Documents` or `~/Desktop` needs permission: macOS asks the
|
||||
first time, or the Python in `~/Library/Application Support/SaraSuite/Python/` can be given
|
||||
Full Disk Access in System Settings > Privacy & Security.
|
||||
- A program's Dock icon is the Flet client's, not its own; its app bundle in `~/Applications`
|
||||
(what Launchpad and Spotlight show) has the right one.
|
||||
|
||||
## Running and building
|
||||
|
||||
@@ -116,6 +126,13 @@ aren't offered there.
|
||||
- Windows: `Packaging/BuildExe.sh` builds `dist/SaraSuite.exe` with the Windows Python inside a
|
||||
Wine prefix (`build/WinBuild/`, set up on its first run), since PyInstaller can't build for
|
||||
Windows from Linux directly. The .exe's header has an "Add to Start menu" button.
|
||||
- macOS: `Packaging/BuildMacApp.py` puts `dist/SaraSuite-macOS.zip` together on Linux, since
|
||||
PyInstaller can only build a Mac app on a Mac: a standalone Mac Python
|
||||
(python-build-standalone), SaraSuite's requirements as macOS wheels, the Flet client for macOS
|
||||
and SaraSuite's code, run by a shell-script launcher (which sets `SARASUITE_APP`, so SaraSuite
|
||||
knows it's packaged). It isn't signed with an Apple developer account, so the first start needs
|
||||
System Settings > Privacy & Security > Open Anyway; the launcher then clears the download
|
||||
quarantine from the whole app. It has to be moved out of Downloads (into Applications) first.
|
||||
- From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then
|
||||
`Packaging/Launch.sh`.
|
||||
- Without a desktop (e.g. a server), the same file works from a terminal:
|
||||
@@ -131,7 +148,7 @@ aren't offered there.
|
||||
|
||||
## Where things live
|
||||
|
||||
On Linux (on Windows, the same layout under `%LOCALAPPDATA%` and `%APPDATA%`; see Windows above):
|
||||
On Linux (on Windows and macOS, the same layout in their own folders; see Windows and macOS above):
|
||||
|
||||
| Path | What |
|
||||
| --- | --- |
|
||||
@@ -155,17 +172,18 @@ Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `H
|
||||
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `PortalsDir` (your own xdg-desktop-portal folder where it's 1.17+, else
|
||||
`/usr/share/...`), `PortalsConf` (`<desktop>-portals.conf`, or `portals.conf` without a desktop name),
|
||||
`DesktopPortal` (`<desktop>;` or nothing), `SystemdUserDir`,
|
||||
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`.
|
||||
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`. macOS only:
|
||||
`ApplicationsDir` (`~/Applications`), `LaunchAgentsDir`.
|
||||
|
||||
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
|
||||
`Platform = "Linux"` or `"Windows"` to only apply on that system; items without one apply on both.
|
||||
`Platform = "Linux"`, `"Windows"` or `"macOS"` to only apply on that system; items without one apply everywhere.
|
||||
In TOML templates, write paths as literal strings (`'{ConfigDir}'`): Windows paths are full of
|
||||
backslashes, which a `"..."` string would read as escapes.
|
||||
|
||||
```toml
|
||||
Name = "Example"
|
||||
Description = "Shown on the program's card."
|
||||
Platforms = ["Linux", "Windows"] # where it runs (default ["Linux"])
|
||||
Platforms = ["Linux", "Windows", "macOS"] # where it runs (default ["Linux"])
|
||||
Parent = "SaraSortd" # optional: listed (indented) under that program
|
||||
RestartUnits = ["xdg-desktop-portal.service"] # try-restarted after install/update/remove
|
||||
|
||||
@@ -215,7 +233,8 @@ WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
|
||||
Platform = "Windows" # (both: add ../../ServiceRun.py as a Template to {ProgramDir})
|
||||
Platform = "Windows" # (macOS: the same with Platform = "macOS", from a LaunchAgent;
|
||||
# both: add ../../ServiceRun.py as a Template to {ProgramDir})
|
||||
Name = "Example"
|
||||
Script = "Example.py" # relative to the app folder
|
||||
WorkingDir = "{ConfigDir}"
|
||||
@@ -226,6 +245,8 @@ Platform = "Windows"
|
||||
Name = "Example"
|
||||
Script = "Main.py" # relative to the app folder, which is also its working folder
|
||||
Icon = "assets/Icon.png" # turned into an .ico
|
||||
# macOS: the same with Platform = "macOS" makes ~/Applications/<Name>.app
|
||||
# (the venv's python Script; the icon becomes an .icns)
|
||||
|
||||
[[IniEdits]] # one key in a file SaraSuite doesn't own; restored on removal
|
||||
File = "{ConfigHome}/mimeapps.list"
|
||||
|
||||
+44
-6
@@ -1,27 +1,33 @@
|
||||
"""SaraSuite updating itself from its Gitea releases.
|
||||
|
||||
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
|
||||
AppImage and SaraSuite.exe attached. Here SaraSuite asks Gitea for the latest
|
||||
AppImage and SaraSuite.exe attached (and SaraSuite-macOS.zip, once there's
|
||||
a Mac build). Here SaraSuite asks Gitea for the latest
|
||||
release, and if it's newer than AppVersion and SaraSuite runs packaged
|
||||
(AppImage / .exe), downloads its own platform's file and swaps it in, then
|
||||
restarts into it. The file keeps its path, so its menu entry keeps working.
|
||||
- Linux: the download is renamed over the running AppImage (allowed).
|
||||
- Windows: a running .exe can't be overwritten but can be renamed, so the
|
||||
old one becomes SaraSuite.old.exe (deleted on the next start).
|
||||
- macOS: SaraSuite.app (a folder, Packaging/BuildMacApp.py) comes zipped; it's
|
||||
unpacked next to the running one, which is renamed aside and deleted once
|
||||
the new one is in place.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsWindows, LoadConfig
|
||||
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig, MacApp
|
||||
from Sources import GetBytes, GetJson, SourceError
|
||||
|
||||
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-x86_64.AppImage"
|
||||
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,9 +38,12 @@ class Release:
|
||||
|
||||
def RunningAppImage() -> Optional[Path]:
|
||||
"""The packaged SaraSuite file that's running: the AppImage on Linux, the
|
||||
.exe on Windows. None when running from source."""
|
||||
.exe on Windows, the SaraSuite.app bundle on macOS. None when running
|
||||
from source."""
|
||||
if IsWindows:
|
||||
return Path(sys.executable) if IsFrozen else None
|
||||
if IsMac:
|
||||
return Path(MacApp) if MacApp.endswith(".app") else None
|
||||
Value = os.environ.get("APPIMAGE", "")
|
||||
return Path(Value) if Value else None
|
||||
|
||||
@@ -46,6 +55,25 @@ def CleanUpOldExe() -> None:
|
||||
Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _InstallMacBundle(Target: Path, Data: bytes) -> None:
|
||||
"""Unpack the zipped SaraSuite.app next to the running one (same volume,
|
||||
so the renames are instant), then swap the two. ditto keeps what a Mac
|
||||
app needs: permissions, symlinks inside its frameworks, its signature."""
|
||||
with tempfile.TemporaryDirectory(dir=Target.parent, prefix=".SaraSuite-update-") as Temporary:
|
||||
Archive = Path(Temporary) / AssetName
|
||||
Archive.write_bytes(Data)
|
||||
Unpacked = Path(Temporary) / "Unpacked"
|
||||
subprocess.run(["ditto", "-x", "-k", str(Archive), str(Unpacked)], check=True, capture_output=True)
|
||||
New = next(Unpacked.glob("*.app"), None)
|
||||
if New is None:
|
||||
raise OSError(f"{AssetName} has no app in it; leaving the current one alone.")
|
||||
Old = Target.with_name(f"{Target.stem}.old.app")
|
||||
shutil.rmtree(Old, ignore_errors=True)
|
||||
Target.rename(Old)
|
||||
New.rename(Target)
|
||||
shutil.rmtree(Old, ignore_errors=True)
|
||||
|
||||
|
||||
def _VersionTuple(Version: str) -> tuple[int, ...]:
|
||||
Parts = []
|
||||
for Part in Version.strip().lstrip("vV").split("."):
|
||||
@@ -88,8 +116,15 @@ def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
|
||||
Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}")
|
||||
Log(f"Downloading {Latest.AssetUrl}")
|
||||
Data = GetBytes(Latest.AssetUrl)
|
||||
if not Data.startswith(b"MZ" if IsWindows else b"\x7fELF"):
|
||||
if not Data.startswith(b"MZ" if IsWindows else b"PK" if IsMac else b"\x7fELF"):
|
||||
raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.")
|
||||
if IsMac:
|
||||
try:
|
||||
_InstallMacBundle(Target, Data)
|
||||
except subprocess.CalledProcessError as Error:
|
||||
raise OSError(f"Couldn't unpack {AssetName}: {Error.stderr.decode(errors='replace').strip()}") from Error
|
||||
Log(f"== {AppName} updated; it's the new version from the next start")
|
||||
return Target
|
||||
Temporary = Target.with_name(Target.name + ".new")
|
||||
Temporary.write_bytes(Data)
|
||||
if IsWindows:
|
||||
@@ -115,7 +150,10 @@ def Restart() -> None:
|
||||
Environment = ChildEnvironment()
|
||||
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"):
|
||||
Environment.pop(Key, None) # the new one sets its own
|
||||
if IsWindows:
|
||||
if IsMac:
|
||||
# -n: a new instance, even though this one is still running.
|
||||
subprocess.Popen(["open", "-n", str(Target)], env=Environment, start_new_session=True)
|
||||
elif IsWindows:
|
||||
Environment["PYINSTALLER_RESET_ENVIRONMENT"] = "1" # a fresh onefile unpack, not ours
|
||||
subprocess.Popen([str(Target)], env=Environment, close_fds=True,
|
||||
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0))
|
||||
|
||||
+9
-2
@@ -1,9 +1,10 @@
|
||||
"""Background services SaraSuite runs itself, where there's no systemd --user
|
||||
to do it: Windows, and Linux with another init system (runit on Void, OpenRC,
|
||||
to do it: Windows, macOS, and Linux with another init system (runit on Void, OpenRC,
|
||||
s6, dinit, ...). Each runs as Programs/ServiceRun.py <Name> <Script>
|
||||
<WorkingDir>, which keeps it going (restarts it if it fails), logs its output
|
||||
and records its PID, all in ServicesDir. At login it's started by
|
||||
- Windows: a shortcut in the Startup folder;
|
||||
- macOS: a LaunchAgent (~/Library/LaunchAgents/srhtnin.<Name>.plist);
|
||||
- Linux: an XDG autostart entry (~/.config/autostart/<Name>.desktop), which
|
||||
desktop sessions start whatever the init system is.
|
||||
The installer places those (Installer._ApplyStandInServices); this starts,
|
||||
@@ -18,7 +19,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from Core import ChildEnvironment, IsWindows, ServicesDir
|
||||
from Core import ChildEnvironment, IsMac, IsWindows, ServicesDir
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
@@ -41,6 +42,12 @@ def ServicePid(Name: str) -> Optional[int]:
|
||||
Pid = int(_PidFile(Name).read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if IsMac: # no /proc: ask ps for its command line
|
||||
try:
|
||||
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return Pid if "ServiceRun.py" in Output else None
|
||||
if not IsWindows:
|
||||
try:
|
||||
return Pid if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else None
|
||||
|
||||
+11
-8
@@ -8,8 +8,8 @@ from typing import Callable
|
||||
import flet as ft
|
||||
|
||||
from Core import (
|
||||
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsWindows,
|
||||
LoadSharedSettings, Palette, SharedSettings, ThemesDir,
|
||||
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsMac, IsWindows,
|
||||
LoadSharedSettings, Palette, SharedConfigPath, SharedSettings, ThemesDir,
|
||||
)
|
||||
from Installer import DefaultAnswers, DefaultOptions
|
||||
from Manifests import Program
|
||||
@@ -32,12 +32,15 @@ def _Themes() -> list[str]:
|
||||
|
||||
|
||||
def _Fonts() -> list[str]:
|
||||
"""Installed font families (fontconfig, or the registry on Windows), with
|
||||
the bundled font first."""
|
||||
if IsWindows:
|
||||
import WindowsSupport
|
||||
"""Installed font families (fontconfig, the registry on Windows, the font
|
||||
files themselves on macOS), with the bundled font first."""
|
||||
if IsWindows or IsMac:
|
||||
if IsMac:
|
||||
import MacSupport as Support
|
||||
else:
|
||||
import WindowsSupport as Support
|
||||
|
||||
Families = WindowsSupport.FontFamilies()
|
||||
Families = Support.FontFamilies()
|
||||
Families.discard(BundledFontFamily)
|
||||
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||
try:
|
||||
@@ -98,7 +101,7 @@ def ShowSettingsDialog(Page: ft.Page, Config: AppConfig, Programs: list[Program]
|
||||
Categories: dict[str, ft.Control] = {
|
||||
"Shared": ft.Column(
|
||||
[
|
||||
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml)."),
|
||||
_Note(f"Shared by every SaraSuite program ({SharedConfigPath})."),
|
||||
UseSharedSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
SharedTheme,
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
flet>=1.0,<2.0
|
||||
tomlkit
|
||||
# macOS: app bundle icons (.icns); the Windows build bundles it for .ico.
|
||||
pillow; sys_platform == "darwin"
|
||||
Reference in new issue
Block a user