Metadata-Version: 2.4
Name: filechooser-portal-mock
Version: 0.1
Summary: Mock xdg-desktop-portal FileChooser D-Bus service for automating native file dialogs in headless/CI GUI and browser tests.
Project-URL: Homepage, https://github.com/mithro/filechooser-portal-mock
Project-URL: Repository, https://github.com/mithro/filechooser-portal-mock
Project-URL: Issues, https://github.com/mithro/filechooser-portal-mock/issues
Project-URL: Changelog, https://github.com/mithro/filechooser-portal-mock/blob/main/CHANGELOG.md
Author-email: Tim Ansell <me@mith.ro>
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: chrome,chromium,ci,dbus,file-dialog,filechooser,headless,mock,playwright,portal,selenium,test-double,testing,xdg-desktop-portal
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: X11 Applications
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Desktop Environment
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.9
Requires-Dist: pydbus>=0.6.0
Requires-Dist: pygobject>=3.42
Provides-Extra: examples
Requires-Dist: selenium>=4.0; extra == 'examples'
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == 'test'
Description-Content-Type: text/markdown

# filechooser-portal-mock

A mock [`xdg-desktop-portal`](https://flatpak.github.io/xdg-desktop-portal/)
**FileChooser** D-Bus service for automating native file dialogs in headless/CI
GUI and browser tests.

When a modern application (Chromium, any GTK app) needs to open a file dialog,
it does not draw the dialog itself — it makes a D-Bus call to
`org.freedesktop.portal.Desktop`. In a headless or CI environment there is no
desktop portal to answer, so the application hangs or times out.

`filechooser-portal-mock` **impersonates that portal**. It answers the
`OpenFile`/`SaveFile` call and emits a `Response` signal carrying a path you
choose, so the application proceeds exactly as if a human had picked it — with
no GUI, under `Xvfb`, in CI.

The flagship use case is loading an **unpacked Chrome extension** in an
automated test (`chrome://extensions` → "Load unpacked"), but it works for any
open/save file dialog routed through the portal.

```text
   app under test                 filechooser-portal-mock
  ┌───────────────┐   OpenFile()  ┌───────────────────────┐
  │  Chrome /      │ ────────────► │  org.freedesktop      │
  │  GTK app       │               │  .portal.Desktop      │
  │               │ ◄──────────── │  (FileChooser mock)   │
  └───────────────┘   Response     └───────────────────────┘
        "user picked file:///path/you/configured"
```

## Why a portal mock (and not clicking the dialog)?

The file picker is a separate, privileged process; you cannot reliably
screen-scrape or `xdotool` it across desktops and versions. Intercepting the
request at the D-Bus layer is robust and deterministic. See
[`docs/protocol.md`](docs/protocol.md) for the protocol details and the
hard-won `pydbus`-vs-`dbus-python` findings that make this work in subprocess
contexts.

## Installation

This package binds to your system's GLib via **PyGObject** and **pydbus**, which
are built against system libraries. Install the system dependencies first.

**Debian / Ubuntu:**

```bash
sudo apt install -y dbus python3-dev pkg-config \
    libcairo2-dev libgirepository-2.0-dev libglib2.0-dev
pip install filechooser-portal-mock
```

> Older distributions provide girepository under `libgirepository1.0-dev`.

**Fedora:**

```bash
sudo dnf install -y dbus python3-devel pkgconf-pkg-config \
    cairo-devel gobject-introspection-devel glib2-devel
pip install filechooser-portal-mock
```

If your distribution packages PyGObject and pydbus (e.g. `python3-gi`,
`python3-pydbus`), you can install those from the system instead of building
them from source.

A virtual X server (`Xvfb`) is only needed by the *application under test*, not
by this tool. The portal itself needs no display.

## Quick start

### Command line (works from any language / test harness)

```bash
# Start a portal on a private bus that answers every file dialog with a path:
filechooser-portal --isolated-bus /abs/path/to/extension
# -> prints:  DBUS_SESSION_BUS_ADDRESS=unix:abstract=...
#             FILECHOOSER_PORTAL_READY

# Other answers:
filechooser-portal --cancel      # simulate the user cancelling
filechooser-portal --error       # simulate a failed request
```

Point your application at the printed bus address
(`DBUS_SESSION_BUS_ADDRESS=...`) and it will receive that path when it opens a
file dialog. The `FILECHOOSER_PORTAL_READY` line lets you wait for readiness
instead of sleeping.

### Python (context manager)

```python
from filechooser_portal import serve

with serve("/abs/path/to/extension") as portal:
    launch_app(env=portal.env)   # portal.env has DBUS_SESSION_BUS_ADDRESS set
# portal (and its private bus) are torn down on exit
```

### pytest fixture

Installing the package registers a `file_chooser_portal` fixture automatically:

```python
def test_load_unpacked(file_chooser_portal):
    portal = file_chooser_portal("/abs/path/to/extension")
    launch_chrome(env=portal.env)
    ...
```

## Loading an unpacked Chrome extension (Selenium)

```python
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from filechooser_portal import serve

with serve("/abs/path/to/extension") as portal:
    options = Options()
    options.add_argument("--ozone-platform=x11")     # run under Xvfb, not headless
    # Run Chrome on the same private bus as the portal:
    import os
    os.environ["DBUS_SESSION_BUS_ADDRESS"] = portal.bus_address
    driver = webdriver.Chrome(options=options)

    driver.get("chrome://extensions")
    # enable developer mode, then click "Load unpacked" via JS; the portal
    # answers the directory chooser with your extension path.
```

A complete, runnable version is in
[`examples/selenium_load_unpacked.py`](examples/selenium_load_unpacked.py).

## Dynamic behavior (write your own)

The CLI is intentionally static. For per-request logic, subclass the portal and
override the hooks, then run it:

```python
from filechooser_portal import FileChooserPortal, Response, run_portal

class MyPortal(FileChooserPortal):
    def on_open_file(self, parent_window, title, options):
        if "extension" in title.lower():
            return Response.select("/abs/path/to/extension")
        return Response.cancel()

run_portal(MyPortal())   # uses $DBUS_SESSION_BUS_ADDRESS
```

You can also pass a callable as the answer:

```python
portal = FileChooserPortal(
    lambda method, parent, title, options: Response.select("/abs/path")
)
```

See [`examples/dynamic_portal.py`](examples/dynamic_portal.py).

## What it implements

- `org.freedesktop.portal.FileChooser`: `OpenFile`, `SaveFile`, `version`
- `org.freedesktop.portal.Settings`: `Read`, `ReadAll` (minimal — Chrome probes
  `color-scheme` during portal availability checks)
- `org.freedesktop.portal.Request`: the `Response` signal and `Close`

Response codes follow the portal spec: `0` success, `1` cancelled, `2` ended.

## Limitations

- Linux / D-Bus only.
- One configured answer (or one callable/subclass) drives all requests; there is
  no built-in IPC to change the answer of an already-running CLI process — use
  the Python API for dynamic behavior.
- Implements the FileChooser portal, not the entire portal surface.

## Development

```bash
git clone https://github.com/mithro/filechooser-portal-mock
cd filechooser-portal-mock
uv run --extra test pytest -v
```

The core tests act as a D-Bus client against the portal on a private bus and
need no display.

## License

[Apache-2.0](LICENSE). Originally developed within the `youtube-shortcuts`
project and extracted into a standalone tool.
