Files
SaraFem/PortalService.py
2026-09-24 22:03:58 +02:00

200 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""org.freedesktop.impl.portal.FileChooser backend, so SaraFem shows up as
the actual system file picker (e.g. a browser's "choose an image to upload"
dialog), not just an app you open manually.
Registered via Packaging/SaraFem.portal + Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service
and preferred for FileChooser specifically via
~/.config/xdg-desktop-portal/cosmic-portals.conf — only that one interface is
overridden, so a bug here can't affect any other portal (Screenshot,
Settings, etc.), and the override is one file to delete to revert.
A sibling module (not under Packaging/) so PyInstaller's static import
analysis bundles it — and its own dbus_next import — automatically, the same
way it already bundles Core.py/Browser.py/etc, with no special build flags.
Runnable two ways: `python Main.py --portal-service` (dev venv or a packaged
build) invokes Run() directly; Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service's
Exec= does exactly that via Packaging/Launch.sh.
Each request here spawns the picker UI (Picker.py, via Main.py --pick-*) and
waits for it to exit, reading back the JSON result file it writes — the
D-Bus plumbing lives entirely in this file, not in the GUI.
"""
import asyncio
import json
import sys
import tempfile
import urllib.parse
from pathlib import Path
from typing import Optional
from dbus_next import BusType, Variant
from dbus_next.aio import MessageBus
from dbus_next.service import ServiceInterface, method
BusName = "org.freedesktop.impl.portal.desktop.SaraFem"
ObjectPath = "/org/freedesktop/portal/desktop"
RepoRoot = Path(__file__).resolve().parent
Launcher = RepoRoot / "Packaging" / "Launch.sh"
def _PickerCommand() -> list[str]:
"""A packaged (PyInstaller-frozen) install has no venv and no separate
Launch.sh — just this one executable, which re-invokes itself with
--pick-* flags for the picker UI (see Main.py's --portal-service
branch). Running from source still goes through Launch.sh."""
if getattr(sys, "frozen", False):
return [sys.executable]
return [str(Launcher)]
def _PathToUri(EntryPath: Path) -> str:
return "file://" + urllib.parse.quote(str(EntryPath))
def _DecodeNulPath(RawBytes: bytes) -> Optional[Path]:
Text = bytes(RawBytes).rstrip(b"\x00").decode(errors="replace")
return Path(Text) if Text else None
class _RequestObject(ServiceInterface):
"""Minimal org.freedesktop.impl.portal.Request: lets the calling app
(via the portal frontend) cancel a pending pick — e.g. it closed its own
upload dialog while SaraFem's picker was still open — by killing our
picker subprocess instead of leaving it orphaned."""
def __init__(self, Bus: MessageBus, HandlePath: str):
super().__init__("org.freedesktop.impl.portal.Request")
self.Bus = Bus
self.HandlePath = HandlePath
self.Process: Optional[asyncio.subprocess.Process] = None
self.Cancelled = asyncio.Event()
Bus.export(HandlePath, self)
@method()
def Close(self) -> None:
self.Cancelled.set()
if self.Process is not None and self.Process.returncode is None:
self.Process.terminate()
def Unexport(self) -> None:
try:
self.Bus.unexport(self.HandlePath, self)
except Exception:
pass
class FileChooserPortal(ServiceInterface):
def __init__(self, Bus: MessageBus):
super().__init__("org.freedesktop.impl.portal.FileChooser")
self.Bus = Bus
async def _RunPicker(
self, HandlePath: str, Title: str, ExtraArgs: list[str], StartPath: Optional[Path]
) -> tuple[int, dict]:
Request = _RequestObject(self.Bus, HandlePath)
ResultFile = tempfile.NamedTemporaryFile(prefix="sarafem_pick_", suffix=".json", delete=False)
ResultPath = Path(ResultFile.name)
ResultFile.close()
Args = [*_PickerCommand(), *ExtraArgs, "--title", Title, "--result-file", str(ResultPath)]
if StartPath is not None:
Args.append(str(StartPath))
try:
Process = await asyncio.create_subprocess_exec(*Args)
Request.Process = Process
await Process.wait()
finally:
Request.Unexport()
if Request.Cancelled.is_set():
ResultPath.unlink(missing_ok=True)
return 2, {}
try:
Data = json.loads(ResultPath.read_text())
except (OSError, ValueError):
ResultPath.unlink(missing_ok=True)
return 2, {}
ResultPath.unlink(missing_ok=True)
if Data.get("cancelled"):
return 1, {}
Uris = [_PathToUri(Path(P)) for P in Data.get("paths", [])]
if not Uris:
return 1, {}
return 0, {"uris": Variant("as", Uris)}
def _FilterArgs(self, Options: dict) -> list[str]:
FiltersVariant = Options.get("filters")
if FiltersVariant is None:
return []
Args = []
for Label, Patterns in FiltersVariant.value:
# Pattern type 0 = glob, 1 = mimetype (see the FileChooser portal
# spec) — only globs translate directly to Picker.py's filters;
# mimetype-only filter groups are skipped (files stay unfiltered
# for that group rather than guessing a glob from a MIME type).
Globs = [Pattern for (Kind, Pattern) in Patterns if Kind == 0]
if Globs:
Args += ["--filter", f"{Label}:{','.join(Globs)}"]
return Args
@method()
async def OpenFile(
self, handle: "o", app_id: "s", parent_window: "s", title: "s", options: "a{sv}"
) -> "ua{sv}":
Multiple = options.get("multiple", Variant("b", False)).value
Directory = options.get("directory", Variant("b", False)).value
CurrentFolder = options.get("current_folder")
StartPath = _DecodeNulPath(CurrentFolder.value) if CurrentFolder is not None else None
ExtraArgs = ["--pick-folder"] if Directory else ["--pick-open"]
if Multiple:
ExtraArgs.append("--multiple")
ExtraArgs += self._FilterArgs(options)
Response, Results = await self._RunPicker(handle, title or "Open File", ExtraArgs, StartPath)
return [Response, Results]
@method()
async def SaveFile(
self, handle: "o", app_id: "s", parent_window: "s", title: "s", options: "a{sv}"
) -> "ua{sv}":
CurrentName = options.get("current_name")
SuggestedName = CurrentName.value if CurrentName is not None else ""
CurrentFolder = options.get("current_folder")
StartPath = _DecodeNulPath(CurrentFolder.value) if CurrentFolder is not None else None
ExtraArgs = ["--pick-save"]
if SuggestedName:
ExtraArgs += ["--suggested-name", SuggestedName]
Response, Results = await self._RunPicker(handle, title or "Save File", ExtraArgs, StartPath)
return [Response, Results]
@method()
async def SaveFiles(
self, handle: "o", app_id: "s", parent_window: "s", title: "s", options: "a{sv}"
) -> "ua{sv}":
# SaraFem's picker only does a single Save destination — still a
# reasonable fallback for a rarely-used multi-file-save call.
return await self.SaveFile(handle, app_id, parent_window, title, options)
async def Run() -> None:
Bus = await MessageBus(bus_type=BusType.SESSION).connect()
Bus.export(ObjectPath, FileChooserPortal(Bus))
await Bus.request_name(BusName)
await Bus.wait_for_disconnect()
if __name__ == "__main__":
try:
asyncio.run(Run())
except KeyboardInterrupt:
sys.exit(0)