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
+627 -75

No files matched your search

+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