Store data on device
Apps remember two kinds of things: small settings (a theme choice, a volume, the last-open tab)
and real data (documents, exports, caches). Day splits them across two parts. day::prefs is a
string key/value store backed by each platform’s own preferences facility; day-part-fs is
private per-app file storage. One call each:
day::prefs::set("theme", "dark"); // NSUserDefaults, SharedPreferences, …
day_part_fs::write("notes/today.txt", b"rain later")?; // a real file under the app-data root
Both persist per app and survive restarts, in the place each platform expects: prefs go to
NSUserDefaults on macOS and iOS, SharedPreferences on Android, a file under the config
directory on Linux and Windows, and localStorage on the web; files go under an app-private
data root natively and into the browser’s Origin Private File System (OPFS) on the web. The
dividing line is size and shape: prefs is a small string store for settings, not a database,
and anything file-shaped belongs in day-part-fs.
Works on: both parts cover macOS, iOS, Android, Linux, Windows, HarmonyOS, and web-dom.
On any other target prefs is a no-op store (get returns None, set returns false) and
every fs call returns FsError::Unsupported. On the web, fs is async-only; see step 3.
1. Persist a setting
day::prefs ships with the day crate (a default-on feature), so there is nothing to add to
Cargo.toml. The store takes and returns strings:
day::prefs::set("greeting", "hello"); // -> bool: did the write commit
day::prefs::get("greeting"); // -> Option<String>, Some("hello")
day::prefs::contains("greeting"); // -> bool
day::prefs::remove("greeting"); // -> bool: existed and was removed
Writes are synchronous and immediately readable. A stored empty string is Some(""), not
None. Keep values modest: a large blob belongs in a file (step 3).
2. Bind a signal so it survives relaunch
For state your UI already holds in a Signal, skip the manual get/set and bind it:
let volume = Signal::new(40.0f64);
day::prefs::bind("settings.volume", volume);
bind(key, signal) seeds the signal from the store now and persists every later change. Any
Signal<T> works when T round-trips through FromStr/ToString: numbers, bools, strings.
Call it right after creating the signal: the write-back is a reactive watch, and it stops when
the creating scope is disposed.
On the web this matters most, because a reload is part of normal life there. The showcase’s Controls page binds its counter, name field, volume, and toggle on wasm only, so a reload keeps them while native launches start fresh on purpose:
#[cfg(target_arch = "wasm32")]
day::prefs::bind("controls.count", count);
The same store also backs navigation persistence: call day::prefs::install_nav_store() once in
main and a selector or stack marked .restore(key) remembers its state across launches.
See navigation.
3. Write and read files
day-part-fs is a separate dependency:
[dependencies]
day-part-fs = { git = "https://github.com/daybrite/day.git" }
Paths are relative and sandboxed inside a private per-app root: an absolute path or a ./..
segment is FsError::BadPath before any platform code runs. write creates missing parent
directories. Each operation comes in three forms: blocking (read, write, remove, list),
callback (read_async, …), and future (read_future, …). The blocking calls are real on every
native target and return FsError::Unsupported on the web, where the single browser thread
cannot wait; the *_future forms work everywhere, awaited under day::task:
let status = Signal::new(String::new());
day::task(async move {
if let Err(e) = day_part_fs::write_future("notes/today.txt", text.into_bytes()).await {
status.set(format!("error: {e}"));
return;
}
match day_part_fs::read_future("notes/today.txt").await {
Ok(bytes) => status.set(String::from_utf8_lossy(&bytes).into_owned()),
Err(e) => status.set(format!("error: {e}")),
}
});
The match stays inside the task because day::task takes a future with Output = (); an
async block that returns a Result doesn’t compile there. Handle both arms and write the
outcome into signals; the future resumes on the UI thread, so those are plain signal writes.
list(dir) returns the entry names directly under dir, sorted, with directories suffixed
/; list("") is the root, and a never-written directory lists as empty, the ordinary
first-run state, not an error. Removing a missing path is FsError::NotFound.
Where the files land:
| Target | Root |
|---|---|
| macOS / iOS | ~/Library/Application Support/day/day-fs/ (the iOS sandbox makes this the app container) |
| Android / HarmonyOS | the app’s private files dir (host-provided DAY_DATA_DIR) + day-fs/ |
| Linux | $XDG_DATA_HOME/day/day-fs/, else ~/.local/share/day/day-fs/ |
| Windows | %APPDATA%\day\day-fs\ |
| web-dom | the origin’s OPFS |
To cache a network response, fetch it with day-part-http and write the body
with day_part_fs::write_future, so the next launch renders before the network answers.
4. What not to store
Neither store is for secrets. Prefs write to plain platform stores (a plist, a
SharedPreferences file, a flat config file, localStorage) and day-part-fs writes plain
files; neither encrypts. Day doesn’t cover secret storage yet (there is no keychain or
keystore part), so keep tokens and passwords out of both until you wire the platform’s secure
store yourself.
Pitfalls
- Prefs is a small string store, not a database. Keep values modest; large blobs belong in
a file. On the web,
localStoragecan throw (private browsing, storage pressure); failures report as uncommitted writes or absent reads, never a panic. bind’s write-back stops with its scope. Bind in the scope that owns the signal, right after creating it. A signal bound inside a page keeps persisting only while that page’s scope is alive.- The blocking fs calls don’t exist on web. They return
FsError::Unsupported; the*_asyncand*_futureforms are the portable surface. Even natively, keep large files off the UI thread. - A file is one buffer. v1 has no streaming:
readandwritemove the whole body through memory, so don’t store anything huge this way. - OPFS is the only web store. A pre-OPFS browser, or a private-browsing session (WebKit
gives ephemeral sessions no storage backing), answers
UnsupportedorIo; there is no silent fallback store. - Launch overrides beat stored settings. The settings pieces apply persisted theme/language
with an env-wins rule: when
DAY_THEMEorDAY_LOCALEis set (aday launch --envrun, CI variants), the persisted value is not re-applied at boot. - Testing persistence by hand on macOS: an unbundled binary stores under the process-name
defaults domain;
defaults delete <name>clears it (deleting the plist alone won’t;cfprefsdcaches).
Reference
prefs — the full day::prefs contract and each platform’s store.
fs — the path rules, error taxonomy, and the OPFS web tier.
async — day::task and the rules the file sample leans on.