From 6cb2bd74cc5de2a0eb3bea4291e137be0bc91702 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 14:16:50 +0200 Subject: [PATCH] Windows support --- Core.py | 25 +++- Main.py | 4 +- Packaging/SaraSuite/Icon.png | Bin 0 -> 1026 bytes Packaging/SaraSuite/Manifest.toml | 17 +++ Packaging/SaraSuite/Templates/Launch.sh | 4 + .../SaraSuite/Templates/SaraSort.desktop | 1 + Service.py | 111 +++++++++++++++++- ServicePage.py | 6 +- Settings.py | 36 +++++- Sortd.py | 20 ++-- Widgets.py | 7 +- assets/Icon.ico | Bin 0 -> 6426 bytes requirements.txt | 1 + 13 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 Packaging/SaraSuite/Icon.png create mode 100644 assets/Icon.ico 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 0000000000000000000000000000000000000000..c233ccd4b895d961b1e7ef55152b4a2c69798b21 GIT binary patch literal 1026 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Xs4rZXpFTcZ5`FEq4$8-_i7YQuf{qpO*hO|9;y1 zZf)brbidR-kFIi?`~>|FPbJHklj^&GR%(<4`2{olCk|BEZ27>zz|7+5;uumf=j|Ow zzcmgFu7QuOzHI&4z@xlA|Ux2vJY+@KfOBcxD$gpvjk!I+wnl^B!*+?(2s3T?Dzah z$EIMKQq6SWJ2$E`LA-BE8uIP0%}9P<%D~9P!XcpG&;Vr25&Uq6%hI92w!x49+{npz zd~zQ>&pFa#+j@VPl578OV4Lj@9N5 z4%r6PjMvXGFxiNqg!~P8Mj3yp^LMx-m|#8!nbOmc{>4_GQ{jL+T15QFl}+ExDv-dR zz=H&HoDO_kviEUc`JZzP|GAMQK#Fpt4!mYTNgUu%4{5m0ho+Jz;Z4c`(^J{zahwIl zXraC_p81b7F@eI^^Oo(3GN#JAjO+gH%O)j3vh%Dy9?&lLZ!$0sF?hQAxvX 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 0000000000000000000000000000000000000000..d0eacdaa2e2c2a361631d3dee8b6d794ea705273 GIT binary patch literal 6426 zcmb7GcUTi^v)_b-UP6&B1e79zSdd;qZ-SBDn-pnMl@dy5(m@2JNfV_?Xd*}pD1u6r zDj)A3wJrmwp9$jcwXbo z1Na^Hmf}b+7h7rh*`5}3(a#BTa;;ZyQJ^v1Vr>Vh|RWzUay?N)WKj{%x7jm4sn6& z!&h5Sa?)&+?aF|v-X&cLE>9CvoBl*^p4%7rP~bsZl}pjQ3x<76&eZJowN_(Y5}a;~ zj1iRB3&~-)EeWityjtlXF3GU7B1tESg(?lPhYwAITebm}1(7mtJ_EgP)p? z<6G(|-*s %}N)zj{;3hV6`CGGG|%tDN<-VI?{w_i!jVIGCJ+7{$xn_XQAFnGG0_ z+!u$rA8(m01XUk{3apQ)y)2)dt$)F$s-+qhzJH|}CH^613^ktS+%LVdf3zTO-t0~1 z#fr@9L(E*RAc&ofmPZ&JELL-!aoszalSW4R(FBgn7hfruvkZGedoC&eJiE}>vSJ>U z@plV(Ez*sf8VlsWGmVP5R{W6;%|iE0SOm-5axgz#XF>E^f!if4ucsPwd6oTZ^o=A zs6Xa0(^4na|5W47z@EqIf0=#{sKJFiGYY{G+!)t3=LdF~Cmi3km{yyKVXiOC^;6F1 zBEtmgKMsmA^+koH@GIw?upnq#*P4*0TMqOn3bi8>PTU3?7w+naZS;)N&S^0j?Nmno z9BOlLy#w*zfb4mGvuQ9BPv*(CiL@GS!yV`J=52gVP^bM0c?rd1qCj;cu2>cuELW&u2a1ljrWtx3aT#S8@N0p$;B^jfux^uKM zr!0ATGS1^Hxbl3@kVhNj48^+aT}jLoo#x6OVoN&puD_tu4B#)3cUHCYOO|?sWZ)&Z zBRiBTeWdO`wIXM7WAvZ~bdtzaczy)pXCt&E&()@;I;uKwC#hSL6*~Frr{Q>JRMeNa zba-d@OX4P_cBI{W$`b}ih2Ouk&|Ta0!z^*>3=rAqsYY8mRoa`|BcBz=-s+i|J}<}H z|D3rwQ`Q5+CLidF59iWXYHgK!+&;Z^q-4Uj`Jk!}v;7}>5<$`<{_4pJSD#q`fEN7j z$+cYF)X!?Lp761m{2oDrvlwBjOz(IESTfcss!M=^K7Lbq9 z-KU{sypY?kV4zmFUDJ}Q+M>X-Aupew8Ss#JtGiC&WlKdR(b#(ZK|#BY0(uXCGc_h8 zLN=`1=?FLUnTdtzHFwNEl}rNI6_eXkVD_094qwiVH=Qeuv~xjszse{mEOy%B(n?C< zoC8%NkX8Y0`X;|^Lwb7preI<8=FW|KdPR=-cK$M@Mu*HVy4dtH>7nu5h3SPfx%X=4 zTk~kMw&@u&Ze{Vgbb_Kf0d&eTINwLI3zfQB`kpI_LM__H7tm7IzsR59BRSVufCDx^ zw%A@A&of`vOn1kTu1$^Vgwv+r~MQhWN4 zMP{#C{*4Wib0GH-o!EmI)j$lm!h7IB)`dn~yRPP5!n+DF*o~yM`?kbanmNfxorz}5 zOEA;OGxy*fxy^q&F}Sb%RIC*4K*jFy z;7>=+PjPJ&2~2)QW=-4(+X_;yQC$3bz}wWCT)AoJ2{q!a6tH;h%rfU8;5Z0Pv0Cy7 zYyBau+);~&O8kffMPD>WVR%HY@h6wk@!J^&2D3Q3_~fs9WIeNyqvezib9}SvsgdK5 zr`1pkeo2?tRn0;GW-qkgHkL3&M?Zc2{-d+S*SaBsde}T!ls=sNw%6$#yK~sOH+L91Bz)qa{)%gs0v7g`_QHPD8z{G>GBz;-KSF~`OZ zx5``VCqBfwoBHV$+pWYoM!7w(r#&$)FDT+=ZFrZv_|X1`d%s*)%jQ1E{B%tqDV$b$ zuP8Pc9D-%NUfHG_-d%#SI)(XzCm2)>Q^I@AGRF5VINn6!%eOf_Uba*3=CdSwyVd|sg3DXxL)y~#y24L) zYOjc2Og$FB4sPAJr>}Bi`v9uTEC9p#+`b|0dT2s>_Y3rU_erhPg2`yoM5v?}@OTJmN@ zmu#XGmq^FWP-@nrIy=W1SFf-41#}&A>g+wCal&d40qWF+uLlbn44+x$=^r-wJao&P zqSmVCv&gO49gp;L??b!a=54tdzcZLW2iR+B@#wc4i?|HIWRg{vgxGM^C=OJ z$%;MV?r>16ch76xfznVaD6(U_rk*$GI%!UN%jr5j9F}$o+W#6>$)+pk;!ZQj57k;W z)Nt0Uvc14NEr0#qWooFA-c<8*VM%FmD6`^)?0!ZX!4EX0mAR#Ofk&Hw%t556L z$W;9J@X?E-l@DzTdx+u6P=4%*mjex{r|&UN0apEzvlWIMf+X= zpw#-;T+hR%Ol!cpv0om@egR5#GWnMubUzz;dWQpUJ=-zJ*j4>SN{g{W9bj7rOZhDG zYvXq?Omp5s>jf+0*OSVGZ<;Qx(B9AvSaZB%ZtAlBd zwqs-JLFUfLDa+R5P3mWD#@Fw&^98aTunrcqBhtLSv_-vNj#k)EQ0^sjl%e3)p8Rn88Oem>Eou7chvyoClG%l23#bQNefpvZI=Zl}S85cqPdiy(sxhi(N6e6rp5VzDuI4_~ z@qyzsxSJ_Z)q(@6ZgX3c4Ph^7ttYa`2kAJx{JlC(5Dfz-ghXw$pMYW}%Y@je7>oSn zS_y=!IxUGyRIHnTsj%;<4l|Lco`Af1nnFo`N)R6;aAU5`5R?=hN56enKEMwZISEDf z9KpcU^%0k3lHMBwXZZN{=PD1L)A8~NW$6L^S>X-1^hXQ1;`y#)o3!+c*5JntWBJzt zq)_^>w`42_B~0jLVXl>g!h$yv#>~c9beA39T^=#-Ezrr}F5G(ip_`NXu&DU_S@?@3 zzuQl$!P3@rG>bfglKx%J9PX>>DylN=hesT=uKFG2!H2B8N^P+;lEKg#jV>|?$bAd_ zizb;NM=sxUq6Hm%Krej3O8bx0IZ$7=wywYO7HEAEh*)x&c=~0!B$izkl_`69C2`TIt}d6F3n%#?y1O@OZIF zB{%y!LHbPTeU;B-O)plJZV9Is^8xFxnkckR`+%%IsEzr*4LUi-^prnoi8M{2wp%O% zOi}{r;irYf#VE45d_Y=zU<2FI9_NNOw61w+kH%**t)Jk5s$(Td2`Vd@c^*Us+vd;| z@uthqHnd>_u>rmgdWr(MODY<`I;QC0$$}B*HK8@WM!bvFLrUsO%!GoY=sTP+rK^9mD`}lPg=~a#qHJ_!c$1vX12m^Fa8U_)i}`{2<8{+ zZh)S1Z*kkjFBbT6`hdFDp|{qs1)9?|q)6Vt_JG=8kOd)}{g<&11FZ6w^VqF#D3WkP z^D`OAyljiW$cAtupD((#zV-Ixs5XuUe$@d-kZM5DR06v@GlWKcqrn*DqJlZDEv8;L zoY5I}3OKXd7H-(mSknYJh!RVN0yc1d-LiZ5{^TZLX&u@XH`!29ccokQ=W%V2L>pSh zop{Io`~EEUYIe{kGs3Lxgt7s`D&6lV*kSDCRV=66K>0>0=~IKKvY9A@bHea4>I(?KhT3{P1>dIp+KKFS_iFdFZ%iw*X)HmZzV2nys6Gaw5BGmya;CKRb*Ky#w)1=`rkCJ|_c8J+Lk~7f;&3#ZCXdMIXKl(7$+9h&|~bz7WiA z_ghBVFJ)C$@TOnn=uJgUE5Hy1(%l=0_5H=-9Z1XuVWXGo@Z_$y+oggt>!5)(sMZ>R z_XCwd>H#+iLY$#8odR7UDNO)L^R#XQH;KBQ@j9^EcdHF3XGggmmG_X86j?dvbgxVD zVs`?&>zaUD#Es36@5^e-Sh;sS;Y)%0t)g$|!QS@RqzV_2+S8Ydvt5rMvVHvzM9-j+ z#}uL7`}@ns{Ma7`Tb;)Fc11L*ePqHjqNmB7*#O3?*uXyQ3sG0sUDFf$rB5LP{i|T% z8G4`-MZv&;H}5m$fke!;sxRO59_R1v3%iYXk_5pRpP%1aubwcjQi8BJlR*mKNl})s z#p-kOtGEX29}n8zXtuudlcM9$*W^0vJ=vU{H(vDe2cutuN7^(9J-kk@Q6NXF_8yeN z>yoYfh~rd;*7sM59K_l#kwX6fV{%e@p^WPB2aj)o=VT&$$r&9KT|f*l+qbFNpL&=>inFpR|RGX#i9D6%|k8oaStw#enM1*vXucu z!=qJe<7rcan3jD$6c1!5d|b?ki)mGCHY_k^cs5ed3vax17Lv-3n?D#~6aFFA8gNmD zF5lWC2rasSEp~FLO2)|y~@jCm^7?v@}1HyTnn=@tP+R{Mt7B|AW(Tqj)Nwr z(YQ^t5M79M`BUh1aO~>j4?xvrX201$o2LC5Z=H)SpbAy2BC}#u-5o`=`<_swVJ`E8 zrM(jS-wz-lk{Sg3bpWyQEpr9{NXowsATxZoS#EH5d^z`pUdxCP?yYyx$N>fBj$|HP zkOqM#nBmH-@v3%H%saH62KpVe{WYzV$Dsu?dW6esCqJ&wPG=Wa^WNSNf-isk1_c%< zfoQ6KCk30}H(Sd`mKgC3XItWrg@re!&}$QTV#WC373TWoS$e-K)F=5BE5F*8Dv7z%HwMR0*lGu#6{R&=qH}tsWpN!B|N^Ev3 zgwf(^+&3I{=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