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; astd::path::PathBufcannot represent it andstd::fscannot open it. - A newtype, for one parser: the accessors below live in one place instead of at every call site.
- Stored verbatim:
url::Urlnormalizes and validates in ways that manglecontent://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:
| method | result |
|---|---|
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
| Toolkit | Open | Save |
|---|---|---|
| appkit | NSOpenPanel (sheet) | NSSavePanel (sheet) |
| uikit | UIDocumentPickerViewController (.import) | UIDocumentPickerViewController (export) |
| gtk | GtkFileDialog.open (GTK 4.10+) | GtkFileDialog.save |
| qt | QFileDialog (ExistingFile) via the C++ shim | QFileDialog (AnyFile/AcceptSave) |
| android | ACTION_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 |
| mock | records the spec; resolved programmatically | same |
| xaml | WinRT FileOpenPicker | WinRT 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 build — docs/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 tag3with the paths joined by the unit separator.Cap::FileDialogsadvertises native support.day_spec::present::app_temp_dir(): the app-writable staging dir; Android sets it togetCacheDir()(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.