Windows support

This commit is contained in:
sarah committed 2026-10-09 14:30:59 +02:00
1 parent 6b2b35f5da
commit b295db3fb6
9 files changed
+136 -26

No files matched your search

+7 -4
View File
@@ -5,10 +5,13 @@ from pathlib import Path
import toml import toml
from Core import BookmarksPath, ConfigDir from Core import BookmarksPath, ConfigDir, IsWindows
DefaultIcon = "BOOKMARK_OUTLINE" 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 # 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. # 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("Documents", Path.home() / "Documents", "DESCRIPTION_OUTLINED"),
Bookmark("Downloads", Path.home() / "Downloads", "DOWNLOAD_OUTLINED"), Bookmark("Downloads", Path.home() / "Downloads", "DOWNLOAD_OUTLINED"),
Bookmark("Pictures", Path.home() / "Pictures", "IMAGE_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 # 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 — # couldn't be edited). Migrate existing bookmark files that predate that —
# but only once, so deliberately removing Trash afterwards actually sticks. # 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): if not any(B.Path == TrashPath for B in Bookmarks):
Bookmarks.append(Bookmark("Trash", TrashPath, "DELETE_OUTLINE")) Bookmarks.append(Bookmark("Trash", TrashPath, "DELETE_OUTLINE"))
SaveBookmarks(Bookmarks) SaveBookmarks(Bookmarks)
+14 -4
View File
@@ -5,7 +5,7 @@ import asyncio
import ctypes import ctypes
import fnmatch import fnmatch
import os import os
import pwd import sys
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -108,8 +108,8 @@ def _EntryModified(Entry: Path) -> str:
def _EntryOwner(Entry: Path) -> str: def _EntryOwner(Entry: Path) -> str:
try: try:
return pwd.getpwuid(Entry.stat().st_uid).pw_name return Entry.owner() # works on Windows too (Python 3.13+), unlike pwd
except (OSError, KeyError): except (OSError, KeyError, NotImplementedError):
return "" return ""
@@ -150,6 +150,13 @@ except OSError:
def _EntryCreated(Entry: Path) -> str: 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: if _Libc is None:
return "" return ""
Buf = _StatxStruct() Buf = _StatxStruct()
@@ -915,7 +922,10 @@ class Browser(ft.Row):
self._OpenInEditor(EntryPath) self._OpenInEditor(EntryPath)
return return
try: 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: except OSError as Error:
self._Notify(f"Couldn't open {EntryPath.name}: {Error}") self._Notify(f"Couldn't open {EntryPath.name}: {Error}")
+51 -5
View File
@@ -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. one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix.
""" """
import os
import shutil import shutil
import subprocess import subprocess
import sys
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
@@ -45,7 +47,19 @@ AppName = "SaraFem"
BundledFontFamily = "Fixedsys Excelsior" BundledFontFamily = "Fixedsys Excelsior"
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme 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" ThumbnailCacheDir = CacheDir / "Thumbnails"
# Copy/Cut/Paste uses the real OS clipboard (Clipboard.set_files/get_files — # 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 # Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other
# Sara* programs (SaraTag, SaraView) can eventually read from the same place. # Sara* programs (SaraTag, SaraView) can eventually read from the same place.
SharedConfigDir = Path.home() / ".config" / "SaraSuite" SharedConfigDir = ConfigHome / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes" ThemesDir = SharedConfigDir / "Themes"
ConfigDir = SharedConfigDir / "SaraFem" ConfigDir = SharedConfigDir / "SaraFem"
ConfigPath = ConfigDir / "Config.toml" ConfigPath = ConfigDir / "Config.toml"
@@ -328,7 +342,7 @@ class AppConfig:
# always showing it with a "No devices." placeholder. # always showing it with a "No devices." placeholder.
AutoHideEmptyDevices: bool = False AutoHideEmptyDevices: bool = False
ShowFiletree: bool = True ShowFiletree: bool = True
FiletreeRoot: str = "/" FiletreeRoot: str = DefaultFiletreeRoot
BookmarksMinHeight: int = 0 # 0 = no constraint BookmarksMinHeight: int = 0 # 0 = no constraint
BookmarksMaxHeight: int = 0 BookmarksMaxHeight: int = 0
DevicesMinHeight: int = 0 DevicesMinHeight: int = 0
@@ -552,9 +566,41 @@ def ApplyAppearance(Settings: SharedSettings) -> None:
ActiveFontFamily = Settings.FontFamily or BundledFontFamily 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]: def InstalledFontFamilies() -> list[str]:
"""Font families fontconfig knows about, for the font picker. The """Font families fontconfig (or, on Windows, the registry) knows about,
bundled font is always first, since it isn't installed system-wide.""" 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: try:
Result = subprocess.run(["fc-list", ":", "family"], capture_output=True, text=True, timeout=5) 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()} Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()}
+51 -4
View File
@@ -17,15 +17,23 @@ Falls back to polling /run/media/<user> if udisks2 isn't reachable.
Everything runs on the page's own event loop (Page.run_task), so calling Everything runs on the page's own event loop (Page.run_task), so calling
.update() from here is safe.""" .update() from here is safe."""
from __future__ import annotations # the D-Bus type hints below don't exist on Windows
import asyncio import asyncio
import getpass import getpass
import os
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
import flet as ft 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 from Core import AppConfig, NotificationStyle, Palette
@@ -35,7 +43,7 @@ BlockInterface = f"{UDisks}.Block"
FilesystemInterface = f"{UDisks}.Filesystem" FilesystemInterface = f"{UDisks}.Filesystem"
DriveInterface = f"{UDisks}.Drive" 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()] 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) 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]: def ListFallbackDevices() -> list[Device]:
Devices: list[Device] = [] Devices: list[Device] = []
for Root in MountRoots: for Root in MountRoots:
@@ -210,6 +251,9 @@ class DevicesPanel(ft.Container):
self.Client = None self.Client = None
async def _Start(self) -> None: async def _Start(self) -> None:
if IsWindows:
await self._PollLoop() # no udisks2: Windows' own drive list, checked every few seconds
return
try: try:
self.Client = await UDisksClient.Connect() self.Client = await UDisksClient.Connect()
await self.Client.Watch(self._ScheduleRefresh) await self.Client.Watch(self._ScheduleRefresh)
@@ -221,7 +265,10 @@ class DevicesPanel(ft.Container):
async def _PollLoop(self) -> None: async def _PollLoop(self) -> None:
while not self.Unmounted: 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) await asyncio.sleep(PollInterval)
def _ScheduleRefresh(self) -> None: def _ScheduleRefresh(self) -> None:
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import Callable, Optional
import flet as ft import flet as ft
from Core import AppConfig, DragGroup, MoveIntoDirectory, NotificationStyle, Palette from Core import AppConfig, DefaultFiletreeRoot, DragGroup, MoveIntoDirectory, NotificationStyle, Palette
class _FiletreeNode: class _FiletreeNode:
@@ -119,7 +119,7 @@ class Filetree(ft.Container):
): ):
self.OnNavigate = OnNavigate self.OnNavigate = OnNavigate
self.Config = Config self.Config = Config
self.RootPath = RootPath or Path("/") self.RootPath = RootPath or Path(DefaultFiletreeRoot)
self.OnFilesMoved = OnFilesMoved self.OnFilesMoved = OnFilesMoved
self.SelectedNode: Optional[_FiletreeNode] = None self.SelectedNode: Optional[_FiletreeNode] = None
+7 -3
View File
@@ -18,8 +18,9 @@ from Sidebar import Sidebar
def _SetCursorThemeFallback() -> None: def _SetCursorThemeFallback() -> None:
"""GTK's cursor lookup reads XCURSOR_THEME directly; it isn't always inherited """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'). 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.""" Fill it in from gsettings if missing, so the warning has nowhere to come from.
if os.environ.get("XCURSOR_THEME"): GTK/GNOME only: nothing to do on Windows."""
if os.environ.get("XCURSOR_THEME") or sys.platform == "win32":
return return
try: try:
Result = subprocess.run( Result = subprocess.run(
@@ -233,6 +234,9 @@ def Main(Page: ft.Page) -> None:
Page.padding = 0 Page.padding = 0
Page.window.width = 1100 Page.window.width = 1100
Page.window.height = 700 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 Page.window.title_bar_hidden = Appearance.HideWindowTitleBar
PickerArgs = _ParsePickerArgs() PickerArgs = _ParsePickerArgs()
@@ -246,7 +250,7 @@ def Main(Page: ft.Page) -> None:
if __name__ == "__main__": 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 # No Flet page at all here — the D-Bus FileChooser backend is a
# plain asyncio service (see PortalService.py) that # plain asyncio service (see PortalService.py) that
# spawns *this same executable* again with --pick-* flags for the # spawns *this same executable* again with --pick-* flags for the
+3 -3
View File
@@ -11,7 +11,7 @@ from typing import Callable
import flet as ft import flet as ft
from Core import ( from Core import (
AppConfig, AvailableThemes, ChipButtonStyle, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette, AppConfig, AvailableThemes, ChipButtonStyle, DefaultFiletreeRoot, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette,
SharedSettings, SharedSettings,
) )
from FolderIcons import FolderIconChoices, FolderIconLabelOverrides from FolderIcons import FolderIconChoices, FolderIconLabelOverrides
@@ -294,7 +294,7 @@ def BuildSettingsDialog(
"Devices height", Config.DevicesMinHeight, Config.DevicesMaxHeight "Devices height", Config.DevicesMinHeight, Config.DevicesMaxHeight
) )
FiletreeSwitch = ft.Switch(label="Show filetree section", value=Config.ShowFiletree) 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( FiletreeHeightRow, FiletreeMinHeightField, FiletreeMaxHeightField = _MinMaxRow(
"Filetree height", Config.FiletreeMinHeight, Config.FiletreeMaxHeight "Filetree height", Config.FiletreeMinHeight, Config.FiletreeMaxHeight
) )
@@ -630,7 +630,7 @@ def BuildSettingsDialog(
Config.AutoHideEmptyDevices = AutoHideEmptyDevicesSwitch.value Config.AutoHideEmptyDevices = AutoHideEmptyDevicesSwitch.value
Config.ShowFiletree = FiletreeSwitch.value Config.ShowFiletree = FiletreeSwitch.value
Config.SplitBookmarksFiletreeEvenly = SplitSidebarEvenlySwitch.value Config.SplitBookmarksFiletreeEvenly = SplitSidebarEvenlySwitch.value
Config.FiletreeRoot = FiletreeRootField.value.strip() or "/" Config.FiletreeRoot = FiletreeRootField.value.strip() or DefaultFiletreeRoot
Config.PreviewSide = GetPreviewSide() Config.PreviewSide = GetPreviewSide()
Config.ShowPreview = PreviewSwitch.value Config.ShowPreview = PreviewSwitch.value
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+1 -1
View File
@@ -3,4 +3,4 @@ flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first
pillow pillow
send2trash send2trash
toml toml
dbus-next dbus-next; sys_platform == "linux" # udisks2 and the file-picker portal (Linux only)