mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
Windows support
This commit is contained in:
1 parent
6b2b35f5da
commit
b295db3fb6
9 files changed
+136
-26
No files matched your search
+7
-4
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
+1
-1
@@ -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)
|
||||
Reference in new issue
Block a user