Windows support

This commit is contained in:
sarah committed 2026-10-09 14:16:50 +02:00
1 parent f1935a119b
commit 6cb2bd74cc
13 files changed
+206 -24

No files matched your search

+19 -6
View File
@@ -19,9 +19,17 @@ AppName = "SaraSort"
BundledFontFamily = "Fixedsys Excelsior"
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
IsWindows = sys.platform == "win32"
# Each system's own per-user folders, like SaraSuite's: ~/.config and
# ~/.local/share on Linux, %APPDATA% and %LOCALAPPDATA% on Windows.
Home = Path.home()
ConfigHome = Home / ".config"
DataHome = Home / ".local" / "share"
if IsWindows:
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
SharedConfigDir = ConfigHome / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes"
@@ -100,10 +108,13 @@ class AppConfig:
HideWindowTitleBar: bool = False
# Where SaraSuite installs SaraSortd: GlobalConf.toml and the two
# directory config templates live in SortdConfigDir, SaraSortd.py in
# SortdAppDir, and it runs as a systemd user unit.
SortdConfigDir: str = "~/.config/SaraSuite/SaraSortd"
SortdAppDir: str = "~/.local/share/SaraSuite/Programs/SaraSortd/App"
ServiceName: str = "SaraSortd.service"
# SortdAppDir, and it runs as a systemd user unit (Linux) or a background
# service started from the Startup folder (Windows; see Service.py).
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if IsWindows
else "~/.config/SaraSuite/SaraSortd")
SortdAppDir: str = (str(DataHome / "SaraSuite" / "Programs" / "SaraSortd" / "App") if IsWindows
else "~/.local/share/SaraSuite/Programs/SaraSortd/App")
ServiceName: str = "SaraSortd" if IsWindows else "SaraSortd.service"
# SaraSortd only reads GlobalConf.toml when it starts.
AutoRestart: bool = True
RefreshSeconds: int = 5 # status and log refresh on the Service page
@@ -238,6 +249,8 @@ def ChildEnvironment() -> dict[str, str]:
Environment.pop("LD_LIBRARY_PATH", None)
for Key in ("PYTHONHOME", "PYTHONPATH"):
Environment.pop(Key, None)
if IsWindows:
Environment["PYTHONUTF8"] = "1" # every Sara program reads and writes UTF-8
return Environment
+3 -1
View File
@@ -9,7 +9,7 @@ import flet as ft
from ConfigPage import ConfigPage
from Core import (
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataHome, EffectiveSettings,
LoadConfig, Palette,
IsWindows, LoadConfig, Palette,
)
from FlowPage import FlowPage
from ServicePage import ServicePage
@@ -65,6 +65,8 @@ def Main(Page: ft.Page) -> None:
Page.bgcolor = Palette.Base
Page.window.width = 1180
Page.window.height = 780
if IsWindows:
Page.window.icon = str(AssetsDir / "Icon.ico") # taskbar/title bar (Linux: the desktop entry)
Page.window.min_width = 760
Page.window.min_height = 520
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+17
View File
@@ -1,6 +1,7 @@
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
Name = "SaraSort"
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
Platforms = ["Linux", "Windows"]
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
[Source]
@@ -11,17 +12,33 @@ Exclude = ["Packaging"]
[Python]
RequirementsFile = "requirements.txt"
# Linux: a launcher and a desktop entry.
[[Templates]]
Platform = "Linux"
Source = "Launch.sh"
Target = "{AppDir}/Packaging/Launch.sh"
Executable = true
[[Templates]]
Platform = "Linux"
Source = "SaraSort.desktop"
Target = "{ApplicationsDir}/SaraSort.desktop"
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
[[Shortcuts]]
Platform = "Windows"
Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
[[Leftovers]]
Path = "{ConfigDir}"
Kind = "Config"
Note = "SaraSort's settings, flowchart layout and your templates"
# Shared with the other Flet programs; see Programs/FletIdentity.py.
[[Templates]]
Platform = "Linux"
Source = "../../FletIdentity.py"
Target = "{ProgramDir}/FletIdentity.py"
+4
View File
@@ -1,4 +1,8 @@
#!/bin/sh
# Written by SaraSuite. Runs from the app folder so Flet finds assets/.
# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@"
@@ -7,3 +7,4 @@ Icon={AppDir}/assets/Icon.png
Terminal=false
Categories=Utility;
StartupNotify=true
StartupWMClass=srhtnin.{Name}
+107 -4
View File
@@ -1,11 +1,22 @@
"""SaraSortd's systemd user unit: status, start/stop/restart, autostart, and
its journal. SaraSortd's own log files are read from its LogDir."""
"""SaraSortd as a background service: status, start/stop/restart, autostart,
and its journal. SaraSortd's own log files are read from its LogDir.
Linux: its systemd user unit (systemctl / journalctl).
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
from SaraSortd's config folder, and ServiceRun.py records its PID in
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder.
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
"""
import re
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from Core import ChildEnvironment
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig
@dataclass
@@ -22,11 +33,98 @@ class ServiceStatus:
return self.Active in ("active", "activating", "reloading")
_NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess:
return subprocess.run(Arguments, capture_output=True, text=True, timeout=Timeout, env=ChildEnvironment())
return subprocess.run(Arguments, capture_output=True, text=True, timeout=Timeout, env=ChildEnvironment(),
creationflags=_NoWindow)
# --- Windows ------------------------------------------------------------------------------
def _PidFile(Name: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
def _StartupLink(Name: str) -> Path:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
def _WindowsCommand() -> tuple[Path, list[str], Path]:
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
Config = LoadConfig()
AppDir = Path(Config.SortdAppDir).expanduser()
ProgramDir = AppDir.parent
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
def _WindowsPid(Name: str) -> str:
try:
Pid = int(_PidFile(Name).read_text().strip())
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
except (OSError, ValueError, subprocess.SubprocessError):
return ""
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
def _WindowsStatus(Name: str) -> ServiceStatus:
PythonW, Arguments, _Folder = _WindowsCommand()
Pid = _WindowsPid(Name)
Since = ""
if Pid:
Since = datetime.fromtimestamp(_PidFile(Name).stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
return ServiceStatus(
Active="active" if Pid else "inactive",
Sub="running" if Pid else "dead",
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
Pid=Pid,
Since=Since,
Found=PythonW.is_file() and Path(Arguments[2]).is_file(),
)
def _WindowsControl(Name: str, Action: str) -> str:
PythonW, Arguments, Folder = _WindowsCommand()
try:
if Action in ("stop", "restart"):
Pid = _WindowsPid(Name)
if Pid:
_Run(["taskkill", "/PID", Pid, "/T", "/F"])
_PidFile(Name).unlink(missing_ok=True)
if Action in ("start", "restart"):
if _WindowsPid(Name):
return ""
Folder.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
)
elif Action == "enable":
import pylnk3
Link = _StartupLink(Name)
Link.parent.mkdir(parents=True, exist_ok=True)
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
elif Action == "disable":
_StartupLink(Name).unlink(missing_ok=True)
except (OSError, subprocess.SubprocessError) as Error:
return str(Error)
return ""
# --- Both -----------------------------------------------------------------------------------
def Status(Unit: str) -> ServiceStatus:
if IsWindows:
return _WindowsStatus(Unit)
try:
Result = _Run(["systemctl", "--user", "show", Unit,
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
@@ -45,6 +143,8 @@ def Status(Unit: str) -> ServiceStatus:
def Control(Unit: str, Action: str) -> str:
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
if IsWindows:
return _WindowsControl(Unit, Action)
try:
Result = _Run(["systemctl", "--user", Action, Unit])
except (OSError, subprocess.SubprocessError) as Error:
@@ -60,6 +160,9 @@ def RestartIfRunning(Unit: str) -> str:
def Journal(Unit: str, Lines: int) -> list[str]:
if IsWindows:
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
"have everything it does."]
try:
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
except (OSError, subprocess.SubprocessError) as Error:
+3 -3
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import flet as ft
import tomlkit
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, Note, Palette
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette
from Service import Control, Journal, LogFiles, ReadTail, Status
from Widgets import App
@@ -105,10 +105,10 @@ class ServicePage(ft.Column):
self.StateText.value = Text
self.StateText.color = Color
self.Details.controls = [
Note(f"Unit: {self.App.Config.ServiceName}"),
Note(f"{'Service' if IsWindows else 'Unit'}: {self.App.Config.ServiceName}"),
Note(f"Since: {Current.Since or '-'}"),
Note(f"PID: {Current.Pid or '-'}"),
Note(f"Unit file: {Current.Enabled}"),
Note(f"{'Autostart (Startup folder)' if IsWindows else 'Unit file'}: {Current.Enabled}"),
]
self.StartButton.disabled = Current.Running or not Current.Found
self.StopButton.disabled = not Current.Running
+34 -2
View File
@@ -12,7 +12,7 @@ import tomlkit
from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
ImportSettings, LoadSharedSettings, Palette, SharedSettings,
ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
)
from Sortd import MajorVersion, Setup, SortdError
from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely
@@ -29,8 +29,40 @@ def _Note(Text: str) -> ft.Text:
return ft.Text(Text, color=Palette.Overlay0, size=12)
def _WindowsFonts() -> set[str]:
"""Font families from the system and per-user Fonts registry keys
("Arial Bold (TrueType)" -> "Arial")."""
import re
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 _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:
Families = _WindowsFonts()
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(),
+13 -7
View File
@@ -22,6 +22,11 @@ from typing import Any, Optional
import tomlkit
from tomlkit.items import AoT
def _Slashes(PathText: str) -> str:
"""Windows paths use "\\" (and configs may mix both); SaraSortd 7.5 names and compares with "/"."""
return PathText.replace("\\", "/")
from Core import AppConfig
TemplateNames = {"In": "InputDirConf.toml", "Out": "OutputDirConf.toml"}
@@ -276,20 +281,21 @@ class Setup:
return Path(os.path.normpath(self.GlobalPath.parent / Parsed))
def FlatName(self, DirPath: str) -> str:
"""SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats"."""
Root = self.ParsePath(str(self.Dirs.get("RootDir", ""))).rstrip("/")
DirPath = DirPath.rstrip("/")
"""SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats".
Like SaraSortd 7.5, "\\" counts as "/" and a drive's ":" is dropped (Windows)."""
Root = _Slashes(self.ParsePath(str(self.Dirs.get("RootDir", "")))).rstrip("/")
DirPath = _Slashes(DirPath).rstrip("/")
if Root and DirPath.startswith(Root + "/"):
Relative = DirPath[len(Root) + 1:]
else:
Relative = DirPath.strip("/") or os.path.basename(DirPath)
return Relative.replace("/", "_")
return Relative.replace(":", "").replace("/", "_")
def CentralConf(self, DirPath: str, Kind: str) -> Optional[Path]:
"""SaraSortd 7.2: a config named in [DirConfs], or one in DirConfDir.
None when the config lives inside the directory."""
for Key, Value in (self.Data.get("DirConfs") or {}).items():
if self.ParsePath(str(Key)).rstrip("/") == DirPath.rstrip("/"):
if _Slashes(self.ParsePath(str(Key))).rstrip("/") == _Slashes(DirPath).rstrip("/"):
if isinstance(Value, dict):
Value = Value.get(Kind)
if Value:
@@ -338,7 +344,7 @@ class Setup:
After = Setup(self.Config, NewDoc)
Folder = After.CentralFolder()
for InputPath in self.Inputs:
if Folder and (Folder == Path(InputPath) or str(Folder).startswith(InputPath.rstrip("/") + "/")):
if Folder and Folder.is_relative_to(Path(InputPath)):
raise SortdError(f"{Folder} is inside the input {InputPath}; SaraSortd would sort its own configs away.")
Plan: dict[Path, Path] = {}
for DirPath in dict.fromkeys(self.Inputs + self.Outputs):
@@ -919,7 +925,7 @@ class Setup:
f"which SaraSortd {Daemon} doesn't know about. Update SaraSortd to 7.2."))
if Central:
for InputPath in Inputs:
if Central == Path(InputPath) or str(Central).startswith(InputPath.rstrip("/") + "/"):
if Central.is_relative_to(Path(InputPath)):
Found.append(("Error", f"DirConfDir ({Central}) is inside the input {InputPath}, so SaraSortd "
"would sort its own configs away."))
if UsesOwnInputs and Daemon and not VersionAtLeast(Daemon, (7, 1)):
+4 -1
View File
@@ -7,7 +7,7 @@ from typing import Callable, Optional
import flet as ft
from Core import AppConfig, ChildEnvironment, ChipButton, Note, NotificationStyle, Palette
from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette
from Service import RestartIfRunning
from Sortd import Setup, SortdError
@@ -69,6 +69,9 @@ class App:
def OpenFolder(self, PathText: str) -> None:
try:
if IsWindows:
os.startfile(PathText) # Explorer
else:
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
except OSError as Error:
self.Notify(f"Couldn't open {PathText}: {Error}", "error")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+1
View File
@@ -1,3 +1,4 @@
flet>=1.0,<2.0
flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once
tomlkit
pylnk3; sys_platform == "win32" # Windows: SaraSortd's autostart is a Startup-folder shortcut