Portty routes XDG Desktop Portal D-Bus requests into plain files and Unix sockets. Anything that can read and write files can respond to portal requests — a terminal, a shell script, a keybinding daemon, or echo.
| Portal | Operations | Description |
|---|---|---|
| FileChooser | open-file, save-file, save-files |
File open/save dialogs |
| Screenshot | screenshot, pick-color |
Screen capture and color picking |
graph TD
App[Application] -->|D-Bus| Daemon[porttyd]
Daemon --> Socket[daemon.sock<br>Unix socket]
Daemon --> FIFO[daemon.ctl<br>FIFO]
Daemon --> Session[Session<br>file-based state]
Session -->|exec| Process[Spawned process]
Process -->|exit triggers submit| Session
Session --- Files[/"submission (file)<br>options.json (file)<br>portal (file)"/]
CLI[portty CLI] -->|control commands| Socket
CLI -->|read/write| Files
Script[Shell scripts<br>echo, cat, ...] -->|read/write| Files
Script -->|"echo submit >"| FIFO
Shims["Shims (sel, submit, ...)<br>= portty CLI wrappers"] --> CLI
| Crate | Binary | Description |
|---|---|---|
crates/lib (libportty) |
— | Shared library: protocol, codec, client, paths, files, portal validation |
crates/daemon (porttyd) |
porttyd |
D-Bus service, session management, daemon socket + FIFO |
crates/cli (portty) |
portty |
CLI for interacting with sessions and the daemon |
- An application requests a portal action via D-Bus (e.g. open file dialog)
- The daemon checks for a queued submission — if one exists, it auto-applies and returns immediately
- Otherwise, the daemon creates a session directory with file-based state (
options.json,submission,portal) - The configured
execcommand is run (typically a terminal emulator, but can be any program — evensubmitfor instant auto-confirm). If the process exits, the session submits automatically - The session's
submissionfile can be edited by anything — theporttyCLI, shell shims on$PATH, raw file I/O, or commands piped into the FIFO - On submit/cancel, the daemon reads the submission file, validates it against portal constraints, and returns results via D-Bus
/tmp/portty/<uid>/
├── daemon.sock # Unix socket (CLI <-> daemon, bidirectional)
├── daemon.ctl # FIFO (fire-and-forget commands)
├── pending/submission # Entries queued before any session exists
├── submissions/<ts>-<portal>/ # Queued submissions (auto-applied on next dialog)
│ └── submission
└── <session-id>/
├── portal # "<portal>\n<operation>" (e.g. "file-chooser\nopen-file")
├── options.json # Session options (from D-Bus request)
├── submission # Current entries, one per line
└── bin/ # Shell shims prepended to $PATH
├── sel # -> portty edit "$@"
├── desel # -> portty edit --remove "$@"
├── reset # -> portty edit --reset
├── submit # -> portty submit
├── cancel # -> portty cancel
├── info # -> portty info
└── <custom> # From config [portal.bin] section
All data operations (editing submissions) are file-based. The daemon socket handles control commands only (submit, cancel, verify, reset, list).
There are multiple ways to interact with a session — they all do the same thing (edit files, send control commands):
The shims in bin/ are one-line wrappers around portty. They're the same thing.
# These are equivalent:
sel file1.txt file2.txt # shim
portty edit file1.txt file2.txt # CLI directly
# Edit submission
portty edit file1.txt file2.txt
portty edit --stdin # read from stdin
portty edit --remove file1.txt # remove entries
portty edit --clear # clear all
portty edit --reset # reset to initial state
portty edit # no args = print current entries
# Control
portty submit # confirm and complete the dialog
portty cancel # cancel the operation
portty verify # validate against portal constraints
portty info # show options.json + submission
# Management (context-independent)
portty list # list active sessions
portty queue # show pending + queued submissions
# Target a specific session
portty --session <id> submitThe CLI auto-detects context via PORTTY_SESSION env var — inside a session terminal it operates on the session directory, outside it operates on pending entries.
Since state is just files, you can skip the CLI entirely:
# Read options
cat /tmp/portty/$(id -u)/<session-id>/options.json
# Write submission directly
echo "/path/to/file.txt" >> /tmp/portty/$(id -u)/<session-id>/submission
# Clear submission
> /tmp/portty/$(id -u)/<session-id>/submissionFire-and-forget commands — useful for scripting and keybindings:
echo "submit" > /tmp/portty/$(id -u)/daemon.ctl
echo "cancel" > /tmp/portty/$(id -u)/daemon.ctl
echo "submit my-session-id" > /tmp/portty/$(id -u)/daemon.ctlFor bidirectional communication (when you need the response):
echo "list" | socat - UNIX-CONNECT:/tmp/portty/$(id -u)/daemon.sockPre-queue entries before a dialog opens. When the next dialog arrives, the queued submission is auto-applied without running exec:
portty edit file1.txt file2.txt
portty submit
portty queue # view the queuePlain text, newline-terminated. Shared by the socket and FIFO.
submit [session_id]
cancel [session_id]
verify [session_id]
reset [session_id]
list
When session_id is omitted, the earliest active session is targeted.
ok
error: <message>
<id>\t<portal>\t<operation>\t<created>\t<dir>\t<title>\n
...
ok
Session listing emits one tab-separated line per session, terminated by ok.
~/.config/portty/config.toml — see misc/config.toml.example for a full annotated example.
Config resolution priority: operation-specific > portal-specific > root default.
exec = "foot" # root default (auto-detected if not set)
[file-chooser]
exec = "foot" # portal default
[file-chooser.save-file]
exec = "submit" # operation override: auto-confirm saves
[file-chooser.bin]
pick = "fzf --multi | sel --stdin" # custom shim on $PATHSet exec = "" for headless mode (no process spawned, interact via CLI only).
| Variable | Description |
|---|---|
PORTTY_SESSION |
Session ID |
PORTTY_DIR |
Session directory path |
PORTTY_PORTAL |
Portal name (e.g. file-chooser) |
PORTTY_OPERATION |
Operation name (e.g. open-file) |
The session bin/ directory is prepended to $PATH.
Create crates/lib/src/portal/<portal>.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionOptions {
// Fields from the D-Bus request that the session/CLI needs
}
/// Validate and transform submission entries.
/// Called at submit time — check constraints and produce final output.
pub fn validate(
operation: &str,
entries: &[String],
options: &SessionOptions,
) -> Result<Vec<String>, String> {
// Validate entries against options, return transformed entries
Ok(entries.to_vec())
}
/// Smart add behavior (single-select replace vs multi-select append).
pub fn add_entries(
sub_path: &std::path::Path,
entries: &[String],
options: &SessionOptions,
) -> std::io::Result<super::AddResult> {
crate::files::append_lines(sub_path, entries)?;
Ok(super::AddResult::Appended(entries.len()))
}Register in crates/lib/src/portal/mod.rs:
#[cfg(feature = "portal-my-portal")]
pub mod my_portal;Add the feature to crates/lib/Cargo.toml:
[features]
portal-my-portal = ["portal"]Wire it into SessionContext::add_entries() and validate() in crates/lib/src/portal/mod.rs.
Create crates/daemon/src/dbus/<portal>.rs implementing the org.freedesktop.impl.portal.* interface using zbus. See dbus/file_chooser.rs or dbus/screenshot.rs as examples.
The key pattern: define a Handler trait that the portal implementation calls, and a D-Bus proxy struct that implements the zbus interface and delegates to the handler.
Create crates/daemon/src/portal/<portal>.rs:
pub struct TtyMyPortal {
config: Arc<Config>,
state: Arc<RwLock<DaemonState>>,
}
impl MyPortalHandler for TtyMyPortal {
async fn my_operation(&self, ...) -> Result<MyResult, MyError> {
let session_options = SessionOptions { /* ... */ };
let options_json = serde_json::to_value(&session_options)?;
let entries = super::run_session(
"my-portal", // portal name
"my-operation", // operation name
&options_json,
&initial_entries,
title.as_deref(),
&self.config,
&self.state,
).await?;
// Transform entries into D-Bus result
Ok(MyResult::new(entries))
}
}run_session handles the entire lifecycle: queued submission check -> session creation -> exec spawn -> wait -> unregister -> validate.
In crates/daemon/src/server.rs, add to register_portals():
let my_portal = TtyMyPortal::new(Arc::clone(&self.config), Arc::clone(&self.state));
let builder = builder.serve_at(OBJECT_PATH, MyPortalProxy::from(my_portal))?;In misc/tty.portal, add the interface:
Interfaces=...;org.freedesktop.impl.portal.MyPortalcargo build --releaseRequires nightly Rust (uses linux_pidfd and unix_mkfifo features).
# Install binaries
install -Dm755 target/release/porttyd /usr/lib/portty/porttyd
install -Dm755 target/release/portty /usr/bin/portty
# Install portal file
install -Dm644 misc/tty.portal /usr/share/xdg-desktop-portal/portals/tty.portal
# Install systemd service (optional)
install -Dm644 misc/portty.service /usr/lib/systemd/user/portty.serviceMIT