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
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)
+14 -4
View File
@@ -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}")
+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.
"""
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()}
+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
.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:
+2 -2
View File
@@ -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
+7 -3
View File
@@ -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
+3 -3
View File
@@ -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
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
send2trash
toml
dbus-next
dbus-next; sys_platform == "linux" # udisks2 and the file-picker portal (Linux only)