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 alistening_sections.audio_urlvalue, 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(domainwriting-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 thewriting_taskid (generated client-side before upload so it can be threaded through tocreate_writing_tasks/import_writing_test), with a fixedfigure.<ext>filename inside it. Re-uploading for the same task id overwrites the file.upload_listening_audio(domainlistening-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 toCARGO_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_storageandlistening_assets_migration::sync_listening_assets_to_local_storage) resolves its bundled source directory at runtime, viaapp_handle.path().resolve(.., BaseDirectory::Resource). This depends ontauri.conf.json'sbundle.resourcesentries being resolvable in the current environment, which can differ betweentauri devand 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
infolevel, before attempting to copy anything (log::info!— visible withRUST_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 returnsOk(..)(specificallyAssetSyncOutcome::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 devconsole and packaged-build logs (seeenv_loggerinitialization insrc/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).
:::