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
+627
-75
No files matched your search
@@ -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