From b97ce8ae252ebd31386c89f9a6f2794d88b52999 Mon Sep 17 00:00:00 2001 From: deeleeramone <> Date: Sat, 21 Feb 2026 20:09:11 -0800 Subject: [PATCH 1/2] expose tauri plugins to pywry.config --- pywry/AGENTS.md | 61 +++++++ pywry/docs/docs/features.md | 1 + pywry/docs/docs/guides/configuration.md | 3 + pywry/docs/docs/guides/tauri-plugins.md | 215 ++++++++++++++++++++++++ pywry/docs/docs/reference/config.md | 19 +++ pywry/docs/docs/reference/runtime.md | 10 ++ pywry/docs/mkdocs.yml | 1 + pywry/pywry/__main__.py | 119 ++++++++++++- pywry/pywry/app.py | 3 + pywry/pywry/capabilities/default.toml | 22 ++- pywry/pywry/config.py | 95 +++++++++++ pywry/pywry/runtime.py | 33 ++++ pywry/pywry/state/redis.py | 15 +- 13 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 pywry/docs/docs/guides/tauri-plugins.md diff --git a/pywry/AGENTS.md b/pywry/AGENTS.md index 5de4100..3c178dd 100644 --- a/pywry/AGENTS.md +++ b/pywry/AGENTS.md @@ -1027,6 +1027,67 @@ app.emit("toolbar:set-values", {"values": {"select-1": "A", "toggle-1": True}}, | `asset` | `PYWRY_ASSET__` | Library versions | | `deploy` | `PYWRY_DEPLOY__` | Deploy mode and state backend | +### Tauri Plugins + +PyWry bundles 19 Tauri plugins via `pytauri_wheel`. By default, only `dialog` and `fs` are enabled. Developers can enable additional plugins through configuration — **no Rust compilation required**. + +#### Enabling Plugins + +```python +# Via PyWrySettings constructor +from pywry import PyWry +from pywry.config import PyWrySettings + +settings = PyWrySettings(tauri_plugins=["dialog", "fs", "notification", "http"]) +app = PyWry(settings=settings) +``` + +```toml +# Via pywry.toml or pyproject.toml [tool.pywry] +tauri_plugins = ["dialog", "fs", "notification", "http"] +extra_capabilities = ["shell:allow-execute"] +``` + +```bash +# Via environment variables +export PYWRY_TAURI_PLUGINS="dialog,fs,notification,http" +export PYWRY_EXTRA_CAPABILITIES="shell:allow-execute" +``` + +#### Available Plugins + +| Plugin Name | JS API | Description | +|-------------|--------|-------------| +| `autostart` | - | Launch app on system startup | +| `clipboard_manager` | `window.__TAURI__.clipboardManager` | Read/write system clipboard | +| `deep_link` | - | Handle custom URL schemes | +| `dialog` | `window.__TAURI__.dialog` | Native file/message dialogs | +| `fs` | `window.__TAURI__.fs` | Filesystem read/write | +| `global_shortcut` | `window.__TAURI__.globalShortcut` | System-wide keyboard shortcuts | +| `http` | `window.__TAURI__.http` | HTTP client from webview | +| `notification` | `window.__TAURI__.notification` | Desktop notifications | +| `opener` | `window.__TAURI__.opener` | Open URLs/files with default app | +| `os` | `window.__TAURI__.os` | OS info (platform, arch, etc.) | +| `persisted_scope` | - | Persist filesystem scopes | +| `positioner` | `window.__TAURI__.positioner` | Position windows on screen | +| `process` | `window.__TAURI__.process` | Process management | +| `shell` | `window.__TAURI__.shell` | Execute system commands | +| `single_instance` | - | Prevent duplicate app instances | +| `updater` | `window.__TAURI__.updater` | Auto-update support | +| `upload` | `window.__TAURI__.upload` | File upload with progress | +| `websocket` | `window.__TAURI__.websocket` | WebSocket client | +| `window_state` | - | Persist/restore window size and position | + +#### How It Works + +1. `PyWrySettings.tauri_plugins` holds the list of plugin names to activate +2. The parent process passes this list to the subprocess via `PYWRY_TAURI_PLUGINS` env var +3. In `__main__.py`, `_load_plugins()` dynamically imports each `pytauri_plugins.` module and calls `.init()` +4. The `capabilities/default.toml` pre-grants `:default` permissions for all 19 plugins (unused permissions are harmless) +5. For fine-grained permission control, use `extra_capabilities` to add specific permission strings (e.g., `shell:allow-execute`) + +Each plugin has a `PLUGIN_*` compile-time feature flag in `pytauri_plugins` that is checked before initialization. If a plugin is not compiled into the bundled `pytauri_wheel`, a clear `RuntimeError` is raised. + ### Example pywry.toml ```toml diff --git a/pywry/docs/docs/features.md b/pywry/docs/docs/features.md index 69bce3c..761dd07 100644 --- a/pywry/docs/docs/features.md +++ b/pywry/docs/docs/features.md @@ -29,6 +29,7 @@ One API, three output targets — PyWry automatically selects the right one: | **[Configuration](guides/configuration.md)** | TOML files, env vars, layered precedence | | **[Hot Reload](guides/hot-reload.md)** | Live CSS/JS updates during development | | **[Deploy Mode](guides/deploy-mode.md)** | Redis backend for horizontal scaling | +| **[Tauri Plugins](guides/tauri-plugins.md)** | 19 bundled plugins — clipboard, notifications, HTTP, and more | ## Platform Support diff --git a/pywry/docs/docs/guides/configuration.md b/pywry/docs/docs/guides/configuration.md index 912e568..793cf43 100644 --- a/pywry/docs/docs/guides/configuration.md +++ b/pywry/docs/docs/guides/configuration.md @@ -108,6 +108,8 @@ export PYWRY_LOG__LEVEL=DEBUG | `hot_reload` | `PYWRY_HOT_RELOAD__` | Hot reload behavior | | `server` | `PYWRY_SERVER__` | Inline server settings | | `deploy` | `PYWRY_DEPLOY__` | Deploy mode settings | +| `tauri_plugins` | `PYWRY_TAURI_PLUGINS` | Tauri plugins to load (comma-separated) | +| `extra_capabilities` | `PYWRY_EXTRA_CAPABILITIES` | Extra Tauri permission strings | ## Programmatic Configuration @@ -192,5 +194,6 @@ pywry init ## Next Steps - **[Configuration Reference](../reference/config.md)** — Complete `PyWrySettings` API +- **[Tauri Plugins](tauri-plugins.md)** — Enable clipboard, notifications, HTTP & more - **[Deploy Mode](deploy-mode.md)** — Production server configuration - **[Browser Mode](browser-mode.md)** — Server settings for browser mode diff --git a/pywry/docs/docs/guides/tauri-plugins.md b/pywry/docs/docs/guides/tauri-plugins.md new file mode 100644 index 0000000..55adb3c --- /dev/null +++ b/pywry/docs/docs/guides/tauri-plugins.md @@ -0,0 +1,215 @@ +# Tauri Plugins + +PyWry's native window mode runs on [Tauri](https://tauri.app) via the [PyTauri](https://pytauri.github.io/pytauri/) Python bindings. Tauri ships a rich ecosystem of **plugins** — clipboard access, notifications, HTTP requests, filesystem operations, global shortcuts, and more. PyWry exposes a configuration-driven system for enabling any of the 19 bundled plugins. + +## How It Works + +```mermaid +sequenceDiagram + participant App as PyWry (Python) + participant RT as runtime.py + participant Sub as __main__.py (subprocess) + participant Tauri as Tauri Engine + + App->>RT: set_tauri_plugins(["dialog", "fs", "notification"]) + RT->>Sub: env PYWRY_TAURI_PLUGINS="dialog,fs,notification" + Sub->>Sub: _load_plugins() — check flags, import modules + Sub->>Tauri: builder.build(plugins=[dialog.init(), fs.init(), notification.init()]) + Tauri->>Tauri: Register plugins + grant capabilities +``` + +1. You list the plugins you want in config (Python, TOML, or env var). +2. `PyWry.__init__()` forwards them to the runtime module. +3. The runtime passes `PYWRY_TAURI_PLUGINS` as an environment variable to the subprocess. +4. The subprocess dynamically imports and initialises only the requested plugins. +5. Tauri's capability system (via `capabilities/default.toml`) pre-grants `:default` permissions for all bundled plugins, so capabilities don't need separate configuration. + +--- + +## Quick Start + +### Python + +```python +from pywry import PyWry, PyWrySettings + +settings = PyWrySettings( + tauri_plugins=["dialog", "fs", "notification", "clipboard_manager"], +) + +app = PyWry(settings=settings) +app.show("

Hello with plugins!

") +``` + +### pywry.toml + +```toml +tauri_plugins = ["dialog", "fs", "notification", "clipboard_manager"] +``` + +### pyproject.toml + +```toml +[tool.pywry] +tauri_plugins = ["dialog", "fs", "notification", "clipboard_manager"] +``` + +### Environment Variable + +```bash +# Comma-separated plugin names +export PYWRY_TAURI_PLUGINS="dialog,fs,notification,clipboard_manager" +``` + +```powershell +# PowerShell +$env:PYWRY_TAURI_PLUGINS = "dialog,fs,notification,clipboard_manager" +``` + +!!! info "Defaults" + If you don't configure anything, `dialog` and `fs` are enabled — matching PyWry's original behaviour. + +--- + +## Available Plugins + +All 19 plugins bundled in the `pytauri_wheel` binary are listed below. Each plugin exposes a JavaScript (`window.__TAURI__.*`) and/or Python API once enabled. + +| Plugin name | JS API | Description | +|:---|:---|:---| +| `autostart` | — | Launch at OS login | +| `clipboard_manager` | `clipboard` | Read/write system clipboard | +| `deep_link` | `deepLink` | Handle custom URL schemes | +| `dialog` | `dialog` | Native open/save/message dialogs | +| `fs` | `fs` | Filesystem read/write/watch | +| `global_shortcut` | `globalShortcut` | System-wide keyboard shortcuts | +| `http` | `http` | HTTP client (fetch replacement) | +| `notification` | `notification` | OS notification centre | +| `opener` | `opener` | Open URLs / files with default app | +| `os` | `os` | Platform, arch, locale info | +| `persisted_scope` | — | Persist runtime FS scopes across restarts | +| `positioner` | — | Position windows (centre, tray, etc.) | +| `process` | `process` | Restart / exit app | +| `shell` | `shell` | Execute system commands | +| `single_instance` | — | Prevent duplicate app instances | +| `updater` | `updater` | Auto-update from remote server | +| `upload` | `upload` | Upload files with progress | +| `websocket` | `websocket` | WebSocket client | +| `window_state` | — | Save/restore window size & position | + +!!! note "Feature flags" + Each plugin has a compile-time feature flag (e.g. `PLUGIN_NOTIFICATION`). If the bundled `pytauri_wheel` was not compiled with a particular feature, enabling that plugin at runtime will raise a `RuntimeError` with a clear message. The default PyWry wheel includes all 19. + +--- + +## Using Plugin APIs in JavaScript + +Once a plugin is enabled, its JavaScript API is available through the Tauri bridge. Use the [JavaScript Bridge](javascript-bridge.md) to interact: + +```python +app = PyWry(settings=PyWrySettings( + tauri_plugins=["dialog", "fs", "notification", "clipboard_manager"], +)) + +html = """ + + +""" +app.show(html) +``` + +!!! tip "Plugin documentation" + For the full JavaScript API of each plugin, see the [Tauri Plugins documentation](https://tauri.app/plugin/). + +--- + +## Extra Capabilities + +Tauri's [capability system](https://tauri.app/security/capabilities/) controls which APIs a window is allowed to call. By default, PyWry grants `:default` permissions for every bundled plugin. This is sufficient for most use cases. + +If you need **fine-grained permissions** beyond the defaults (e.g. `shell:allow-execute`, `fs:allow-read-file`), use the `extra_capabilities` setting: + +```python +settings = PyWrySettings( + tauri_plugins=["shell", "fs"], + extra_capabilities=["shell:allow-execute", "fs:allow-read-file"], +) +``` + +Or via TOML: + +```toml +tauri_plugins = ["shell", "fs"] +extra_capabilities = ["shell:allow-execute", "fs:allow-read-file"] +``` + +Or the environment variable: + +```bash +export PYWRY_EXTRA_CAPABILITIES="shell:allow-execute,fs:allow-read-file" +``` + +!!! warning "Capability names use hyphens" + Tauri permission strings use **hyphens**, not underscores: `clipboard-manager:default`, not `clipboard_manager:default`. Plugin *config names* use underscores (Python identifiers), but capability *permission strings* use the Tauri convention. + +--- + +## Capabilities That Don't Exist + +Two plugins — `persisted_scope` and `single_instance` — do **not** register Tauri capability manifests. They are Rust-only plugins without permission-gated functionality. Including `persisted-scope:default` or `single-instance:default` in a capabilities file will cause a **Tauri panic** at startup. PyWry's `capabilities/default.toml` intentionally omits them. + +You can still _enable_ these plugins (they'll be initialised at the Rust level), but do not add capability strings for them. + +--- + +## Architecture Details + +### Plugin loading flow + +1. **`config.py`** — `PyWrySettings.tauri_plugins` validates names against `AVAILABLE_TAURI_PLUGINS`. +2. **`app.py`** — `PyWry.__init__()` calls `runtime.set_tauri_plugins(settings.tauri_plugins)` and `runtime.set_extra_capabilities(settings.extra_capabilities)`. +3. **`runtime.py`** — Stores both lists and passes them as `PYWRY_TAURI_PLUGINS` and `PYWRY_EXTRA_CAPABILITIES` environment variables when starting the subprocess. +4. **`__main__.py`** — The subprocess: + - Reads `PYWRY_TAURI_PLUGINS`, calls `_load_plugins()` which validates names, checks `PLUGIN_*` feature flags, dynamically imports modules, and calls `.init()`. + - Reads `PYWRY_EXTRA_CAPABILITIES`. If non-empty, copies the package directory to a temp staging dir and writes an `extra.toml` capability file with the requested permissions. This is necessary because `context_factory()` reads capabilities from static TOML files, and installed packages may be read-only. +5. **Tauri build** — `context_factory(ctx_dir)` reads all `.toml` files under `capabilities/` (including the staged `extra.toml` if present), then `builder_factory().build(plugins=plugins)` registers all initialised plugins with the Tauri engine. + +### Capability files + +Base permissions live in [`pywry/capabilities/default.toml`](https://github.com/deeleeramone/PyWry/blob/main/pywry/pywry/capabilities/default.toml). It pre-grants `:default` for all 17 plugins that have valid Tauri capability manifests. Permissions for plugins that aren't initialised are simply unused — they don't cause errors or security issues. + +When `extra_capabilities` is configured, an additional `extra.toml` is generated at runtime in a temporary directory with just the extra permissions. The temp directory is cleaned up when the subprocess exits. + +--- + +## Troubleshooting + +### "Unknown Tauri plugin 'xyz'" + +The plugin name isn't in the registry. Check spelling — use underscores (e.g. `clipboard_manager`, not `clipboard-manager`). + +### "PLUGIN_XYZ feature was not compiled" + +The `pytauri_wheel` binary was compiled without that plugin's feature flag. This shouldn't happen with the default PyWry distribution, but custom builds may exclude plugins. + +### App crashes on startup after adding capabilities + +If you get a `PanicException` mentioning `UnknownManifest`, you've added a capability string for a plugin that doesn't register one (`persisted_scope` or `single_instance`). Remove the offending string from `extra_capabilities`. + +--- + +## Next Steps + +- **[Configuration Guide](configuration.md)** — Full settings reference +- **[JavaScript Bridge](javascript-bridge.md)** — Calling Tauri APIs from JS +- **[Events](events.md)** — Python↔JS event system +- **[PyTauri Plugin Docs](https://pytauri.github.io/pytauri/latest/usage/tutorial/using-plugins/)** — Upstream plugin tutorial diff --git a/pywry/docs/docs/reference/config.md b/pywry/docs/docs/reference/config.md index 320ce8f..3f69ebd 100644 --- a/pywry/docs/docs/reference/config.md +++ b/pywry/docs/docs/reference/config.md @@ -116,6 +116,25 @@ Content Security Policy configuration. --- +## Tauri Plugin Constants + +::: pywry.config.TAURI_PLUGIN_REGISTRY + options: + show_root_heading: true + heading_level: 2 + +::: pywry.config.AVAILABLE_TAURI_PLUGINS + options: + show_root_heading: true + heading_level: 2 + +::: pywry.config.DEFAULT_TAURI_PLUGINS + options: + show_root_heading: true + heading_level: 2 + +--- + ## Settings Functions ::: pywry.config.get_settings diff --git a/pywry/docs/docs/reference/runtime.md b/pywry/docs/docs/reference/runtime.md index f100908..a20f7b0 100644 --- a/pywry/docs/docs/reference/runtime.md +++ b/pywry/docs/docs/reference/runtime.md @@ -55,6 +55,16 @@ Low-level PyTauri subprocess management and IPC communication. show_root_heading: true heading_level: 2 +::: pywry.runtime.set_tauri_plugins + options: + show_root_heading: true + heading_level: 2 + +::: pywry.runtime.set_extra_capabilities + options: + show_root_heading: true + heading_level: 2 + --- ## Command IPC diff --git a/pywry/docs/mkdocs.yml b/pywry/docs/mkdocs.yml index 11b0814..78a1890 100644 --- a/pywry/docs/mkdocs.yml +++ b/pywry/docs/mkdocs.yml @@ -178,6 +178,7 @@ nav: - Plotly Charts: guides/plotly.md - AgGrid Tables: guides/aggrid.md - Multi-Widget Pages: guides/multi-widget.md + - Tauri Plugins: guides/tauri-plugins.md - Hosting: - Browser Mode: guides/browser-mode.md - Deploy Mode: guides/deploy-mode.md diff --git a/pywry/pywry/__main__.py b/pywry/pywry/__main__.py index 3b1aced..cca1e97 100644 --- a/pywry/pywry/__main__.py +++ b/pywry/pywry/__main__.py @@ -5,7 +5,7 @@ """ # pylint: disable=C0301,C0413,C0415,C0103,W0718 -# flake8: noqa: N806 +# flake8: noqa: N806, PLR0915 import sys import typing @@ -136,11 +136,18 @@ def _set_macos_dock_icon() -> None: sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") +import importlib # noqa: E402 +import shutil # noqa: E402 +import tempfile # noqa: E402 + +import pytauri_plugins # noqa: E402 + from anyio import create_task_group # noqa: E402 from anyio.from_thread import start_blocking_portal # noqa: E402 from pytauri import Commands, Manager, RunEvent, WebviewUrl, WindowEvent # noqa: E402 from pytauri.webview import WebviewWindowBuilder # noqa: E402 -from pytauri_plugins import dialog as dialog_plugin, fs as fs_plugin # noqa: E402 + +from pywry.config import TAURI_PLUGIN_REGISTRY as _PLUGIN_REGISTRY # noqa: E402 # Try vendored pytauri_wheel first, fall back to installed package @@ -153,6 +160,91 @@ def _set_macos_dock_icon() -> None: from pytauri_wheel.lib import builder_factory, context_factory +def _load_plugins(plugin_names: list[str]) -> list[Any]: + """Dynamically import and initialise the requested Tauri plugins. + + Parameters + ---------- + plugin_names : list[str] + Plugin names to load (e.g. ``["dialog", "fs"]``). + + Returns + ------- + list + Initialised plugin objects for ``builder_factory().build(plugins=...)``. + + Raises + ------ + RuntimeError + If a plugin is unknown or its feature flag is disabled in the + compiled ``pytauri_wheel``. + """ + plugins: list[Any] = [] + for name in plugin_names: + if name not in _PLUGIN_REGISTRY: + msg = f"Unknown Tauri plugin '{name}'. Available: {', '.join(sorted(_PLUGIN_REGISTRY))}" + raise RuntimeError(msg) + + flag_name, module_path = _PLUGIN_REGISTRY[name] + + # Check the compile-time feature flag + flag_value = getattr(pytauri_plugins, flag_name, None) + if flag_value is not True: + msg = ( + f"Tauri plugin '{name}' is not available — the " + f"'{flag_name}' feature was not compiled into pytauri_wheel. " + f"Current value: {flag_value!r}" + ) + raise RuntimeError(msg) + + mod = importlib.import_module(module_path) + plugins.append(mod.init()) + log(f"Loaded Tauri plugin: {name}") + + return plugins + + +def _stage_extra_capabilities( + src_dir: Path, + extra_caps: list[str], +) -> Path: + """Create a temp copy of *src_dir* with an extra capability TOML. + + Tauri's ``context_factory(src_dir)`` reads every ``.toml`` file under + ``/capabilities/``. The package directory may be read-only + (e.g. installed in site-packages), so we copy the entire source tree to + a temporary directory and add an ``extra.toml`` capability file there. + + Parameters + ---------- + src_dir : Path + Original ``pywry/pywry`` package directory. + extra_caps : list[str] + Tauri permission strings, e.g. ``["shell:allow-execute"]``. + + Returns + ------- + Path + The temporary directory to pass to ``context_factory()``. + """ + tmp_dir = Path(tempfile.mkdtemp(prefix="pywry_caps_")) + # Copy Tauri.toml, capabilities/, frontend/ — everything context_factory needs + shutil.copytree(src_dir, tmp_dir, dirs_exist_ok=True) + + # Write the extra capability file + extra_toml = tmp_dir / "capabilities" / "extra.toml" + perms = ", ".join(f'"{p}"' for p in extra_caps) + extra_toml.write_text( + f'identifier = "extra"\n' + f'description = "User-supplied extra capabilities"\n' + f'windows = ["*"]\n' + f"permissions = [{perms}]\n", + encoding="utf-8", + ) + log(f"Staged extra capabilities in {tmp_dir}: {extra_caps}") + return tmp_dir + + # Debug mode controlled by environment variable DEBUG = os.environ.get("PYWRY_DEBUG", "").lower() in ("1", "true", "yes", "on") @@ -852,6 +944,7 @@ def main() -> int: # pylint: disable=too-many-statements log(f"Starting subprocess... (headless={HEADLESS})") src_dir = Path(__file__).parent.absolute() ipc = JsonIPC() + tmp_caps_dir: Path | None = None # Start stdin reader thread reader_thread = threading.Thread(target=stdin_reader, args=(ipc,), daemon=True) reader_thread.start() @@ -861,14 +954,28 @@ def main() -> int: # pylint: disable=too-many-statements start_blocking_portal("asyncio") as portal, portal.wrap_async_context_manager(portal.call(create_task_group)) as _, ): - context = context_factory(src_dir) + # --- Dynamic plugin initialisation from env var --- + plugin_csv = os.environ.get("PYWRY_TAURI_PLUGINS", "dialog,fs") + plugin_names = [p.strip() for p in plugin_csv.split(",") if p.strip()] + log(f"Requested Tauri plugins: {plugin_names}") + plugins = _load_plugins(plugin_names) + + # --- Extra capabilities from env var --- + extra_csv = os.environ.get("PYWRY_EXTRA_CAPABILITIES", "") + extra_caps = [c.strip() for c in extra_csv.split(",") if c.strip()] + ctx_dir = src_dir + if extra_caps: + tmp_caps_dir = _stage_extra_capabilities(src_dir, extra_caps) + ctx_dir = tmp_caps_dir + + context = context_factory(ctx_dir) commands = Commands() register_commands(commands) app = builder_factory().build( context=context, invoke_handler=commands.generate_handler(portal), - plugins=[dialog_plugin.init(), fs_plugin.init()], + plugins=plugins, ) def on_run(app_handle: Any, run_event: Any) -> None: @@ -898,6 +1005,10 @@ def on_run(app_handle: Any, run_event: Any) -> None: traceback.print_exc() return 1 + finally: + # Clean up staged capabilities temp dir + if tmp_caps_dir is not None: + shutil.rmtree(tmp_caps_dir, ignore_errors=True) log("Subprocess exiting") return 0 diff --git a/pywry/pywry/app.py b/pywry/pywry/app.py index 5bd16b4..b78205e 100644 --- a/pywry/pywry/app.py +++ b/pywry/pywry/app.py @@ -117,6 +117,9 @@ def __init__( WindowMode.NEW_WINDOW: "new", } runtime.set_window_mode(mode_map.get(mode, "new")) + # Pass Tauri plugin selection to subprocess + runtime.set_tauri_plugins(self._settings.tauri_plugins) + runtime.set_extra_capabilities(self._settings.extra_capabilities) # Initialize the appropriate window mode self._mode: WindowModeBase = self._create_mode(mode) diff --git a/pywry/pywry/capabilities/default.toml b/pywry/pywry/capabilities/default.toml index 494ad52..a84792c 100644 --- a/pywry/pywry/capabilities/default.toml +++ b/pywry/pywry/capabilities/default.toml @@ -2,6 +2,7 @@ identifier = "default" description = "Capability for PyWry windows" windows = ["*"] permissions = [ + # Core Tauri permissions "core:default", "pytauri:default", "core:window:allow-close", @@ -14,7 +15,26 @@ permissions = [ "core:window:allow-set-focus", "core:webview:default", "core:app:default", - "notification:default", + + # All bundled pytauri_plugins — permissions are harmless if the + # corresponding plugin is not initialised at runtime. + # NOTE: only plugins with valid Tauri manifest keys are listed here. + # persisted-scope and single-instance do not register capability manifests. + "autostart:default", + "clipboard-manager:default", + "deep-link:default", "dialog:default", "fs:default", + "global-shortcut:default", + "http:default", + "notification:default", + "opener:default", + "os:default", + "positioner:default", + "process:default", + "shell:default", + "updater:default", + "upload:default", + "websocket:default", + "window-state:default", ] diff --git a/pywry/pywry/config.py b/pywry/pywry/config.py index 100b565..b896a63 100644 --- a/pywry/pywry/config.py +++ b/pywry/pywry/config.py @@ -874,6 +874,37 @@ def parse_comma_separated(cls, v: Any) -> list[str]: return v or [] +# All Tauri plugins supported by the bundled pytauri_wheel. +# Maps plugin name -> (feature flag constant name, module path). +TAURI_PLUGIN_REGISTRY: dict[str, tuple[str, str]] = { + "autostart": ("PLUGIN_AUTOSTART", "pytauri_plugins.autostart"), + "clipboard_manager": ("PLUGIN_CLIPBOARD_MANAGER", "pytauri_plugins.clipboard_manager"), + "deep_link": ("PLUGIN_DEEP_LINK", "pytauri_plugins.deep_link"), + "dialog": ("PLUGIN_DIALOG", "pytauri_plugins.dialog"), + "fs": ("PLUGIN_FS", "pytauri_plugins.fs"), + "global_shortcut": ("PLUGIN_GLOBAL_SHORTCUT", "pytauri_plugins.global_shortcut"), + "http": ("PLUGIN_HTTP", "pytauri_plugins.http"), + "notification": ("PLUGIN_NOTIFICATION", "pytauri_plugins.notification"), + "opener": ("PLUGIN_OPENER", "pytauri_plugins.opener"), + "os": ("PLUGIN_OS", "pytauri_plugins.os"), + "persisted_scope": ("PLUGIN_PERSISTED_SCOPE", "pytauri_plugins.persisted_scope"), + "positioner": ("PLUGIN_POSITIONER", "pytauri_plugins.positioner"), + "process": ("PLUGIN_PROCESS", "pytauri_plugins.process"), + "shell": ("PLUGIN_SHELL", "pytauri_plugins.shell"), + "single_instance": ("PLUGIN_SINGLE_INSTANCE", "pytauri_plugins.single_instance"), + "updater": ("PLUGIN_UPDATER", "pytauri_plugins.updater"), + "upload": ("PLUGIN_UPLOAD", "pytauri_plugins.upload"), + "websocket": ("PLUGIN_WEBSOCKET", "pytauri_plugins.websocket"), + "window_state": ("PLUGIN_WINDOW_STATE", "pytauri_plugins.window_state"), +} + +#: Names of all known Tauri plugins (for validation). +AVAILABLE_TAURI_PLUGINS: frozenset[str] = frozenset(TAURI_PLUGIN_REGISTRY) + +#: Default plugins that ship enabled with PyWry. +DEFAULT_TAURI_PLUGINS: list[str] = ["dialog", "fs"] + + class PyWrySettings(BaseSettings): """Main settings aggregating all configuration sections. @@ -909,6 +940,56 @@ class PyWrySettings(BaseSettings): description="OAuth2 authentication settings (None to disable)", ) + # Tauri plugin configuration + tauri_plugins: Annotated[list[str], NoDecode] = Field( + default_factory=lambda: list(DEFAULT_TAURI_PLUGINS), + description=( + "Tauri plugins to initialise in the native subprocess. " + "Each name must be one of the 19 plugins bundled in pytauri_wheel " + "(e.g. 'dialog', 'fs', 'notification', 'http'). " + "Set via PYWRY_TAURI_PLUGINS env var (comma-separated) or in " + "pyproject.toml / pywry.toml under [tool.pywry]." + ), + ) + extra_capabilities: Annotated[list[str], NoDecode] = Field( + default_factory=list, + description=( + "Additional Tauri capability permission strings to grant beyond " + "the auto-generated ':default' entries (e.g. " + "'shell:allow-execute', 'fs:allow-read-file'). " + "Set via PYWRY_EXTRA_CAPABILITIES env var (comma-separated)." + ), + ) + + @field_validator("tauri_plugins", mode="before") + @classmethod + def _parse_tauri_plugins(cls, v: Any) -> list[str]: + """Accept a comma-separated string (from env var) or a list.""" + if isinstance(v, str): + v = [p.strip() for p in v.split(",") if p.strip()] + if not isinstance(v, list): + msg = f"tauri_plugins must be a list or comma-separated string, got {type(v).__name__}" + raise TypeError(msg) + unknown = set(v) - AVAILABLE_TAURI_PLUGINS + if unknown: + msg = ( + f"Unknown Tauri plugin(s): {', '.join(sorted(unknown))}. " + f"Available: {', '.join(sorted(AVAILABLE_TAURI_PLUGINS))}" + ) + raise ValueError(msg) + return v + + @field_validator("extra_capabilities", mode="before") + @classmethod + def _parse_extra_capabilities(cls, v: Any) -> list[str]: + """Accept a comma-separated string (from env var) or a list.""" + if isinstance(v, str): + v = [p.strip() for p in v.split(",") if p.strip()] + if not isinstance(v, list): + msg = f"extra_capabilities must be a list or comma-separated string, got {type(v).__name__}" + raise TypeError(msg) + return v + # Tracks where each value came from (for CLI display) _sources: ClassVar[dict[str, str]] = {} @@ -933,6 +1014,14 @@ def to_toml(self) -> str: """Export settings as TOML string.""" lines = ["# PyWry Configuration", "# Generated by: pywry config --toml", ""] + # Top-level list fields + tp = "[" + ", ".join(f'"{p}"' for p in self.tauri_plugins) + "]" + lines.append(f"tauri_plugins = {tp}") + if self.extra_capabilities: + ec = "[" + ", ".join(f'"{c}"' for c in self.extra_capabilities) + "]" + lines.append(f"extra_capabilities = {ec}") + lines.append("") + section_names = [ "csp", "theme", @@ -985,6 +1074,12 @@ def to_env(self) -> str: "", ] + # Top-level list fields + lines.append(f'export PYWRY_TAURI_PLUGINS="{",".join(self.tauri_plugins)}"') + if self.extra_capabilities: + lines.append(f'export PYWRY_EXTRA_CAPABILITIES="{",".join(self.extra_capabilities)}"') + lines.append("") + env_sections = [ ("CSP", "csp"), ("THEME", "theme"), diff --git a/pywry/pywry/runtime.py b/pywry/pywry/runtime.py index 683be6b..ab167ba 100644 --- a/pywry/pywry/runtime.py +++ b/pywry/pywry/runtime.py @@ -53,6 +53,8 @@ def is_headless() -> bool: _registry = None _ON_WINDOW_CLOSE = "hide" # Setting for MULTI_WINDOW close behavior _WINDOW_MODE = "new" # Window mode: "single", "multi", "new" +_TAURI_PLUGINS = "dialog,fs" # Comma-separated Tauri plugin names +_EXTRA_CAPABILITIES = "" # Comma-separated extra capability permission strings # Portal state for async callback support _exit_stack: ExitStack | None = None @@ -150,6 +152,34 @@ def set_window_mode(mode: str) -> None: _WINDOW_MODE = mode if mode in ("single", "multi", "new") else "new" +def set_tauri_plugins(plugins: list[str]) -> None: + """Set the Tauri plugins to initialise in the subprocess. + + Must be called before ``start()``. + + Parameters + ---------- + plugins : list[str] + Plugin names (e.g. ``["dialog", "fs", "notification"]``). + """ + global _TAURI_PLUGINS + _TAURI_PLUGINS = ",".join(plugins) + + +def set_extra_capabilities(caps: list[str]) -> None: + """Set additional Tauri capability permission strings. + + Must be called before ``start()``. + + Parameters + ---------- + caps : list[str] + Permission strings (e.g. ``["shell:allow-execute"]``). + """ + global _EXTRA_CAPABILITIES + _EXTRA_CAPABILITIES = ",".join(caps) + + def get_pywry_dir() -> Path: """Get the pywry directory containing the subprocess entry point.""" return Path(__file__).parent.absolute() @@ -797,6 +827,9 @@ def start() -> bool: env["PYTHONUTF8"] = "1" # Force UTF-8 env["PYWRY_ON_WINDOW_CLOSE"] = _ON_WINDOW_CLOSE # Pass close behavior to subprocess env["PYWRY_WINDOW_MODE"] = _WINDOW_MODE # Pass window mode to subprocess + env["PYWRY_TAURI_PLUGINS"] = _TAURI_PLUGINS # Tauri plugins to initialise + if _EXTRA_CAPABILITIES: + env["PYWRY_EXTRA_CAPABILITIES"] = _EXTRA_CAPABILITIES # On Windows, CREATE_NEW_PROCESS_GROUP prevents the subprocess from # receiving CTRL_C_EVENT when the user presses Ctrl+C in the terminal. diff --git a/pywry/pywry/state/redis.py b/pywry/pywry/state/redis.py index d6fceec..bd7c50d 100644 --- a/pywry/pywry/state/redis.py +++ b/pywry/pywry/state/redis.py @@ -574,20 +574,27 @@ async def get_session(self, session_id: str) -> UserSession | None: with contextlib.suppress(json.JSONDecodeError): metadata = json.loads(data["metadata"]) + expires_at = float(data.get("expires_at", 0)) + + # Check Python-side expiry as belt-and-suspenders alongside Redis TTL. + # Redis expires keys lazily, so under load a key may briefly outlive + # its TTL. Checking the stored timestamp catches that case. + if expires_at and expires_at < time.time(): + return None + return UserSession( session_id=session_id, user_id=data.get("user_id", ""), roles=roles, created_at=float(data.get("created_at", 0)), - expires_at=float(data.get("expires_at", 0)), + expires_at=expires_at, metadata=metadata, ) async def validate_session(self, session_id: str) -> bool: """Validate a session is active and not expired.""" - r = await self._redis() - result = await r.exists(self._session_key(session_id)) - return cast("bool", result > 0) + session = await self.get_session(session_id) + return session is not None async def delete_session(self, session_id: str) -> bool: """Delete a session.""" From a96ac481168c0dd5b540bf5a1e9bd82dbb156c81 Mon Sep 17 00:00:00 2001 From: deeleeramone <> Date: Sat, 21 Feb 2026 20:31:08 -0800 Subject: [PATCH 2/2] test files --- pywry/tests/test_config.py | 83 +++++++++ pywry/tests/test_tauri_plugins.py | 280 +++++++++++++++++++++++++++--- 2 files changed, 341 insertions(+), 22 deletions(-) diff --git a/pywry/tests/test_config.py b/pywry/tests/test_config.py index 84bfe6e..a9c95e0 100644 --- a/pywry/tests/test_config.py +++ b/pywry/tests/test_config.py @@ -7,6 +7,8 @@ import pytest from pywry.config import ( + AVAILABLE_TAURI_PLUGINS, + DEFAULT_TAURI_PLUGINS, AssetSettings, HotReloadSettings, PyWrySettings, @@ -573,3 +575,84 @@ def test_asset_in_show_output(self): output = settings.show() assert "Assets" in output assert "plotly_version" in output + + +# ============================================================================= +# Tauri Plugin Settings Tests +# ============================================================================= + + +class TestTauriPluginSettings: + """Tests for tauri_plugins and extra_capabilities config fields.""" + + def test_default_plugins(self): + """Default tauri_plugins value is ['dialog', 'fs'].""" + settings = PyWrySettings() + assert settings.tauri_plugins == DEFAULT_TAURI_PLUGINS + + def test_custom_plugins_list(self): + """Custom plugin list is accepted.""" + settings = PyWrySettings(tauri_plugins=["dialog", "fs", "notification"]) + assert "notification" in settings.tauri_plugins + + def test_comma_separated_string(self): + """Comma-separated string is parsed into a list.""" + settings = PyWrySettings(tauri_plugins="dialog,fs,http") + assert settings.tauri_plugins == ["dialog", "fs", "http"] + + def test_comma_separated_with_spaces(self): + """Comma-separated string with spaces is trimmed.""" + settings = PyWrySettings(tauri_plugins=" dialog , fs , notification ") + assert settings.tauri_plugins == ["dialog", "fs", "notification"] + + def test_unknown_plugin_raises(self): + """Unknown plugin name raises ValueError.""" + with pytest.raises(ValueError, match="Unknown Tauri plugin"): + PyWrySettings(tauri_plugins=["dialog", "nonexistent_plugin"]) + + def test_all_known_plugins_accepted(self): + """All 19 known plugins are individually accepted.""" + for name in AVAILABLE_TAURI_PLUGINS: + settings = PyWrySettings(tauri_plugins=[name]) + assert settings.tauri_plugins == [name] + + def test_extra_capabilities_default_empty(self): + """Default extra_capabilities is an empty list.""" + settings = PyWrySettings() + assert settings.extra_capabilities == [] + + def test_extra_capabilities_list(self): + """Custom capability list is accepted.""" + caps = ["shell:allow-execute", "fs:allow-read-file"] + settings = PyWrySettings(extra_capabilities=caps) + assert settings.extra_capabilities == caps + + def test_extra_capabilities_comma_string(self): + """Comma-separated capability string is parsed.""" + settings = PyWrySettings(extra_capabilities="shell:allow-execute,fs:allow-read-file") + assert settings.extra_capabilities == ["shell:allow-execute", "fs:allow-read-file"] + + def test_plugins_in_toml_export(self): + """Tauri plugins appear in TOML export.""" + settings = PyWrySettings(tauri_plugins=["dialog", "fs", "notification"]) + toml = settings.to_toml() + assert "tauri_plugins" in toml + assert '"notification"' in toml + + def test_plugins_in_env_export(self): + """Tauri plugins appear in env export.""" + settings = PyWrySettings(tauri_plugins=["dialog", "fs", "http"]) + env = settings.to_env() + assert "PYWRY_TAURI_PLUGINS" in env + assert "http" in env + + def test_env_var_override(self, monkeypatch): + """PYWRY_TAURI_PLUGINS env var overrides default.""" + monkeypatch.setenv("PYWRY__TAURI_PLUGINS", "dialog,fs,notification,http") + settings = PyWrySettings() + assert "notification" in settings.tauri_plugins + assert "http" in settings.tauri_plugins + + def test_available_plugins_has_19(self): + """Registry contains exactly 19 plugins.""" + assert len(AVAILABLE_TAURI_PLUGINS) == 19 diff --git a/pywry/tests/test_tauri_plugins.py b/pywry/tests/test_tauri_plugins.py index a896b65..5e8e49a 100644 --- a/pywry/tests/test_tauri_plugins.py +++ b/pywry/tests/test_tauri_plugins.py @@ -2,6 +2,7 @@ Tests verify: - Dialog and FS plugins are properly registered +- Dynamic plugin loading from config / env vars works - Tauri APIs are available in the webview - File save dialog functionality works - Plugin capabilities are correctly configured @@ -20,6 +21,7 @@ from pywry import runtime from pywry.app import PyWry +from pywry.config import AVAILABLE_TAURI_PLUGINS, TAURI_PLUGIN_REGISTRY from pywry.models import ThemeMode # Import shared test utilities from tests.conftest @@ -85,6 +87,77 @@ def test_capabilities_has_pytauri_permission(self): content = capabilities_file.read_text(encoding="utf-8") assert "pytauri:default" in content, "pytauri:default permission not found in capabilities" + def test_capabilities_has_all_plugin_permissions(self): + """Capabilities file includes ':default' for plugins that register manifests.""" + capabilities_file = Path(__file__).parent.parent / "pywry" / "capabilities" / "default.toml" + content = capabilities_file.read_text(encoding="utf-8") + # Plugin names in capabilities use hyphens (e.g. clipboard-manager, not clipboard_manager) + # NOTE: persisted-scope and single-instance do NOT register Tauri capability + # manifests, so they are intentionally excluded from default.toml. + expected = { + "autostart:default", + "clipboard-manager:default", + "deep-link:default", + "dialog:default", + "fs:default", + "global-shortcut:default", + "http:default", + "notification:default", + "opener:default", + "os:default", + "positioner:default", + "process:default", + "shell:default", + "updater:default", + "upload:default", + "websocket:default", + "window-state:default", + } + for perm in expected: + assert perm in content, f"{perm} not found in capabilities" + + # These must NOT be in the file — they cause Tauri panics + assert "persisted-scope:default" not in content + assert "single-instance:default" not in content + + +class TestPluginRegistry: + """Tests for the Tauri plugin registry constants.""" + + def test_registry_has_19_entries(self): + """TAURI_PLUGIN_REGISTRY contains exactly 19 plugins.""" + assert len(TAURI_PLUGIN_REGISTRY) == 19 + + def test_available_plugins_matches_registry(self): + """AVAILABLE_TAURI_PLUGINS matches registry keys.""" + assert frozenset(TAURI_PLUGIN_REGISTRY) == AVAILABLE_TAURI_PLUGINS + + def test_registry_values_are_tuples(self): + """Each registry value is a (flag_name, module_path) tuple.""" + for name, (flag, module) in TAURI_PLUGIN_REGISTRY.items(): + assert flag.startswith("PLUGIN_"), f"{name}: flag {flag!r} doesn't start with PLUGIN_" + assert module.startswith("pytauri_plugins."), ( + f"{name}: module {module!r} has wrong prefix" + ) + + +class TestRuntimePluginSetters: + """Tests for runtime.set_tauri_plugins() and set_extra_capabilities().""" + + def test_set_tauri_plugins_updates_state(self): + """set_tauri_plugins() updates the module-level variable.""" + runtime.set_tauri_plugins(["dialog", "fs", "notification"]) + assert runtime._TAURI_PLUGINS == "dialog,fs,notification" + # Restore default + runtime.set_tauri_plugins(["dialog", "fs"]) + + def test_set_extra_capabilities_updates_state(self): + """set_extra_capabilities() updates the module-level variable.""" + runtime.set_extra_capabilities(["shell:allow-execute"]) + assert runtime._EXTRA_CAPABILITIES == "shell:allow-execute" + # Restore default + runtime.set_extra_capabilities([]) + class TestPluginImports: """Tests for plugin module imports.""" @@ -105,39 +178,202 @@ def test_can_import_fs_plugin(self): class TestMainModulePluginRegistration: """Tests for plugin registration in __main__.py.""" - def test_main_imports_dialog_plugin(self): - """__main__.py imports dialog plugin.""" + def test_main_has_plugin_registry(self): + """__main__.py imports _PLUGIN_REGISTRY from config.""" + main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" + content = main_file.read_text(encoding="utf-8") + assert "_PLUGIN_REGISTRY" in content, "_PLUGIN_REGISTRY not found in __main__.py" + assert "TAURI_PLUGIN_REGISTRY" in content, "import from config not found" + + def test_main_has_load_plugins_function(self): + """__main__.py defines _load_plugins() for dynamic loading.""" + main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" + content = main_file.read_text(encoding="utf-8") + assert "def _load_plugins(" in content, "_load_plugins function not found" + + def test_main_reads_env_var(self): + """__main__.py reads PYWRY_TAURI_PLUGINS env var.""" + main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" + content = main_file.read_text(encoding="utf-8") + assert "PYWRY_TAURI_PLUGINS" in content, "PYWRY_TAURI_PLUGINS env var not read" + + def test_main_registers_plugins_dynamically(self): + """__main__.py passes dynamic plugins list to builder.build().""" + main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" + content = main_file.read_text(encoding="utf-8") + assert "plugins=plugins" in content or "plugins = plugins" in content, ( + "Dynamic plugins list not passed to builder.build()" + ) + + def test_main_reads_extra_capabilities_env(self): + """__main__.py reads PYWRY_EXTRA_CAPABILITIES env var.""" main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" content = main_file.read_text(encoding="utf-8") - # Check for various import patterns including aliased imports - has_dialog_import = ( - "from pytauri_plugins import dialog" in content - or "pytauri_plugins.dialog" in content - or "dialog as dialog_plugin" in content + assert "PYWRY_EXTRA_CAPABILITIES" in content, ( + "PYWRY_EXTRA_CAPABILITIES env var not consumed in __main__.py" ) - assert has_dialog_import, "dialog plugin import not found in __main__.py" - def test_main_imports_fs_plugin(self): - """__main__.py imports fs plugin.""" + def test_main_calls_stage_extra_capabilities(self): + """__main__.py calls _stage_extra_capabilities when extra caps are present.""" main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" content = main_file.read_text(encoding="utf-8") - # Check for various import patterns including aliased imports - has_fs_import = ( - "from pytauri_plugins import fs" in content - or "pytauri_plugins.fs" in content - or "fs as fs_plugin" in content + assert "_stage_extra_capabilities(" in content, ( + "_stage_extra_capabilities not called in main()" ) - assert has_fs_import, "fs plugin import not found in __main__.py" - def test_main_registers_plugins(self): - """__main__.py registers plugins in builder.build().""" + def test_main_cleans_up_temp_dir(self): + """__main__.py cleans up staged temp dir in a finally block.""" main_file = Path(__file__).parent.parent / "pywry" / "__main__.py" content = main_file.read_text(encoding="utf-8") - assert "plugins=" in content, "plugins= parameter not found in __main__.py" - assert "dialog" in content and "init()" in content, ( - "dialog.init() not found in plugins list" + assert "shutil.rmtree(tmp_caps_dir" in content, "Temp dir cleanup not found in __main__.py" + + +def _import_stage_extra_capabilities(): + """Import _stage_extra_capabilities from __main__.py safely. + + Importing pywry.__main__ replaces sys.stdin/stdout/stderr with fresh + UTF-8 TextIOWrapper objects (on Windows) that wrap the *same* buffer. + This corrupts pytest's capture system. We swap in decoy BytesIO-backed + streams before the import so the originals are never touched, then + restore them immediately after. + """ + import io + import sys + + orig = sys.stdin, sys.stdout, sys.stderr + # Give __main__'s module-level code decoy streams to wrap + sys.stdin = io.TextIOWrapper(io.BytesIO(), encoding="utf-8") + sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding="utf-8") + sys.stderr = io.TextIOWrapper(io.BytesIO(), encoding="utf-8") + try: + from pywry.__main__ import _stage_extra_capabilities + finally: + sys.stdin, sys.stdout, sys.stderr = orig + return _stage_extra_capabilities + + +class TestStageExtraCapabilities: + """Tests for _stage_extra_capabilities() in __main__.py.""" + + def test_creates_temp_dir_with_extra_toml(self, tmp_path: Path): + """Staging creates a capabilities/extra.toml with requested permissions.""" + fn = _import_stage_extra_capabilities() + + src_dir = tmp_path / "src" + caps_dir = src_dir / "capabilities" + caps_dir.mkdir(parents=True) + (caps_dir / "default.toml").write_text( + 'identifier = "default"\npermissions = ["dialog:default"]\n', + encoding="utf-8", ) - assert "fs" in content, "fs plugin not found in plugins list" + (src_dir / "Tauri.toml").write_text("[tauri]\n", encoding="utf-8") + + result = fn(src_dir, ["shell:allow-execute", "http:default"]) + + try: + extra_toml = result / "capabilities" / "extra.toml" + assert extra_toml.exists(), "extra.toml not created" + content = extra_toml.read_text(encoding="utf-8") + assert 'identifier = "extra"' in content + assert '"shell:allow-execute"' in content + assert '"http:default"' in content + finally: + import shutil + + shutil.rmtree(result, ignore_errors=True) + + def test_preserves_original_default_toml(self, tmp_path: Path): + """Staging copies default.toml without modifying the original.""" + fn = _import_stage_extra_capabilities() + + src_dir = tmp_path / "src" + caps_dir = src_dir / "capabilities" + caps_dir.mkdir(parents=True) + original_content = 'identifier = "default"\npermissions = ["dialog:default"]\n' + (caps_dir / "default.toml").write_text(original_content, encoding="utf-8") + (src_dir / "Tauri.toml").write_text("[tauri]\n", encoding="utf-8") + + result = fn(src_dir, ["notification:default"]) + + try: + # Original file untouched + assert (caps_dir / "default.toml").read_text(encoding="utf-8") == original_content + # Copied default.toml in temp dir also intact + copied = (result / "capabilities" / "default.toml").read_text(encoding="utf-8") + assert copied == original_content + finally: + import shutil + + shutil.rmtree(result, ignore_errors=True) + + def test_returns_path_object(self, tmp_path: Path): + """Staging returns a Path pointing to the temp directory.""" + fn = _import_stage_extra_capabilities() + + src_dir = tmp_path / "src" + caps_dir = src_dir / "capabilities" + caps_dir.mkdir(parents=True) + (caps_dir / "default.toml").write_text( + 'identifier = "default"\npermissions = []\n', + encoding="utf-8", + ) + + result = fn(src_dir, ["os:default"]) + + try: + assert isinstance(result, Path) + assert result.is_dir() + assert (result / "capabilities").is_dir() + finally: + import shutil + + shutil.rmtree(result, ignore_errors=True) + + def test_extra_toml_has_wildcard_windows(self, tmp_path: Path): + """Extra capability file targets all windows with wildcard.""" + fn = _import_stage_extra_capabilities() + + src_dir = tmp_path / "src" + caps_dir = src_dir / "capabilities" + caps_dir.mkdir(parents=True) + (caps_dir / "default.toml").write_text( + 'identifier = "default"\npermissions = []\n', + encoding="utf-8", + ) + + result = fn(src_dir, ["process:default"]) + + try: + content = (result / "capabilities" / "extra.toml").read_text(encoding="utf-8") + assert 'windows = ["*"]' in content + finally: + import shutil + + shutil.rmtree(result, ignore_errors=True) + + def test_copies_tauri_toml(self, tmp_path: Path): + """Staging copies Tauri.toml alongside capabilities.""" + fn = _import_stage_extra_capabilities() + + src_dir = tmp_path / "src" + caps_dir = src_dir / "capabilities" + caps_dir.mkdir(parents=True) + (caps_dir / "default.toml").write_text( + 'identifier = "default"\npermissions = []\n', + encoding="utf-8", + ) + tauri_content = '[tauri]\nidentifier = "com.pywry"\n' + (src_dir / "Tauri.toml").write_text(tauri_content, encoding="utf-8") + + result = fn(src_dir, ["os:default"]) + + try: + assert (result / "Tauri.toml").exists(), "Tauri.toml not copied" + assert (result / "Tauri.toml").read_text(encoding="utf-8") == tauri_content + finally: + import shutil + + shutil.rmtree(result, ignore_errors=True) # =============================================================================