Skip to main content

File System & Native Dialog Integration

tauri-plugin-dialog (Save-As export flow)

Registered as a plugin in src/core/src/lib.rs:

tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())

Used in export_test_to_zip (src/core/src/commands/export.rs) to prompt a native "Save As" dialog before writing the exported zip to disk:

use tauri_plugin_dialog::DialogExt;

let chosen = tauri::async_runtime::spawn_blocking(move || {
let mut dialog = app.dialog().file().set_file_name(&file_name).add_filter("Zip Archive", &["zip"]);
if let Some(dir) = default_dir {
dialog = dialog.set_directory(dir);
}
dialog.blocking_save_file()
})
.await
.map_err(|e| e.to_string())?;

If the admin cancels the dialog, chosen is None and the command returns Ok(None) rather than an error — see Exporting Tests as ZIP Archives.

convertFileSrc (serving local media into the webview)

Local audio/image files can't be loaded by the webview via raw filesystem paths. @tauri-apps/api/core's convertFileSrc converts a filesystem path into an asset://-scheme URL the webview is allowed to load, subject to tauri.conf.json's assetProtocol.scope:

"assetProtocol": {
"enable": true,
"scope": ["$HOME/.imh/**"]
}

Two call sites in src/ui/lib/tauri.ts use this:

  • toPlayableUrl(url) — normalizes a listening_sections.audio_url value, converting it only if it looks like a raw filesystem path (vs. an already-converted URL).
  • uploadWritingAsset() / uploadListeningAudio() — convert the newly-uploaded file's returned storage path to a playable URL before handing it back to the caller.

Storage commands

upload_writing_asset and upload_listening_audio (src/core/src/commands/storage.rs) write uploaded bytes under $HOME/.imh/<domain>/... (matching the assetProtocol.scope above), but use different naming schemes per domain:

  • upload_writing_asset (domain writing-assets) stores the file as $HOME/.imh/writing-assets/<writing_task_id>/figure.<ext> — no per-user subfolder, but a per-task subfolder named after the writing_task id (generated client-side before upload so it can be threaded through to create_writing_tasks/import_writing_test), with a fixed figure.<ext> filename inside it. Re-uploading for the same task id overwrites the file.
  • upload_listening_audio (domain listening-audio) stores the file as $HOME/.imh/listening-audio/<user_id>/<timestamp>-<uuid>.<ext>.

See Import & Export Commands and Listening Test Commands.

Seed asset copying vs. schema migrations: why they behave differently

database::init() (src/core/src/database/mod.rs) runs two very different kinds of startup work back to back:

  • Schema migrations (sqlx::migrate!("./src/database/migrations")) are embedded into the compiled binary at compile time (relative to CARGO_MANIFEST_DIR), so they run identically and reliably in every environment — dev, CI, and packaged builds.
  • Seed asset copying (writing_assets_migration::sync_writing_assets_to_local_storage and listening_assets_migration::sync_listening_assets_to_local_storage) resolves its bundled source directory at runtime, via app_handle.path().resolve(.., BaseDirectory::Resource). This depends on tauri.conf.json's bundle.resources entries being resolvable in the current environment, which can differ between tauri dev and a packaged build, or simply not exist if the resource bundling step didn't run.

Because of this, it's possible for migrations to always succeed while seed images/audio are missing. To make this loudly visible instead of a silent no-op:

  • Both sync functions log the exact resolved seed source path and whether it exists, at info level, before attempting to copy anything (log::info! — visible with RUST_LOG=info, the default).
  • If the resolved source directory doesn't exist, a log::warn! is emitted with the module's [sync_writing_assets_to_local_storage] / [sync_listening_assets_to_local_storage] prefix and the attempted path — but the sync still returns Ok(..) (specifically AssetSyncOutcome::SourceMissing) rather than failing startup, since a build may legitimately ship without bundled seed assets.
  • These logs are visible on stderr in both the tauri dev console and packaged-build logs (see env_logger initialization in src/core/src/lib.rs::run()).

See the Troubleshooting page if seed images/audio don't appear under ~/.imh/... after a fresh install.

Tauri ACL / capabilities

Tauri 2's permission model is capability-based; adding a new plugin (like tauri-plugin-dialog) requires regenerating the ACL/capabilities schema so the plugin's commands are actually allowed to run. RELEASES.md records this exact step during the export feature's development:

- Regenerate Tauri ACL schemas for tauri-plugin-dialog
- Register tauri-plugin-dialog in app builder
- Add tauri-plugin-dialog dependency for export file picker

The generated schema lives at src/core/gen/schemas/capabilities.json.

:::note Never hand-edit generated ACL files src/core/gen/schemas/capabilities.json is generated output — regenerate it via Tauri's tooling when adding/changing plugin permissions rather than editing it directly (see the "Never edit generated files" rule in docs/folder-structure.md). :::