From b295db3fb63502103c1cf4d9093412e68d83a152 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 14:30:59 +0200 Subject: [PATCH] Windows support --- Bookmarks.py | 11 ++++++---- Browser.py | 18 +++++++++++---- Core.py | 56 ++++++++++++++++++++++++++++++++++++++++++----- Devices.py | 55 ++++++++++++++++++++++++++++++++++++++++++---- Filetree.py | 4 ++-- Main.py | 10 ++++++--- Settings.py | 6 ++--- assets/Icon.ico | Bin 0 -> 5999 bytes requirements.txt | 2 +- 9 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 assets/Icon.ico diff --git a/Bookmarks.py b/Bookmarks.py index 8dcf9e7..c5757d5 100644 --- a/Bookmarks.py +++ b/Bookmarks.py @@ -5,10 +5,13 @@ from pathlib import Path import toml -from Core import BookmarksPath, ConfigDir +from Core import BookmarksPath, ConfigDir, IsWindows DefaultIcon = "BOOKMARK_OUTLINE" -TrashPath = Path.home() / ".local" / "share" / "Trash" / "files" +# The freedesktop trash folder (Linux). Windows' Recycle Bin isn't a folder +# that can be browsed, so there's no Trash bookmark there (deleting still goes +# to the Recycle Bin, through send2trash). +TrashPath = None if IsWindows else Path.home() / ".local" / "share" / "Trash" / "files" # One-time marker: without it, re-adding the Trash bookmark to old files (see # LoadBookmarks) would run on every load and undo the user removing it. @@ -41,7 +44,7 @@ def DefaultBookmarks() -> list[Bookmark]: Bookmark("Documents", Path.home() / "Documents", "DESCRIPTION_OUTLINED"), Bookmark("Downloads", Path.home() / "Downloads", "DOWNLOAD_OUTLINED"), Bookmark("Pictures", Path.home() / "Pictures", "IMAGE_OUTLINED"), - Bookmark("Trash", TrashPath, "DELETE_OUTLINE"), + *([Bookmark("Trash", TrashPath, "DELETE_OUTLINE")] if TrashPath else []), ] @@ -74,7 +77,7 @@ def LoadBookmarks() -> list[Bookmark]: # Trash used to be a hardcoded Sidebar entry, not a real bookmark (so it # couldn't be edited). Migrate existing bookmark files that predate that — # but only once, so deliberately removing Trash afterwards actually sticks. - if not _TrashMigrationMarker.exists(): + if TrashPath is not None and not _TrashMigrationMarker.exists(): if not any(B.Path == TrashPath for B in Bookmarks): Bookmarks.append(Bookmark("Trash", TrashPath, "DELETE_OUTLINE")) SaveBookmarks(Bookmarks) diff --git a/Browser.py b/Browser.py index 26867c4..1ffed47 100644 --- a/Browser.py +++ b/Browser.py @@ -5,7 +5,7 @@ import asyncio import ctypes import fnmatch import os -import pwd +import sys import re import shutil import subprocess @@ -108,8 +108,8 @@ def _EntryModified(Entry: Path) -> str: def _EntryOwner(Entry: Path) -> str: try: - return pwd.getpwuid(Entry.stat().st_uid).pw_name - except (OSError, KeyError): + return Entry.owner() # works on Windows too (Python 3.13+), unlike pwd + except (OSError, KeyError, NotImplementedError): return "" @@ -150,6 +150,13 @@ except OSError: def _EntryCreated(Entry: Path) -> str: + # Windows (and macOS) record it in plain stat() as st_birthtime. + try: + BirthTime = getattr(Entry.stat(), "st_birthtime", None) + except OSError: + BirthTime = None + if BirthTime: + return datetime.fromtimestamp(BirthTime).strftime("%d/%m/%y %H:%M") if _Libc is None: return "" Buf = _StatxStruct() @@ -915,7 +922,10 @@ class Browser(ft.Row): self._OpenInEditor(EntryPath) return try: - subprocess.Popen(["xdg-open", str(EntryPath)]) + if sys.platform == "win32": + os.startfile(str(EntryPath)) # the default app + else: + subprocess.Popen(["xdg-open", str(EntryPath)]) except OSError as Error: self._Notify(f"Couldn't open {EntryPath.name}: {Error}") diff --git a/Core.py b/Core.py index d79e5ab..98e059d 100644 --- a/Core.py +++ b/Core.py @@ -5,8 +5,10 @@ SaraView/Backend/DefaultThemes/CatppuccinMocha.toml and Frontend/src/Styles/Them one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix. """ +import os import shutil import subprocess +import sys from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Callable, Optional @@ -45,7 +47,19 @@ AppName = "SaraFem" BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme -CacheDir = Path.home() / ".cache" / "SaraFem" +IsWindows = sys.platform == "win32" + +# Each system's own per-user folders, like SaraSuite's: ~/.config and ~/.cache +# on Linux; %APPDATA% (settings) and %LOCALAPPDATA% (caches) on Windows. +if IsWindows: + ConfigHome = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") + CacheDir = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "SaraFem" / "Cache" + # Where the file tree starts by default: the system drive (C:\), not "/". + DefaultFiletreeRoot = (os.environ.get("SystemDrive") or "C:") + "\\" +else: + ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + CacheDir = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "SaraFem" + DefaultFiletreeRoot = "/" ThumbnailCacheDir = CacheDir / "Thumbnails" # Copy/Cut/Paste uses the real OS clipboard (Clipboard.set_files/get_files — @@ -72,7 +86,7 @@ def GetClipboardMode() -> str: # Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other # Sara* programs (SaraTag, SaraView) can eventually read from the same place. -SharedConfigDir = Path.home() / ".config" / "SaraSuite" +SharedConfigDir = ConfigHome / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" ConfigDir = SharedConfigDir / "SaraFem" ConfigPath = ConfigDir / "Config.toml" @@ -328,7 +342,7 @@ class AppConfig: # always showing it with a "No devices." placeholder. AutoHideEmptyDevices: bool = False ShowFiletree: bool = True - FiletreeRoot: str = "/" + FiletreeRoot: str = DefaultFiletreeRoot BookmarksMinHeight: int = 0 # 0 = no constraint BookmarksMaxHeight: int = 0 DevicesMinHeight: int = 0 @@ -552,9 +566,41 @@ def ApplyAppearance(Settings: SharedSettings) -> None: ActiveFontFamily = Settings.FontFamily or BundledFontFamily +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 InstalledFontFamilies() -> list[str]: - """Font families fontconfig knows about, for the font picker. The - bundled font is always first, since it isn't installed system-wide.""" + """Font families fontconfig (or, on Windows, the registry) knows about, + for the font picker. The bundled font is always first, since it isn't + installed system-wide.""" + 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) Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()} diff --git a/Devices.py b/Devices.py index 3d543f7..8a672e6 100644 --- a/Devices.py +++ b/Devices.py @@ -17,15 +17,23 @@ Falls back to polling /run/media/ if udisks2 isn't reachable. Everything runs on the page's own event loop (Page.run_task), so calling .update() from here is safe.""" +from __future__ import annotations # the D-Bus type hints below don't exist on Windows + import asyncio import getpass +import os +import sys from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional import flet as ft -from dbus_next import BusType, Message, MessageType, Variant -from dbus_next.aio import MessageBus + +IsWindows = sys.platform == "win32" +if not IsWindows: + # udisks2 is Linux-only; Windows lists its drives itself (ListWindowsDrives). + from dbus_next import BusType, Message, MessageType, Variant + from dbus_next.aio import MessageBus from Core import AppConfig, NotificationStyle, Palette @@ -35,7 +43,7 @@ BlockInterface = f"{UDisks}.Block" FilesystemInterface = f"{UDisks}.Filesystem" DriveInterface = f"{UDisks}.Drive" -PollInterval = 3.0 # seconds, only for the no-udisks2 fallback +PollInterval = 3.0 # seconds, for Windows and the no-udisks2 fallback MountRoots = [Path("/run/media") / getpass.getuser(), Path("/media") / getpass.getuser()] @@ -71,6 +79,39 @@ def _UserMount(MountPoint: Path) -> bool: return any(Root in MountPoint.parents for Root in MountRoots) +def ListWindowsDrives(ShowInternal: bool, ShowSystemDrive: bool) -> list[Device]: + """Windows: removable drives (USB sticks, memory cards, discs with a disc + in), plus fixed and network drives with ShowInternal, and the system drive + (C:) with ShowSystemDrive. Windows mounts drives itself, so there's nothing + to mount; ejecting is left to Windows' own "Safely remove".""" + import ctypes + + Kernel = ctypes.windll.kernel32 + Removable, Fixed, Remote, CdRom = 2, 3, 4, 5 + SystemDrive = (os.environ.get("SystemDrive") or "C:").upper().rstrip("\\") + "\\" + Devices: list[Device] = [] + Mask = Kernel.GetLogicalDrives() + for Index in range(26): + if not Mask & (1 << Index): + continue + Root = f"{chr(65 + Index)}:\\" + Kind = Kernel.GetDriveTypeW(Root) + IsSystem = Root.upper() == SystemDrive + if IsSystem and not ShowSystemDrive: + continue + if Kind in (Fixed, Remote) and not (ShowInternal or IsSystem): + continue + if Kind not in (Removable, Fixed, Remote, CdRom): + continue + Name = ctypes.create_unicode_buffer(261) + # Fails for an empty card reader or disc drive: nothing to show then. + if not Kernel.GetVolumeInformationW(Root, Name, 261, None, None, None, None, 0): + continue + Fallback = {Removable: "Removable drive", CdRom: "Disc", Remote: "Network drive"}.get(Kind, "Local disk") + Devices.append(Device(Root, f"{Name.value or Fallback} ({Root[:2]})", Path(Root), IsSystemMount=IsSystem)) + return Devices + + def ListFallbackDevices() -> list[Device]: Devices: list[Device] = [] for Root in MountRoots: @@ -210,6 +251,9 @@ class DevicesPanel(ft.Container): self.Client = None async def _Start(self) -> None: + if IsWindows: + await self._PollLoop() # no udisks2: Windows' own drive list, checked every few seconds + return try: self.Client = await UDisksClient.Connect() await self.Client.Watch(self._ScheduleRefresh) @@ -221,7 +265,10 @@ class DevicesPanel(ft.Container): async def _PollLoop(self) -> None: while not self.Unmounted: - self._Show(ListFallbackDevices()) + if IsWindows: + self._Show(ListWindowsDrives(self.Config.ShowInternalDrives, self.Config.ShowSystemDrive)) + else: + self._Show(ListFallbackDevices()) await asyncio.sleep(PollInterval) def _ScheduleRefresh(self) -> None: diff --git a/Filetree.py b/Filetree.py index 33c3373..f16a662 100644 --- a/Filetree.py +++ b/Filetree.py @@ -9,7 +9,7 @@ from typing import Callable, Optional import flet as ft -from Core import AppConfig, DragGroup, MoveIntoDirectory, NotificationStyle, Palette +from Core import AppConfig, DefaultFiletreeRoot, DragGroup, MoveIntoDirectory, NotificationStyle, Palette class _FiletreeNode: @@ -119,7 +119,7 @@ class Filetree(ft.Container): ): self.OnNavigate = OnNavigate self.Config = Config - self.RootPath = RootPath or Path("/") + self.RootPath = RootPath or Path(DefaultFiletreeRoot) self.OnFilesMoved = OnFilesMoved self.SelectedNode: Optional[_FiletreeNode] = None diff --git a/Main.py b/Main.py index 4da51cf..afb52af 100644 --- a/Main.py +++ b/Main.py @@ -18,8 +18,9 @@ from Sidebar import Sidebar def _SetCursorThemeFallback() -> None: """GTK's cursor lookup reads XCURSOR_THEME directly; it isn't always inherited from the desktop session (shows as 'Unable to load ... from the cursor theme'). - Fill it in from gsettings if missing, so the warning has nowhere to come from.""" - if os.environ.get("XCURSOR_THEME"): + Fill it in from gsettings if missing, so the warning has nowhere to come from. + GTK/GNOME only: nothing to do on Windows.""" + if os.environ.get("XCURSOR_THEME") or sys.platform == "win32": return try: Result = subprocess.run( @@ -233,6 +234,9 @@ def Main(Page: ft.Page) -> None: Page.padding = 0 Page.window.width = 1100 Page.window.height = 700 + if sys.platform == "win32": + # The taskbar/title bar icon (on Linux it comes from the desktop entry). + Page.window.icon = str(Path(__file__).resolve().parent / "assets" / "Icon.ico") Page.window.title_bar_hidden = Appearance.HideWindowTitleBar PickerArgs = _ParsePickerArgs() @@ -246,7 +250,7 @@ def Main(Page: ft.Page) -> None: if __name__ == "__main__": - if "--portal-service" in sys.argv: + if "--portal-service" in sys.argv and sys.platform != "win32": # Linux's file-picker portal only # No Flet page at all here — the D-Bus FileChooser backend is a # plain asyncio service (see PortalService.py) that # spawns *this same executable* again with --pick-* flags for the diff --git a/Settings.py b/Settings.py index 18a335d..7a28e93 100644 --- a/Settings.py +++ b/Settings.py @@ -11,7 +11,7 @@ from typing import Callable import flet as ft from Core import ( - AppConfig, AvailableThemes, ChipButtonStyle, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette, + AppConfig, AvailableThemes, ChipButtonStyle, DefaultFiletreeRoot, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings, ) from FolderIcons import FolderIconChoices, FolderIconLabelOverrides @@ -294,7 +294,7 @@ def BuildSettingsDialog( "Devices height", Config.DevicesMinHeight, Config.DevicesMaxHeight ) FiletreeSwitch = ft.Switch(label="Show filetree section", value=Config.ShowFiletree) - FiletreeRootField = ft.TextField(value=Config.FiletreeRoot, label="Filetree root", hint_text="/") + FiletreeRootField = ft.TextField(value=Config.FiletreeRoot, label="Filetree root", hint_text=DefaultFiletreeRoot) FiletreeHeightRow, FiletreeMinHeightField, FiletreeMaxHeightField = _MinMaxRow( "Filetree height", Config.FiletreeMinHeight, Config.FiletreeMaxHeight ) @@ -630,7 +630,7 @@ def BuildSettingsDialog( Config.AutoHideEmptyDevices = AutoHideEmptyDevicesSwitch.value Config.ShowFiletree = FiletreeSwitch.value Config.SplitBookmarksFiletreeEvenly = SplitSidebarEvenlySwitch.value - Config.FiletreeRoot = FiletreeRootField.value.strip() or "/" + Config.FiletreeRoot = FiletreeRootField.value.strip() or DefaultFiletreeRoot Config.PreviewSide = GetPreviewSide() Config.ShowPreview = PreviewSwitch.value diff --git a/assets/Icon.ico b/assets/Icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1d2f3bc2e24e320fabb99f480eead8dc53d92776 GIT binary patch literal 5999 zcmbVQ2T)Vn);yrDoxrg(0PU+P$mw}!1|+?=*S(GG2ojzf9Tg>qxTwEWIy8jm z?HbkaL`L$B6O&6OFYKOiHmUYlD0YxVwuU8N9pzDpF&O#$rF>v!cFeC`(Y~8%Ft~+C z>^_{8#m;PT5SE=&vc|mXVgMp#%Wld!w)|mnjoQIx7q7~_s_L#U`-n{MkaOZ&;#(cu zF+6tLF?@5*pqSmPF?xq30Yjgi)Ucjwj}nF#oz|u&5SN`p6o{1h*)8rgxZJTOKZeOx zf~#kmpBSrl+>-xi@0PAbR0#B5nIsqHHGJtsarHy-TyYcH`u^H^(mx@7G_ z1m{K!1#*EN3Gs#*@6QVlrdJeiUPTJI&Z9%@+cUW5p2|+`>SS!Oay-~O#1P0;9!M?7 zC|wWje5gnb=E{kVoNU|XD~L<|?xr|j=DEuLuB5(WZrs1i#)a&@l#%XUU*yKMYaU+_ zLa_LX*}d;a0##cR6nrlJI5MtWmX~So$IfWg1X;#)47V3wM6hk~<*L$tVFfVPQ93l* z%z~q7!YmF_0_A2l-?sGA<2VXz6e!y)V&0fHpFhfx`-IVB~{+F5mwnB+Y9=|UZW# z4D<^}4dDb9#R$nS{kex~yz2gKaef6a4X2B=`Pf5rwfZZ4=HAofYX)1)Q)NF`?ir%| zXyJ>a2t^*qvt4$b8Wa|uG1t}@_1h%-sD!U$BGg1a4*z;e=EEsX!L#{WPwhrlYW9qX1WHBs9GEnCDY zds|ZI#EbZ6yl~;TdZ!kD5fC~n9jX~QssPZ+VZlk_6<5T?OF5RgA`(}GT@ z-1D_1jYS7E*Db06iePrjw^3yF7Ki*2$;!Kj_>Wg>pHPgN z=~A-Mo7DP6J>jq!`Y>Ja<(~Fq? C!4dOh{@hvlj0BzdTxQSm*>kuFCj@Ov-zO=+ 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.0,<2.0 # the desktop client; otherwise Flet fetches it on first pillow send2trash toml -dbus-next +dbus-next; sys_platform == "linux" # udisks2 and the file-picker portal (Linux only)