mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
Windows support in SaraSuite
This commit is contained in:
1 parent
3998dfde74
commit
92033f3bf6
9 files changed
+615
-63
No files matched your search
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Executable
+48
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user