Windows support in SaraSuite

This commit is contained in:
sarah committed 2026-10-09 13:59:08 +02:00
1 parent 3998dfde74
commit 92033f3bf6
9 files changed
+615 -63

No files matched your search

+30 -5
View File
@@ -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/<Program>/...) 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: <Name>.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 <desktop>-portals.conf file name."""
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
Empty on Windows."""
return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0]
+127 -18
View File
@@ -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:
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"}
+36 -5
View File
@@ -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", "")
# 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,9 +325,19 @@ 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:]))
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.
+86 -20
View File
@@ -1,5 +1,10 @@
"""Program manifests: Programs/<Name>/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/<Name>/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/<Name>/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:
+48
View File
@@ -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"
+40
View File
@@ -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 <Name> <Script> (in the service's working folder)
It records its PID in %LOCALAPPDATA%\\SaraSuite\\Services\\<Name>.pid, so
anything can tell whether the service runs, and stop it (taskkill), without
a Windows service and without admin rights. Installed next to each program
that has a Windows service (Programs/<Name>/Manifest.toml, [[Services]]).
"""
import os
import runpy
import sys
from pathlib import Path
def Main() -> None:
Name, Script = sys.argv[1], Path(sys.argv[2])
LocalAppData = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
PidFile = LocalAppData / "SaraSuite" / "Services" / f"{Name}.pid"
PidFile.parent.mkdir(parents=True, exist_ok=True)
PidFile.write_text(str(os.getpid()))
try:
sys.argv = [str(Script)]
sys.path.insert(0, str(Script.parent))
runpy.run_path(str(Script), run_name="__main__")
finally:
# Only remove it if it's still ours (a newer start may have replaced it).
try:
if PidFile.read_text().strip() == str(os.getpid()):
PidFile.unlink()
except OSError:
pass
if __name__ == "__main__":
Main()
+39 -13
View File
@@ -1,24 +1,27 @@
"""SaraSuite updating itself from its Gitea releases.
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
AppImage attached. Here SaraSuite asks Gitea for the latest release, and if
it's newer than AppVersion and SaraSuite runs as an AppImage, downloads it
next to the running one and renames it into place (allowed while it's
running), then restarts into it. The AppImage keeps its path, so its app
menu entry keeps working.
AppImage and SaraSuite.exe attached. 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).
"""
import os
import subprocess
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, LoadConfig
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsWindows, LoadConfig
from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}-x86_64.AppImage"
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-x86_64.AppImage"
@dataclass
@@ -28,10 +31,21 @@ 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."""
if IsWindows:
return Path(sys.executable) if IsFrozen else None
Value = os.environ.get("APPIMAGE", "")
return Path(Value) if Value else None
def CleanUpOldExe() -> None:
"""Windows: delete the .exe a previous update renamed aside."""
Running = RunningAppImage()
if IsWindows and Running is not None:
Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True)
def _VersionTuple(Version: str) -> tuple[int, ...]:
Parts = []
for Part in Version.strip().lstrip("vV").split("."):
@@ -74,12 +88,19 @@ 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"\x7fELF"):
raise OSError("The download isn't an AppImage; leaving the current one alone.")
# Same folder, so the rename is atomic; renaming over a running AppImage
# is fine (overwriting it in place isn't: "Text file busy").
if not Data.startswith(b"MZ" if IsWindows else b"\x7fELF"):
raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.")
Temporary = Target.with_name(Target.name + ".new")
Temporary.write_bytes(Data)
if IsWindows:
# A running .exe can't be replaced, but it can be renamed out of the way.
Old = Target.with_name(f"{Target.stem}.old.exe")
Old.unlink(missing_ok=True)
Target.rename(Old)
Temporary.rename(Target)
else:
# Same folder, so the rename is atomic; renaming over a running AppImage
# is fine (overwriting it in place isn't: "Text file busy").
Temporary.chmod(0o755)
Temporary.replace(Target)
Log(f"== {AppName} updated; it's the new version from the next start")
@@ -92,7 +113,12 @@ def Restart() -> None:
if Target is None:
return
Environment = ChildEnvironment()
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): # the new AppImage sets its own
Environment.pop(Key, None)
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"):
Environment.pop(Key, None) # the new one sets its own
if 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))
else:
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+9 -2
View File
@@ -8,7 +8,7 @@ from typing import Callable
import flet as ft
from Core import (
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, LoadSharedSettings, Palette,
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, IsWindows, LoadSharedSettings, Palette,
SharedSettings, ThemesDir,
)
from Installer import DefaultAnswers, DefaultOptions
@@ -31,7 +31,14 @@ def _Themes() -> list[str]:
def _Fonts() -> list[str]:
"""Installed font families (fontconfig), with the bundled font first."""
"""Installed font families (fontconfig, or the registry on Windows), with
the bundled font first."""
if IsWindows:
import WindowsSupport
Families = WindowsSupport.FontFamilies()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
try:
Result = subprocess.run(
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
+200
View File
@@ -0,0 +1,200 @@
"""Windows stand-ins for what SaraSuite uses on Linux:
Linux Windows
.desktop entry Start Menu shortcut (.lnk)
systemd --user service shortcut in the Startup folder, started and stopped
through Programs/ServiceRun.py (which records its PID)
system python3 the py launcher / python.exe, else winget installs it
fc-list the Fonts registry key
Only imported on Windows (pylnk3, winreg).
"""
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Callable, Optional
from Core import DataHome, ServicesDir
LogFn = Callable[[str], None]
# Child processes of a windowed program would each flash a console window.
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
PythonVersion = "3.14" # what winget installs when there's no Python yet
_WingetId = f"Python.Python.{PythonVersion}"
# --- Shortcuts and icons ---------------------------------------------------------------
def MakeIcon(Png: Path, Ico: Path) -> Optional[Path]:
"""A .ico from one of the Sara icons (32x32 pixel art stored at 10x).
Each size is a whole multiple of the 32x32 grid, so every art pixel
stays square and crisp (same reasoning as the cards' 128px icons)."""
try:
from PIL import Image
Source = Image.open(Png).convert("RGBA")
Grid = Source.resize((32, 32), Image.NEAREST)
Sizes = [(Size, Size) for Size in (256, 128, 64, 32, 16)]
Ico.parent.mkdir(parents=True, exist_ok=True)
Grid.resize((256, 256), Image.NEAREST).save(Ico, format="ICO", sizes=Sizes)
return Ico
except Exception:
return None
def CreateShortcut(
Link: Path, Target: Path, Arguments: str = "", WorkingDir: Optional[Path] = None,
Icon: Optional[Path] = None, Description: str = "",
) -> None:
import pylnk3
Link.parent.mkdir(parents=True, exist_ok=True)
pylnk3.for_file(
str(Target), str(Link), arguments=Arguments or None, description=Description or None,
icon_file=str(Icon) if Icon else None, icon_index=0,
work_dir=str(WorkingDir) if WorkingDir else None,
)
# --- Python -------------------------------------------------------------------------------
def _Usable(Candidate: str) -> bool:
# The "python.exe" in WindowsApps is only a stub that opens the Microsoft Store.
return bool(Candidate) and "WindowsApps" not in Candidate and Path(Candidate).is_file()
def FindPython() -> Optional[str]:
"""A real Python 3 to build program venvs from."""
Launcher = shutil.which("py")
if Launcher:
try:
Result = subprocess.run(
[Launcher, "-3", "-c", "import sys; print(sys.executable)"],
capture_output=True, text=True, timeout=30, creationflags=NoWindow,
)
if _Usable(Result.stdout.strip()):
return Result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
for Name in ("python3", "python"):
Found = shutil.which(Name) or ""
if _Usable(Found):
return Found
# Per-user installs from python.org or winget, newest first.
Installed = sorted((DataHome / "Programs" / "Python").glob("Python3*/python.exe"), reverse=True)
return str(Installed[0]) if Installed else None
def InstallPython(Log: LogFn) -> Optional[str]:
"""Install the official Python for the current user with winget (built
into Windows 10/11; no admin needed with --scope user)."""
Winget = shutil.which("winget")
if Winget is None:
Log("winget isn't available. Install Python from https://www.python.org/downloads/ and try again.")
return None
Log(f"Python not found; installing Python {PythonVersion} with winget (just for you, no admin)")
Command = [
Winget, "install", "--id", _WingetId, "--exact", "--scope", "user", "--silent",
"--accept-package-agreements", "--accept-source-agreements",
]
Log(f"$ {' '.join(Command)}")
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
creationflags=NoWindow, errors="replace")
for Line in Process.stdout:
if Line.strip():
Log(f" {Line.rstrip()}")
Process.wait()
return FindPython()
# --- Background services (SaraSortd) ---------------------------------------------------
def _PidFile(Name: str) -> Path:
return ServicesDir / f"{Name}.pid"
def ServicePid(Name: str) -> Optional[int]:
"""The PID ServiceRun.py recorded, if that process is still alive."""
try:
Pid = int(_PidFile(Name).read_text().strip())
except (OSError, ValueError):
return None
try:
Output = subprocess.run(
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if re.search(rf'"{Pid}"', Output) else None
def StartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
"""Start a background service now (it's also started at every login by
its Startup shortcut, which runs the same command)."""
if ServicePid(Name) is not None:
Log(f"{Name} is already running")
return
WorkingDir.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[str(PythonW), str(Runner), Name, str(Script)], cwd=str(WorkingDir),
creationflags=_Detached | NoWindow, close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
Log(f"Started {Name}")
def StopService(Name: str, Log: LogFn) -> None:
Pid = ServicePid(Name)
if Pid is not None:
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
Log(f"Stopped {Name}")
_PidFile(Name).unlink(missing_ok=True)
def RestartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
if ServicePid(Name) is not None:
StopService(Name, Log)
StartService(Name, PythonW, Runner, Script, WorkingDir, Log)
# --- Fonts -----------------------------------------------------------------------------------
def FontFamilies() -> set[str]:
"""Installed font families, from the system and per-user Fonts keys
("Arial Bold (TrueType)" -> "Arial")."""
import winreg
Families: set[str] = set()
Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
try:
with winreg.OpenKey(Root, Key) as Fonts:
Index = 0
while True:
try:
Name = winreg.EnumValue(Fonts, Index)[0]
except OSError:
break
Index += 1
Name = re.sub(r"\s*\(.*\)$", "", Name)
Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name)
if Name and "&" not in Name:
Families.add(Name.strip())
except OSError:
continue
return Families
def OpenPath(Target: Path) -> None:
os.startfile(str(Target)) # type: ignore[attr-defined] # Windows only