File chooser contract: files.choose

Status: draft, version 0.1.0, ready for step 2 (implementation). Nothing here is served yet. Revised after reviews by Sol, Fable and GLM.

MixOS has one system file chooser. A person picks files or a save location in it; it never opens, reads or writes the files. It is reached two ways:

  • Over the Bus, with files.choose. Apps and agents call it directly; an agent uses it to ask a person to pick a file.
  • Over D-Bus, through org.freedesktop.portal.FileChooser, which portald serves and forwards to files.choose. Every app that asks the desktop portal for a dialog uses this route, whatever its toolkit (rfd, GTK, Qt, Electron and Firefox).
app --D-Bus FileChooser.OpenFile--> portald --Bus files.choose--> chooser window
agent --Bus files.choose--------------------------^                  |
          <---- outcome on the files.chosen topic, or files.choose.result

Provider

The Bus service files is served by DOpus in chooser mode, dopus --choose-service, a session unit started with the desk. It has no main window. Each accepted files.choose opens one chooser window: a single DOpus pane with Places, a path bar, a file-name field when saving, a filter menu, the caller's choices, and Accept and Cancel. Several requests open several windows. Requests keep no state from one to the next, apart from the Places, bookmarks and recent folders that the file manager also uses.

The unit restarts on failure, as portald@.service does, so a crash costs the open requests but not the next dialog. The chooser process serves files.* and the drive commands only, never dopus.*; DOpus's registry test must check each mode's command set separately.

Each start of the provider is an incarnation, named by a random 64-bit value in lower-case hex. Requests belong to the incarnation that accepted them and are lost with it.

Commands

Owner dopus. They enter docs/docs/spec/bus/commands.conf.mix (status draft; classes as given below) with the code that serves them, because DOpus's registry test requires the registry to list exactly what DOpus serves.

files.choose (intent)

Opens a chooser and answers straight away; the person's answer comes later (see Outcome). Body:

FieldTypeDefaultMeaning
modeopen, save or save_filesopenWhat the person is choosing.
titlestring, 1 to 256 bytesby modeThe window title.
accept_labelstring, 1 to 64 bytesby modeThe Accept button's label. Mnemonic underscores are removed.
multipleboolfalseopen only: more than one entry may be chosen.
directoryboolfalseopen only: choose folders, not files.
filterslist of filter, at most 64noneopen and save only. See Filters.
current_filterfilterfirst of filtersopen and save only: the filter in effect at first. See Filters.
choiceslist of choice, at most 16noneSee Choices.
current_folderlocationlast folder used, else homeThe folder shown first.
current_namenamenonesave only: the suggested file name.
current_filelocationnonesave only: an existing file to save over; its folder and name are shown. Takes precedence over current_folder and current_name.
fileslist of name, 1 to 256, uniquenonesave_files only, and required: the names of the files about to be saved into the chosen folder.
parent_windowstring, at most 256 bytes""The window the chooser belongs to. See Placement.
correlationstring, 1 to 256 bytesnoneOpaque to the provider; echoed in the reply, the outcome and files.choose.list.
on_behalf_ofstring, 1 to 128 bytesnoneThe app a forwarding service asks for. portald sends <unique bus name> <process name>, for example :1.42 fakeapp. Shown to the person and used for limits and the remembered folder; it is not an authority.

A location is an absolute path written as a UTF-8 string, or a file:// URI (RFC 8089, no host or localhost) whose percent-encoding carries the exact bytes of a path. The path, decoded, is at most 4,096 bytes with no NUL byte. Nothing else is accepted.

A name is one path component: a UTF-8 string, or {"bytes": "<percent-encoded>"} for a name that is not UTF-8. Decoded, it is 1 to 255 bytes, with no / and no NUL, and is neither . nor ...

Unknown fields are ignored. A known field of the wrong type or out of range, a field used in the wrong mode, save_files without files, duplicate names in files, or a body over 256 KiB is refused with ARGUMENT.

Reply, rc 0:

{"request": "9f2c41d07ab3e655-17", "topic": "files.chosen", "correlation": "/org/freedesktop/portal/desktop/request/1_42/rfd1"}

request is <incarnation>-<sequence>, made by the provider. It is never reused: the sequence only grows within an incarnation, and a new incarnation has a new prefix. correlation is present when the caller gave one.

Refusals open no window and keep nothing:

  • BUSY when 8 choosers are already open on the desk; when 2 are open for this caller; or when this caller has made more than 10 requests in the last 10 seconds. The caller for both limits is the pair (broker connection, on_behalf_of) when on_behalf_of is given, else the broker connection alone, so the apps behind portald are limited one by one;
  • UNAVAILABLE when the chooser window cannot be opened (no display, for example).

Shape. A person can take longer than the Bus client waits for a reply (60 s), and a topic is published only by the service that owns it, so the answer cannot be a held reply or a topic the caller names. The shape is the one shell.execute, shell.execute.result and shell.execute.cancel already use: an immediate reply, an owner-published event and a pullable result.

Ordering. A caller subscribes to files.chosen before it calls files.choose, or reads files.choose.result instead; a caller on another node uses the result query. The provider sends the reply before the window accepts any input, but the reply and the topic are separate deliveries, so a caller keeps for a short while any outcome naming a request it has not yet been told about. Once the reply is in, outcomes are matched by request alone; correlation only links a parked outcome to a reply, and is applied only when the outcome's request equals the reply's.

files.choose.result (query)

Body {request}. Answers with the request's state:

  • {state: "open"}: the person has not answered;
  • {state: "done", outcome: {...}}: the outcome, exactly as published;
  • ARGUMENT with {"reason": "unknown"} for a request this incarnation never made (another incarnation's included), or {"reason": "expired"} for one whose outcome is no longer kept.

Outcomes are kept for 10 minutes after they are decided, at most 64 of them; when a 65th is decided, the oldest is dropped first. Reading an outcome does not remove it. This command is for a caller that missed the publication.

files.choose.cancel (intent)

Body {request}. Closes an open chooser; its outcome is response 1 with reason: "caller". Idempotent: a request already done, expired, never made by this incarnation, or made by an earlier one answers rc 0 and changes nothing. A malformed request is ARGUMENT.

Any caller may cancel any request, as the open Bus requires (docs/docs/decisions/2026-10-26-open-bus-for-agents.md); drive input can press Cancel in the window anyway. It is also how choosers left by a caller that has gone are cleared before noded's connection-closed notice exists.

files.choose.list (query)

Body none. Answers {incarnation, requests: [{request, mode, title, caller, on_behalf_of, correlation, opened_ms}]} for every open chooser, oldest first. caller is citizen::caller_key of the connection that asked.

Outcome

When a request ends, one outcome is published once on topic files.chosen and kept for files.choose.result:

{
  "request": "9f2c41d07ab3e655-17",
  "correlation": "/org/freedesktop/portal/desktop/request/1_42/rfd1",
  "response": 0,
  "reason": "chosen",
  "uris": ["file:///home/user/Pictures/beach.png"],
  "current_filter": {"name": "Images", "patterns": ["*.png", "*.jpg"], "mime_types": []},
  "choices": {"encoding": "utf8", "reencode": "true"}
}
responsereasonWhen
0chosenThe person accepted.
1cancelledThe person pressed Cancel or Escape, or closed the window.
1callerfiles.choose.cancel, or the caller's broker connection closed.
2failedThe chooser failed after it opened. message says why.

Each request has exactly one outcome; the first end wins and later ones are dropped. Response codes are the portal's, so portald passes them on unchanged.

  • uris are file:// URIs, percent-encoded from the exact bytes of each path. On open, there is one unless multiple is set, never none. On save, there is exactly one. On save_files, there is one per name in files, in the same order, each exactly <chosen folder>/<name>; the provider checks this before it accepts.
  • current_filter is the filter in effect when the person accepted. It is present whenever a filter was in effect, from filters or from current_filter alone.
  • choices maps every choice's id to its value: an option id, or "true" or "false" for a check box.
  • uris, current_filter and choices are present only when response is 0.

The topic is published by files alone. Every subscriber sees every outcome; the Bus is open (docs/docs/decisions/2026-10-26-open-bus-for-agents.md), and an outcome carries paths, never file contents.

Filters

A filter is {name, patterns, mime_types}: a label of 1 to 128 bytes, and together 1 to 512 entries in two lists of strings of 1 to 256 bytes (an "All images" filter can list hundreds):

  • patterns: shell globs (*, ?, [...]), matched against the file name, case sensitive;
  • mime_types: MIME types or type/*, matched by the type the shared MIME database gives the file.

current_filter alone, with no filters, is the one filter in effect and the filter menu is hidden. With filters, a current_filter equal to one of them (same name and lists) selects it; one that is not equal to any is added at the top of the menu and selected. Folders are always shown, so the person can move between them. When save is used with a filter whose only pattern is *.ext, a typed name without an extension gets that one.

Choices

A choice is {id, label, options, default}:

  • id and label are strings of 1 to 64 and 1 to 128 bytes; ids are unique;
  • options is a list of up to 32 {id, label} with unique ids. When it is empty the choice is a check box;
  • default is optional. For a list it is one of the option ids, and the first option when absent. For a check box it is "true" or "false", and "false" when absent.

They are shown under the file list, in order.

Behaviour

  • No timeout. A chooser waits for the person as long as it takes.
  • Save. When the chosen file exists, the person confirms replacing it before Accept succeeds. save_files does the same once for all the names that exist; unlike some portal backends it never renames to avoid a collision, so each URI is exactly <folder>/<name>. The chooser never creates, truncates or replaces anything; the caller does that with the URIs it receives.
  • Typed names. A typed save name containing / moves to that folder when it exists, as a path bar would; otherwise Accept is refused in the window with a message.
  • Remembered folder. The last folder accepted is kept per on_behalf_of, else per caller, and is where the next chooser for it opens when no current_folder or current_file is given.
  • Who is asking. When the caller is not portald, the window names the caller by citizen::caller_key, so a person can tell an agent's request from an app's. on_behalf_of is the caller's claim, not an identity: it is shown as a claim ("for fakeapp, says portald"), visibly apart from the caller's name, and never in its place.
  • Drive. Each chooser window answers the toolkit drive commands (docs/docs/drive.md) as files.ui.* and files.window*, with the request id as the viewport. With more than one chooser open, a drive command without viewport is ARGUMENT. They are registered with the code.
  • Caller leaves. When the caller's broker connection closes, its open choosers close with response 1, reason: "caller". This needs a notice from noded that a connection closed, which does not exist yet (see Losing a party).
  • Provider stops. If files stops, every open request is lost with its incarnation; a new incarnation knows nothing of them. Nothing is published for a lost request, so the topic is not a liveness guarantee: a caller that must not wait forever checks files.choose.result (unknown means lost) when files unregisters, or on a deadline of its own.

Losing a party

Two notices are needed, and noded publishes neither today. Step 2 adds both to noded as owner-published topics: a service registered or unregistered, and a connection closed. Until they land, v1 does this, and the stopgaps are removed when they do:

  • Provider lost, or an outcome missed (portald's view). While at least one Request is not ended, portald reads files.choose.list every 5 seconds. An error or a changed incarnation means files was lost. An open Request whose request is missing from the list, with the incarnation unchanged, was decided and its publication missed: portald reads files.choose.result and sends the Response (done), or Response 2 (unknown). This is a bounded, temporary exception to the no-polling rule: it runs only while a person has a dialog open.
  • portald lost (the provider's view). At start, portald reads files.choose.list and cancels every request whose correlation is a portal Request path (/org/freedesktop/portal/desktop/request/…): none of them can belong to the new portald.
  • Other callers lost. Their choosers stay open until the person answers or anyone cancels them; the outcome is published and kept as usual.

Placement

parent_window is the portal's window identifier: "", wayland:<handle> (an exported xdg-foreign handle) or x11:<hex id>. In this version the chooser opens centred on the output of the parent window when compd can resolve the identifier, otherwise on the focused output, and stays above the parent. It is not modal to the parent; that needs xdg-foreign import and xdg-dialog-v1 support in compd, a later step.

D-Bus: org.freedesktop.portal.FileChooser

portald serves the interface at version 4 on the object it already serves (see ../portal/README.md), for trusted unsandboxed callers in the session. OpenFile(s parent_window, s title, a{sv} options) -> o, SaveFile with the same arguments, and SaveFiles with the same arguments all return a Request object straight away, at /org/freedesktop/portal/desktop/request/<sender>/<handle_token>, built as for ScreenCast: handle_token is 1 to 64 of A-Z a-z 0-9 _, else the call is refused with InvalidArgument; a token whose path already holds a live Request from the same sender is refused the same way. Then portald calls files.choose with the mapped body, correlation set to the Request's object path, and on_behalf_of set to the caller's unique bus name and process name, the process name cut to fit 128 bytes.

Portalfiles.choose
method OpenFile / SaveFile / SaveFilesmode open / save / save_files
parent_window argumentparent_window
title argumenttitle (the mode's default when empty)
handle_token (s)not sent: it names the Request path only
accept_label (s)accept_label
modal (b)ignored: not modal in this version
multiple (b)multiple
directory (b)directory
filters (a(sa(us)))filters: kind 0 to patterns, kind 1 to mime_types; not sent for SaveFiles
current_filter ((sa(us)))current_filter, mapped the same way; not sent for SaveFiles
choices (a(ssa(ss)s))choices: (id, label, [(id, label)], default); an empty default is left out
current_name (s)current_name
current_folder (ay)current_folder as a percent-encoded file:// URI
current_file (ay)current_file as a percent-encoded file:// URI
files (aay, SaveFiles)files, each the last component of the entry (as other portal backends do); {"bytes": ...} when not UTF-8

Byte strings (ay) carry exactly one trailing NUL, which is removed; a byte string without it, or with a NUL anywhere else, is refused. Other options are ignored.

Lenient at the D-Bus edge. rfd and others show any portal error as a cancel, so a refusal is a dialog that silently never appears. portald therefore refuses only a known option with the wrong D-Bus type, a NUL violation, or SaveFiles with no usable files, with org.freedesktop.portal.Error.InvalidArgument at the call and no Request. Everything else is made to fit: an option the method's mode does not take (multiple or directory on a save, for example) is not sent; a location or name that cannot be made valid is not sent; duplicate files names keep the first; an empty string becomes the field's default; a label over its limit is cut at a character boundary; a list over its limit keeps its first entries; an invalid entry (a pattern over 256 bytes, a choice with a duplicate id) is dropped. portald logs each adjustment.

The outcome becomes Response(u response, a{sv} results), addressed to the Request's owner alone:

OutcomeResponse
00, with uris (as), choices (a(ss)) when the call had choices, current_filter ((sa(us))) when a filter was in effect
11, {}
2, or files.choose refused (BUSY, UNAVAILABLE, any error), or files not registered, or lost2, {}

Request lifecycle

Each portal Request is in one of four states. Every transition happens under one lock, and a Request leaves the table only when it is ended.

StateEntered whenClose (owner) or owner leavesOutcome for it arrivesfiles.choose answersBus or files lost
sendingthe method returns the Request path, before files.choose is sentto closingparked by correlation until the replyok: to open, then apply a parked outcome whose request matches; refused: Response 2, ended; no reply in time: Response 2, to closingResponse 2, ended
openfiles.choose answered with a request idsend files.choose.cancel; ended, no Responsematched by request: Response as mapped, ended—Response 2, ended
closingClose came while sending, or the reply timed out—droppedok: send files.choose.cancel, ended; refused: endedended
endeda Response was sent, or Close was handled—droppeddropped—
  • portald subscribes to files.chosen before it serves FileChooser, and again on every reconnect to the Bus.
  • A Request in closing that never gets a reply is also swept: portald looks for its correlation in files.choose.list, cancels any match, and ends it.
  • Close from anyone but the owner is refused with org.freedesktop.portal.Error.NotAllowed.
  • A lost connection is final. When portald loses the Bus, or files is lost (see Losing a party), every Request not yet ended gets Response 2 and ends. After reconnecting, portald does not try to recover them; it cancels their choosers as described there.
  • A Request is never left without an end, so an app waiting on it can tell failure from cancel. A Response is sent at most once, and never after Close.

Acceptance

  1. Over the Bus alone, under a nested compd: open, save (with replace confirmation) and save_files, each accepted, cancelled by the person, cancelled by the caller, and with the caller's connection dropped. Filters (including a standalone current_filter) and choices (including an absent default) round-trip. A path and a save_files name that are not UTF-8 round-trip. Names .., a/b, an empty name and duplicates are refused. Request ids are not reused across a provider restart. The limits and the outcome eviction refuse and evict as written.
  2. Over D-Bus, with a persistent zbus caller fixture (a one-shot gdbus call disconnects before the Response): each method; two apps using the same handle_token at once; three apps with dialogs open at once (none BUSY); Request.Close while sending and while open (no Response, no chooser left open), then the same handle_token reused at once (the new Request gets only its own outcome); the owner disconnecting; a files.choose reply that times out (Response 2, and the late window is cancelled); files absent, and killed mid-request (Response 2 within 10 s); an empty accept_label and a 300-pattern filter (accepted, adjusted); and Close from another sender (refused).
  3. A craft app built with rfd 0.17, on VT3: File > Open and File > Save As show the chooser; the file is opened and saved; Cancel leaves the app as it was.

Settled questions

  1. FileChooser version 4. Checked against upstream: every mapped option exists at version 4 (directory since 3); SaveFiles takes no filters or current_filter.
  2. Drive with several windows: the request id is the drive viewport (see Behaviour).
  3. MixOS's own apps (Email's Attach files) move to files.choose as a task of their own once acceptance 3 passes. toolkit::file_chooser stays only for when files.choose is UNAVAILABLE or files is absent.
  4. Connection-closed notice: v1 does not wait for it (see Losing a party); noded gains the two topics in step 2.

This page as Markdown · This page describes the current main branch of MixOS.