Files: native open & save pickers

Day opens and saves files through each platform’s native file-interaction UI, using the same imperative request→response model as dialogs: an action opens a picker and .awaits the result:

button(tr("open")).action(|| day::task(async move {
    if let Some(file) = open_file().filter("Text", &["txt", "md"]).await {
        // `day::task` takes a `Future<Output = ()>`, so handle the error here
        // rather than reaching for `?`.
        match file.read_to_string() {          // FileUrl::read_to_string
            Ok(text) => editor.set(text),
            Err(e) => status.set(format!("error: {e}")),
        }
    }
}));

button(tr("save")).action(|| day::task(async move {
    let saved: Option<FileUrl> = save_file(editor.get_untracked().into_bytes())
        .suggested_name("notes.txt")
        .filter("Text", &["txt"])
        .await;
}));

The path type: FileUrl

A file location crosses back as a FileUrl, a newtype wrapping a single locator string. Each platform hands back a different kind of locator, and the type stores whichever arrives:

  • A string, because of Android: the Storage Access Framework returns a content:// URI rather than a filesystem path; a std::path::PathBuf cannot represent it and std::fs cannot open it.
  • A newtype, for one parser: the accessors below live in one place instead of at every call site.
  • Stored verbatim: url::Url normalizes and validates in ways that mangle content:// authorities, and it would add a large dependency, so the locator is kept as received.

FileUrl is the lossless union (a filesystem path on desktop/iOS, a content:// URI on Android) with accessors:

methodresult
as_str()the raw locator
local_path() -> Option<PathBuf>Some for filesystem paths (and file://), None for content://
file_name() -> Option<String>the last path component, for display
read() / read_to_string()the bytes / UTF-8 text (local paths; content:// errors)

Opened files are always readable. Where a platform doesn’t hand back a usable path, the backend materializes one first: Android copies the picked document into the app cache, and iOS imports it into the app sandbox. So open_file().await?.read_to_string() works on every target.

The builders (day-pieces, in the prelude)

  • open_file()OpenFile: .title(..), .filter(name, &["ext", …]), .await → Option<FileUrl>.
  • save_file(data)SaveFile: .title(..), .suggested_name(..), .filter(..), .await → Option<FileUrl>. The bytes are staged to an app-writable temp file that the backend hands to the native save UI; the pieces layer delivers them to a chosen local destination and cleans up.

Per-toolkit native mapping

ToolkitOpenSave
appkitNSOpenPanel (sheet)NSSavePanel (sheet)
uikitUIDocumentPickerViewController (.import)UIDocumentPickerViewController (export)
gtkGtkFileDialog.open (GTK 4.10+)GtkFileDialog.save
qtQFileDialog (ExistingFile) via the C++ shimQFileDialog (AnyFile/AcceptSave)
androidACTION_OPEN_DOCUMENT + ContentResolver (copy → cache)ACTION_CREATE_DOCUMENT + ContentResolver
arkui (HarmonyOS)ArkTS DocumentViewPicker.select + @ohos.file.fs (copy → cache)DocumentViewPicker.save + @ohos.file.fs
dom (web)<input type=file> (the browser’s picker)a Blob download
mockrecords the spec; resolved programmaticallysame
xamlWinRT FileOpenPickerWinRT FileSavePicker

On HarmonyOS the picker lives in the ArkTS @kit.CoreFileKit layer, not the native NodeAPI, so the day-arkui backend calls up into its ArkTS host over NAPI (safe: Day’s loop runs on the JS thread); the host drives DocumentViewPicker and answers via a registered onFileResult callback, wired in the framework’s ArkTS host page (toolkits/day-arkui/platform/harmony/ets/Index.ets, staged into every app’s hvigor project by day builddocs/harmonyos.md).

A browser has no filesystem, so on web-dom the bytes ride a per-page store instead of paths: an opened file’s content lands under a virtual /day-web/<name> path that FileUrl::read resolves, and a save’s staged bytes leave as a download named by suggested_name. The app uses the same builders and the same FileUrl surface on web as on every other target.

All backends present the picker non-blocking (sheet / open() / delegate / Activity result), so the main loop keeps running and dayscript stays live while a picker is up.

Plumbing

Files go through the existing present path (docs/dialogs.md) and add no Toolkit methods:

  • day_spec::present::PresentSpec::{OpenFile, SaveFile} + FileFilter { name, extensions }.
  • PresentResult::Files(Vec<String>): the chosen locators, crossing the C ABI (Qt shim / Android JNI) as tag 3 with the paths joined by the unit separator.
  • Cap::FileDialogs advertises native support.
  • day_spec::present::app_temp_dir(): the app-writable staging dir; Android sets it to getCacheDir() (the OS temp dir isn’t app-writable there).

dayscript

A file picker is a presentation, so a script answers it with a path (docs/dialogs.md):

- tap: { id: btn-save-file }
- assert_presented: {}
- respond: { path: "notes.txt" }        # relative → the app temp dir (writable on every target)
- tap: { id: btn-open-file }
- assert_presented: {}
- respond: { path: "notes.txt" }         # reads the file just written: a real round-trip

This makes open/save flows headless-testable and screenshot-able on every backend without touching the machine’s real filesystem. See Day-Showcase (the Files & storage page’s Files section) and Day-Showcase/dayscript/files.yaml.

Windows picker behavior

The XAML backend advertises Cap::FileDialogs = Native. It uses the system WinRT pickers, initialized with the host HWND through IInitializeWithWindow (the desktop picker interop contract). Open filters become .ext entries, with * when no filters are supplied; save filters retain their names. The WinRT API does not expose a custom dialog title. The selected local path becomes a FileUrl, so read() returns the original bytes.

Registration precedes native setup. Missing owners, activation/filter/initialization failures, canceled operations, and errors retrieving the result or path resolve as Dismissed, rather than leaving the calling future pending. Completion removes the registration before notifying Rust; duplicate or late replies are ignored.

Validate on a Windows host with Day-Showcase/dayscript/files.yaml, then manually open and cancel the native picker and select a file with a Unicode path. In Day-Sketch, insert an image through the + menu, edit its geometry/opacity/rotation, save the drawing, and reopen it. Scripted respond validates the portable presentation flow; it does not select a file through the real Windows picker UI.

macOS sandboxing

See macOS App Sandbox for Day.toml configuration and persistent file access. FileUrl::bookmark(read_only) returns opaque bookmark bytes; FileUrl::resolve_bookmark(bytes) returns a FileAccess guard. Keep it alive through I/O and renew stale bookmarks while the guard is held. A saved path alone does not preserve permission.