From 92033f3bf6f4b22427203595c3cd35120166ed46 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 13:59:08 +0200 Subject: [PATCH] Windows support in SaraSuite --- Core.py | 35 ++++++-- Installer.py | 149 +++++++++++++++++++++++++----- Main.py | 53 ++++++++--- Manifests.py | 106 +++++++++++++++++----- Packaging/BuildExe.sh | 48 ++++++++++ Programs/ServiceRun.py | 40 +++++++++ SelfUpdate.py | 60 +++++++++---- Settings.py | 11 ++- WindowsSupport.py | 200 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 627 insertions(+), 75 deletions(-) create mode 100755 Packaging/BuildExe.sh create mode 100644 Programs/ServiceRun.py create mode 100644 WindowsSupport.py diff --git a/Core.py b/Core.py index 9a0f215..655e207 100644 --- a/Core.py +++ b/Core.py @@ -1,5 +1,12 @@ """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//...) is the same on both. + 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 picked for one Sara program looks the same in all of them. @@ -17,7 +24,9 @@ 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.1.1" -IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage +IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe +IsWindows = sys.platform == "win32" +Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform # 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. @@ -25,9 +34,19 @@ BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme Home = Path.home() -ConfigHome = Home / ".config" -DataHome = Home / ".local" / "share" -CacheHome = Home / ".cache" +if IsWindows: + ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming") + DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local") + CacheHome = DataHome + # Start Menu shortcuts, and the Startup folder (run at login: SaraSortd's + # stand-in for a systemd user service). + StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" + StartupDir = StartMenuDir / "Startup" +else: + ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") + DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") + CacheHome = Path(os.environ.get("XDG_CACHE_HOME") or Home / ".cache") + StartMenuDir = StartupDir = None SharedConfigDir = ConfigHome / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" @@ -45,6 +64,7 @@ ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact remova PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base) BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives +ServicesDir = DataDir / "Services" # Windows: .pid of each running background service # Bundled read-only files (program manifests, fonts). Inside a PyInstaller # build they're unpacked to sys._MEIPASS rather than next to the code. @@ -194,12 +214,17 @@ def ChildEnvironment() -> dict[str, str]: Environment.pop("LD_LIBRARY_PATH", None) for Key in ("PYTHONHOME", "PYTHONPATH"): Environment.pop(Key, None) + if IsWindows: + # Python on Windows otherwise reads/writes text in the old ANSI code + # page; every Sara program assumes UTF-8 (their TOML files). + Environment["PYTHONUTF8"] = "1" return Environment def CurrentDesktop() -> str: """First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for - portal descriptors' UseIn= and the -portals.conf file name.""" + portal descriptors' UseIn= and the -portals.conf file name. + Empty on Windows.""" return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0] diff --git a/Installer.py b/Installer.py index 42024d0..5c83b6e 100644 --- a/Installer.py +++ b/Installer.py @@ -19,7 +19,7 @@ from pathlib import Path, PurePosixPath from typing import Callable, Optional from ConfigMerge import ApplySet, MergeConfig, MergeResult -from Core import BackupsDir, ChildEnvironment, DownloadsDir, LoadConfig, PristineDir +from Core import BackupsDir, ChildEnvironment, DownloadsDir, IsWindows, LoadConfig, PristineDir, StartMenuDir, StartupDir from Manifests import Leftover, Program, Render from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt from Sources import ClearDownloads, Download, LatestCommit, SourceError @@ -43,6 +43,8 @@ def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] Log(f"$ {' '.join(Command)}") Process = subprocess.Popen( Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(), + encoding="utf-8", errors="replace", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows ) for Line in Process.stdout: Log(f" {Line.rstrip()}") @@ -60,7 +62,12 @@ def _RemovePath(Target: Path) -> None: if Target.is_symlink() or Target.is_file(): Target.unlink() elif Target.is_dir(): - shutil.rmtree(Target) + def ClearReadOnly(Function, FailedPath, _Error): + # Windows refuses to delete read-only files (e.g. in .git folders). + os.chmod(FailedPath, 0o700) + Function(FailedPath) + + shutil.rmtree(Target, onexc=ClearReadOnly) def _BackupName(Target: str) -> str: @@ -291,23 +298,34 @@ def _RequirementsText(Program: Program) -> str: return "\n".join(Program.Requirements) +def _SystemPython(Program: Program, Log: LogFn) -> str: + if not IsWindows: + 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 + + Python = WindowsSupport.FindPython() or WindowsSupport.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 + + def _SetUpVenv(Program: Program, OldHash: str, Log: LogFn) -> str: """(Re)install requirements only when they changed. Returns the new hash.""" if not Program.UsesPython: return "" Venv = Program.ProgramDir / ".venv" NewHash = hashlib.sha256(_RequirementsText(Program).encode()).hexdigest() - if (Venv / "bin" / "python").exists() and NewHash == OldHash: + if Program.Python.exists() and NewHash == OldHash: Log("Python requirements unchanged, skipping pip") return NewHash - if not (Venv / "bin" / "python").exists(): - # Always the system python: inside the AppImage sys.executable is the - # AppImage itself, and the venv has to outlive SaraSuite anyway. - Python = shutil.which("python3") - if Python is None: - raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)") - _Run([Python, "-m", "venv", str(Venv)], Log) - Pip = [str(Venv / "bin" / "python"), "-m", "pip", "install", "--disable-pip-version-check"] + if not Program.Python.exists(): + # Always the system python: inside the AppImage/.exe sys.executable is + # SaraSuite itself, and the venv has to outlive SaraSuite anyway. + _Run([_SystemPython(Program, Log), "-m", "venv", str(Venv)], Log) + Pip = [str(Program.Python), "-m", "pip", "install", "--disable-pip-version-check"] if Program.RequirementsFile: _Run([*Pip, "-r", str(Program.AppDir / Program.RequirementsFile)], Log) else: @@ -381,6 +399,16 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip Log(f"Wrote {Target}") +def _RemoveStaleShortcuts(OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: + """Shortcuts the old install made that this one no longer wants (an + option was unticked, a service disabled, or the manifest dropped it).""" + if OldReceipt is None: + return + for Placed in OldReceipt.Files: + if Placed.Kind == "Shortcut" and NewReceipt.FindFile(Placed.Path) is None: + _RemovePlacedTemplate(Placed, Log) + + def _RevertIni(Record: IniRecord, Log: LogFn) -> None: FilePath = Path(Record.File) Parser = _ReadIni(FilePath) @@ -434,14 +462,86 @@ def _ApplyIniEdits(Program: Program, Context: dict, OldReceipt: Optional[Receipt Log(f"Set {Key}={Value} in {File}") -def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: +def _PlaceShortcut(Program: Program, Link: Path, Arguments: str, WorkingDir: Path, IconPng: Optional[Path], + Description: str, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: + """Windows: a .lnk running the program's pythonw.exe (in UTF-8 mode, which + every Sara program assumes) with Arguments. A link that was already there + 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) + 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 _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: + return + for Link in Program.Shortcuts: + if not Program.OptionEnabled(Link.Option, NewReceipt.Options): + continue + Script = Program.AppDir / Link.Script + Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip() + _PlaceShortcut( + Program, StartMenuDir / f"{Link.Name}.lnk", Arguments, Program.AppDir, + Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description, + OldReceipt, NewReceipt, Log, + ) + + +def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: + """A Startup-folder shortcut runs the service at login (through + ServiceRun.py, which records its PID); enabling also starts it now.""" + import WindowsSupport + + WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set() + Runner = Program.ProgramDir / "ServiceRun.py" + for Service in Program.Services: + Script = Program.AppDir / Service.Script + WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir + if Program.OptionEnabled(Service.Option, NewReceipt.Options): + NewReceipt.EnabledServices.append(Service.Id) + _PlaceShortcut( + Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}"', + WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log, + ) + WindowsSupport.StartService(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log) + elif Service.Id in WasEnabled: + WindowsSupport.StopService(Service.Name, Log) + + +def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None: + """Windows locks a running program's files, so its services stop before + its files or venv are replaced or removed.""" + if not IsWindows or Receipt is None: + return + import WindowsSupport + + for Name in Receipt.EnabledServices: + WindowsSupport.StopService(Name, Log) + + +def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn, Context: Optional[dict] = None) -> None: if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices): return + if IsWindows: + _ApplyWindowsServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log) + return _Systemctl(Log, "daemon-reload") WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set() for Service in Program.Services: if Program.OptionEnabled(Service.Option, NewReceipt.Options): - NewReceipt.EnabledServices.append(Service.Unit) + NewReceipt.EnabledServices.append(Service.Id) if Service.Unit in WasEnabled: _Systemctl(Log, "try-restart", Service.Unit) else: @@ -451,6 +551,8 @@ def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None: + if IsWindows: + return # Start Menu shortcuts 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) @@ -508,9 +610,11 @@ def Apply( try: Program.ProgramDir.mkdir(parents=True, exist_ok=True) + _StopWindowsServices(Program, OldReceipt, Log) _CopyProgramFiles(Program, SourceDir, Log) NewReceipt.RequirementsHash = _SetUpVenv(Program, OldReceipt.RequirementsHash if OldReceipt else "", Log) _ApplyTemplates(Program, Context, OldReceipt, NewReceipt, Log) + _ApplyShortcuts(Program, Context, OldReceipt, NewReceipt, Log) _ApplyConfigs(Program, ConfigPlans, NewReceipt, Log) # .new files from an earlier update that are still around stay tracked. if OldReceipt is not None: @@ -518,7 +622,8 @@ def Apply( if Placed.Kind == "ConfigNew" and Path(Placed.Path).exists() and NewReceipt.FindFile(Placed.Path) is None: NewReceipt.Files.append(Placed) _ApplyIniEdits(Program, Context, OldReceipt, NewReceipt, Log) - _ApplyServices(Program, OldReceipt, NewReceipt, Log) + _ApplyServices(Program, OldReceipt, NewReceipt, Log, Context) + _RemoveStaleShortcuts(OldReceipt, NewReceipt, Log) _AfterIntegrationChanges(Program, Log) NewReceipt.Complete = True ClearDownloads(Program.Name) @@ -581,10 +686,11 @@ def PlanRemoval(Program: Program) -> RemovalPlan: Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source")) if Current is not None: + ServiceNote = "background service, stopped and taken out of Startup" if IsWindows else "systemd user service, stopped and disabled" for Unit in Current.EnabledServices: - Plan.Items.append(RemovalItem(Unit, "Integration", "systemd user service, stopped and disabled")) + Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote)) for Placed in Current.Files: - if Placed.Kind == "Template": + if Placed.Kind in ("Template", "Shortcut"): Note = "put back to your previous version" if Placed.Displaced else "" if Placed.Root: Note = ", ".join(Part for Part in ("needs your password", Note) if Part) @@ -625,14 +731,17 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF Current = LoadReceipt(Program.Name) if Current is not None: - for Unit in Current.EnabledServices: - _Systemctl(Log, "disable", "--now", Unit) + if IsWindows: + _StopWindowsServices(Program, Current, Log) + else: + for Unit in Current.EnabledServices: + _Systemctl(Log, "disable", "--now", Unit) for Placed in Current.Files: - if Placed.Kind == "Template": + if Placed.Kind in ("Template", "Shortcut"): _RemovePlacedTemplate(Placed, Log) for Record in Current.IniEdits: _RevertIni(Record, Log) - if Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files): + if not IsWindows and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)): _Systemctl(Log, "daemon-reload") Groups = {"Program"} diff --git a/Main.py b/Main.py index 2481a94..0045e3b 100644 --- a/Main.py +++ b/Main.py @@ -6,6 +6,7 @@ import os import shutil import sys import traceback +from pathlib import Path from typing import Callable import flet as ft @@ -13,7 +14,7 @@ import flet as ft import SelfUpdate from Catalog import UpdateCatalog from Core import ( - AppName, AppVersion, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir, + AppName, AppVersion, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir, BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette, ) from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog @@ -24,10 +25,17 @@ from Settings import ShowSettingsDialog _MaxLogLines = 2000 -# When running as an AppImage, SaraSuite can put itself in the app menu. -_AppImage = os.environ.get("APPIMAGE", "") -_MenuEntry = DataHome / "applications" / "SaraSuite.desktop" -_MenuIcon = DataDir / "SaraSuite.png" +# When running packaged (AppImage / .exe), SaraSuite can put itself in the +# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. +_Packaged = SelfUpdate.RunningAppImage() +_AppImage = str(_Packaged) if _Packaged else "" +if IsWindows: + _MenuEntry = StartMenuDir / "SaraSuite.lnk" + _MenuIcon = DataDir / "SaraSuite.ico" +else: + _MenuEntry = DataHome / "applications" / "SaraSuite.desktop" + _MenuIcon = DataDir / "SaraSuite.png" +_MenuLabel = "Start menu" if IsWindows else "app menu" def _LoadFletIdentity(): @@ -44,6 +52,12 @@ def _SetMenuEntry(Enabled: bool) -> None: _MenuEntry.unlink(missing_ok=True) _MenuIcon.unlink(missing_ok=True) return + if IsWindows: + import WindowsSupport + + Icon = WindowsSupport.MakeIcon(AssetsDir / "Icon.png", _MenuIcon) + WindowsSupport.CreateShortcut(_MenuEntry, Path(_AppImage), Icon=Icon, Description="Install, update and remove Sara programs") + return _MenuIcon.parent.mkdir(parents=True, exist_ok=True) shutil.copy(AssetsDir / "Icon.png", _MenuIcon) _MenuEntry.parent.mkdir(parents=True, exist_ok=True) @@ -70,6 +84,13 @@ def Main(Page: ft.Page) -> None: Page.bgcolor = Palette.Base Page.window.width = 960 Page.window.height = 720 + if IsWindows: + # The taskbar/title bar icon (on Linux it comes from the desktop entry). + import WindowsSupport + + Icon = WindowsSupport.MakeIcon(AssetsDir / "Icon.png", DataDir / "SaraSuite.ico") + if Icon is not None: + Page.window.icon = str(Icon) LogView = ft.ListView(expand=True, auto_scroll=True, spacing=0, padding=8) @@ -242,11 +263,11 @@ def Main(Page: ft.Page) -> None: def ToggleMenuEntry(e) -> None: _SetMenuEntry(not _MenuEntry.exists()) - MenuButton.content = "Remove from app menu" if _MenuEntry.exists() else "Add to app menu" + MenuButton.content = f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}" MenuButton.update() MenuButton = ft.OutlinedButton( - content="Remove from app menu" if _MenuEntry.exists() else "Add to app menu", + content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}", style=ChipButtonStyle(), visible=bool(_AppImage), on_click=ToggleMenuEntry, @@ -304,11 +325,21 @@ def Main(Page: ft.Page) -> None: if __name__ == "__main__": if len(sys.argv) > 1: + if IsWindows and getattr(sys, "frozen", False): + # The .exe is built windowed (no console of its own); write the + # command-line mode's output to the console it was started from. + import ctypes + + if ctypes.windll.kernel32.AttachConsole(-1): + sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1) + sys.stderr = sys.stdout from Cli import Run sys.exit(Run(sys.argv[1:])) - # 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. - os.environ.setdefault("FLET_VIEW_PATH", _LoadFletIdentity().ClientFor(f"srhtnin.{AppName}")) + SelfUpdate.CleanUpOldExe() + if not IsWindows: + # 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. + os.environ.setdefault("FLET_VIEW_PATH", _LoadFletIdentity().ClientFor(f"srhtnin.{AppName}")) ft.run(Main, assets_dir=str(AssetsDir)) diff --git a/Manifests.py b/Manifests.py index c92aa9a..dbca4f7 100644 --- a/Manifests.py +++ b/Manifests.py @@ -1,5 +1,10 @@ """Program manifests: Programs//Manifest.toml, plus optional Templates/ -and Hooks.py next to it. See README.md for the full manifest format.""" +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.""" import importlib.util import re @@ -11,8 +16,8 @@ from typing import Optional import tomlkit from Core import ( - CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir, - SharedConfigDir, + CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, IsWindows, LoadConfig, Platform, + ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, ) # Never worth copying out of a source checkout, whatever Include says. @@ -78,8 +83,33 @@ class IniEdit: @dataclass class Service: - Unit: str # a systemd --user unit, placed by a Template + """A background service. Linux: a systemd --user Unit (placed by a + Template). Windows: Script (relative to the app folder) run windowless by + ServiceRun.py in WorkingDir, at login through a Startup-folder shortcut.""" + + Unit: str = "" Option: str = "" # enabled + started only while this option is ticked + Name: str = "" # Windows: the service's name (PID file, Startup shortcut) + Script: str = "" + WorkingDir: str = "" + + @property + def Id(self) -> str: + return self.Unit or self.Name + + +@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).""" + + Name: str + Script: str + Arguments: str = "" + Icon: str = "" + Description: str = "" + Option: str = "" @dataclass @@ -119,6 +149,8 @@ class Program: Services: list[Service] = field(default_factory=list) RestartUnits: list[str] = field(default_factory=list) Leftovers: list[Leftover] = field(default_factory=list) + Shortcuts: list[Shortcut] = field(default_factory=list) + Platforms: list[str] = field(default_factory=lambda: ["Linux"]) Hooks: Optional[ModuleType] = None @property @@ -132,9 +164,13 @@ class Program: @property def IconPath(self) -> Optional[Path]: """The installed copy's own icon if it's there (always current; the - Icon= line of the program's desktop entry template says where), otherwise - the copy bundled next to the manifest (Programs//Icon.png), so the - card has an icon before installing too.""" + Icon= line of the program's desktop entry template, or its Windows + Shortcut's Icon, says where), otherwise the copy bundled next to the + manifest (Programs//Icon.png), so the card has an icon before + installing too.""" + for Link in self.Shortcuts: + if Link.Icon and (self.AppDir / Link.Icon).is_file(): + return self.AppDir / Link.Icon for Template in self.Templates: if Template.Target.endswith(".desktop"): for Line in (self.Folder / "Templates" / Template.Source).read_text().splitlines(): @@ -153,6 +189,16 @@ class Program: def GitUrl(self) -> str: return Render(self.Git, self.Context()) + @property + def Python(self) -> Path: + Venv = self.ProgramDir / ".venv" + return Venv / "Scripts" / "python.exe" if IsWindows else Venv / "bin" / "python" + + @property + def PythonW(self) -> Path: + """Windows: the venv's console-less python, for GUI programs and services.""" + return self.Python.with_name("pythonw.exe") if IsWindows else self.Python + @property def UsesPython(self) -> bool: return bool(self.Requirements or self.RequirementsFile) @@ -170,16 +216,22 @@ class Program: "ConfigHome": str(ConfigHome), "DataHome": str(DataHome), "CacheHome": str(CacheHome), - "SystemdUserDir": str(ConfigHome / "systemd" / "user"), - "ApplicationsDir": str(DataHome / "applications"), - "DBusServicesDir": str(DataHome / "dbus-1" / "services"), "ProgramDir": str(self.ProgramDir), "AppDir": str(self.AppDir), - "Python": str(self.ProgramDir / ".venv" / "bin" / "python"), + "Python": str(self.Python), + "PythonW": str(self.PythonW), "ConfigDir": str(self.ConfigDir), "DesktopName": Desktop, "Desktop": Desktop.lower(), } + if IsWindows: + Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir)) + else: + Context.update( + SystemdUserDir=str(ConfigHome / "systemd" / "user"), + ApplicationsDir=str(DataHome / "applications"), + DBusServicesDir=str(DataHome / "dbus-1" / "services"), + ) Context.update(Answers or {}) return Context @@ -200,8 +252,18 @@ def _LoadHooks(Folder: Path, Name: str) -> Optional[ModuleType]: return Module +def _ForPlatform(Items: list[dict]) -> list[dict]: + """Drop the items meant for the other system (their Platform key).""" + Kept = [] + for Item in Items: + Item = dict(Item) + if Item.pop("Platform", Platform) == Platform: + Kept.append(Item) + return Kept + + def LoadProgram(Folder: Path) -> Program: - Data = tomlkit.parse((Folder / "Manifest.toml").read_text()).unwrap() + Data = tomlkit.parse((Folder / "Manifest.toml").read_text(encoding="utf-8")).unwrap() Source = Data.get("Source", {}) Version = Data.get("Version", {}) Python = Data.get("Python", {}) @@ -218,14 +280,16 @@ def LoadProgram(Folder: Path) -> Program: VersionPattern=Version.get("Pattern", ""), Requirements=Python.get("Requirements", []), RequirementsFile=Python.get("RequirementsFile", ""), - Options=[Option(**Item) for Item in Data.get("Options", [])], - Questions=[Question(**Item) for Item in Data.get("Questions", [])], - Templates=[TemplateFile(**Item) for Item in Data.get("Templates", [])], - Configs=[ConfigFile(**Item) for Item in Data.get("Configs", [])], - IniEdits=[IniEdit(**Item) for Item in Data.get("IniEdits", [])], - Services=[Service(**Item) for Item in Data.get("Services", [])], - RestartUnits=Data.get("RestartUnits", []), - Leftovers=[Leftover(**Item) for Item in Data.get("Leftovers", [])], + Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))], + Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))], + Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))], + 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", []), + 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"]), Hooks=_LoadHooks(Folder, Data["Name"]), ) @@ -246,6 +310,8 @@ 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). + Loaded = [Current for Current in Loaded if Platform in Current.Platforms] Names = {Current.Name for Current in Loaded} Ordered = [] for Current in Loaded: diff --git a/Packaging/BuildExe.sh b/Packaging/BuildExe.sh new file mode 100755 index 0000000..2fc62b4 --- /dev/null +++ b/Packaging/BuildExe.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Builds dist/SaraSuite.exe, the Windows version: the same PyInstaller +# one-file recipe as the AppImage, run with the Windows Python inside a Wine +# prefix (build/WinBuild/Prefix; PyInstaller can't build for Windows from +# Linux directly). The first run sets that prefix up: it downloads the +# official Windows Python and installs the build requirements into it. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +PythonVersion=3.14.8 +export WINEPREFIX="$PWD/build/WinBuild/Prefix" WINEDEBUG=-all WINEARCH=win64 +WinPython='C:\Python314\python.exe' + +if [ ! -f "$WINEPREFIX/drive_c/Python314/python.exe" ]; then + echo "== Setting up the Windows build environment (one-time)" + mkdir -p build/WinBuild + Installer="build/WinBuild/python-$PythonVersion-amd64.exe" + [ -f "$Installer" ] || curl -sfL -o "$Installer" "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe" + wineboot -i >/dev/null 2>&1 || true + wine "$Installer" /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 TargetDir='C:\Python314' +fi +wine "$WinPython" -m pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller pylnk3 pillow + +find Programs -name __pycache__ -type d -prune -exec rm -rf {} + +Win() { winepath -w "$1"; } + +# The window/taskbar/Explorer icon, from the same pixel art at whole multiples of its grid. +wine "$WinPython" -c " +from PIL import Image +Grid = Image.open(r'$(Win assets/Icon.png)').convert('RGBA').resize((32, 32), Image.NEAREST) +Grid.resize((256, 256), Image.NEAREST).save(r'$(Win build/WinBuild/Icon.ico)', sizes=[(S, S) for S in (256, 128, 64, 32, 16)]) +" + +# --python-option "X utf8": Python's UTF-8 mode, since on Windows it would +# otherwise read and write every TOML file in the old ANSI code page. +wine "$WinPython" -m PyInstaller --noconfirm --clean --log-level WARN \ + --onefile --windowed --name SaraSuite --collect-data flet \ + --python-option "X utf8" \ + --icon "$(Win build/WinBuild/Icon.ico)" \ + --add-data "$(Win assets);assets" --add-data "$(Win Programs);Programs" \ + --distpath "$(Win build/WinBuild/dist)" --workpath "$(Win build/WinBuild/work)" --specpath "$(Win build/WinBuild)" \ + "$(Win Main.py)" + +mkdir -p dist +# Renamed into place, like the AppImage, in case one is running from dist/. +cp build/WinBuild/dist/SaraSuite.exe dist/SaraSuite.exe.new +mv -f dist/SaraSuite.exe.new dist/SaraSuite.exe +echo "Built dist/SaraSuite.exe" diff --git a/Programs/ServiceRun.py b/Programs/ServiceRun.py new file mode 100644 index 0000000..7fd1357 --- /dev/null +++ b/Programs/ServiceRun.py @@ -0,0 +1,40 @@ +"""Runs a SaraSuite program as a background service on Windows, the stand-in +for a systemd --user service. Started windowless with pythonw.exe, both at +login (by the program's shortcut in the Startup folder) and when SaraSuite +or the program's own GUI (e.g. SaraSort for SaraSortd) starts it: + + pythonw.exe ServiceRun.py