diff --git a/Core.py b/Core.py index 2427099..158d8e1 100644 --- a/Core.py +++ b/Core.py @@ -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 diff --git a/Main.py b/Main.py index 66220b8..0c38910 100644 --- a/Main.py +++ b/Main.py @@ -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 diff --git a/Packaging/SaraSuite/Icon.png b/Packaging/SaraSuite/Icon.png new file mode 100644 index 0000000..c233ccd Binary files /dev/null and b/Packaging/SaraSuite/Icon.png differ diff --git a/Packaging/SaraSuite/Manifest.toml b/Packaging/SaraSuite/Manifest.toml index 52aa6d1..a481581 100644 --- a/Packaging/SaraSuite/Manifest.toml +++ b/Packaging/SaraSuite/Manifest.toml @@ -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" diff --git a/Packaging/SaraSuite/Templates/Launch.sh b/Packaging/SaraSuite/Templates/Launch.sh index c606b43..70e1413 100755 --- a/Packaging/SaraSuite/Templates/Launch.sh +++ b/Packaging/SaraSuite/Templates/Launch.sh @@ -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 "$@" diff --git a/Packaging/SaraSuite/Templates/SaraSort.desktop b/Packaging/SaraSuite/Templates/SaraSort.desktop index 079c9a7..94d5e7c 100644 --- a/Packaging/SaraSuite/Templates/SaraSort.desktop +++ b/Packaging/SaraSuite/Templates/SaraSort.desktop @@ -7,3 +7,4 @@ Icon={AppDir}/assets/Icon.png Terminal=false Categories=Utility; StartupNotify=true +StartupWMClass=srhtnin.{Name} diff --git a/Service.py b/Service.py index 16bfcae..3b384a8 100644 --- a/Service.py +++ b/Service.py @@ -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: diff --git a/ServicePage.py b/ServicePage.py index 56019cc..f4e2260 100644 --- a/ServicePage.py +++ b/ServicePage.py @@ -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 diff --git a/Settings.py b/Settings.py index 184f0b3..c04b990 100644 --- a/Settings.py +++ b/Settings.py @@ -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(), diff --git a/Sortd.py b/Sortd.py index 1e2dbfb..115a96c 100644 --- a/Sortd.py +++ b/Sortd.py @@ -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)): diff --git a/Widgets.py b/Widgets.py index b5a93c2..ececbcf 100644 --- a/Widgets.py +++ b/Widgets.py @@ -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,7 +69,10 @@ class App: def OpenFolder(self, PathText: str) -> None: try: - subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True) + 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") diff --git a/assets/Icon.ico b/assets/Icon.ico new file mode 100644 index 0000000..d0eacda Binary files /dev/null and b/assets/Icon.ico differ diff --git a/requirements.txt b/requirements.txt index f282f7b..4ea6b4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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