mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
201 lines
7.5 KiB
Python
201 lines
7.5 KiB
Python
"""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
|