fix: keep vault asset previews available after switches
This commit is contained in:
@@ -162,7 +162,7 @@ interface VaultEntry {
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. The "open in default app" action re-enters the vault command boundary through `open_vault_file_external` before delegating to the native opener, so preview and external-open access stay scoped to the active vault. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
### Note Content Freshness
|
||||
|
||||
@@ -358,7 +358,7 @@ Command-facing vault content is filtered through `vault::filter_gitignored_entri
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ flowchart LR
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -542,7 +542,7 @@ sequenceDiagram
|
||||
A-->>U: WelcomeScreen
|
||||
else Vault found
|
||||
A->>VL: useVaultLoader fires
|
||||
VL->>T: invoke('reload_vault') → sync active vault asset scope + scan_vault_cached()
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
@@ -677,7 +677,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `reload_vault` | Allow the requested vault roots in the runtime asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app |
|
||||
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
|
||||
@@ -758,8 +758,8 @@ The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bri
|
||||
| `save_vault_config` | Save per-vault UI config |
|
||||
| `get_default_vault_path` | Get default vault path |
|
||||
| `get_build_number` | Get app build number |
|
||||
| `save_image` | Save base64 image to `attachments/` and refresh the active vault asset scope |
|
||||
| `copy_image_to_vault` | Copy image file to `attachments/` and refresh the active vault asset scope |
|
||||
| `save_image` | Save base64 image to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `copy_image_to_vault` | Copy image file to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
|
||||
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
|
||||
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
|
||||
|
||||
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0099"
|
||||
title: "Cumulative vault asset scope for previews"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
supersedes: "0074 asset-protocol runtime scoping"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 moved the desktop asset protocol away from broad filesystem access and toward runtime vault scoping. The implementation tried to keep only the active vault in scope by calling Tauri's `forbid_directory` for vault roots that were no longer active.
|
||||
|
||||
Tauri's filesystem scope treats forbidden paths as permanent precedence rules: a forbidden path is denied even if it is later allowed again. After a user switched away from a vault and back, image and PDF previews could keep producing `403 Forbidden` responses for valid vault files until the app restarted.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria accumulates Tauri asset protocol access for vault roots loaded during the current app session and never forbids a previously loaded vault root at runtime.**
|
||||
|
||||
- `sync_vault_asset_scope` adds the canonical vault root and requested vault root when they are missing from the runtime asset scope.
|
||||
- The runtime asset scope remains narrower than global filesystem access because only vault roots that Tolaria has loaded are added.
|
||||
- Command paths still enforce the active vault boundary through the Rust command layer before reads, writes, external opens, and attachment imports.
|
||||
- Asset scope revocation is deferred to process exit, because Tauri does not expose a safe runtime unallow operation for directories.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Cumulative runtime vault scope** (chosen): keeps previews reliable after vault switches while preserving vault-only access in the current process.
|
||||
- **Continue forbidding previous vaults**: appears stricter, but Tauri forbids are not reversible and valid previews fail after switching back.
|
||||
- **Allow all filesystem paths**: avoids preview failures but returns to the broad asset protocol access that ADR-0074 intentionally removed.
|
||||
- **Replace `convertFileSrc` with a custom protocol**: could support exact active-vault revocation, but it would be a larger cross-cutting migration for editor images, file previews, and PDF rendering.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Images and PDFs from any vault loaded in the current session can keep rendering after vault switches.
|
||||
- The app process, not each vault switch, is the revocation boundary for asset URL access.
|
||||
- Active-vault command validation remains the primary guard for mutations and default-app opens.
|
||||
- Re-evaluate this if Tauri adds a public runtime unallow operation for asset protocol directories.
|
||||
@@ -151,3 +151,4 @@ proposed → active → superseded
|
||||
| [0096](0096-root-created-type-documents.md) | Root-created type documents | active |
|
||||
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
|
||||
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |
|
||||
| [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active |
|
||||
|
||||
@@ -63,7 +63,7 @@ fn suppress_windows_console(_command: &mut Command) {}
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -418,6 +418,18 @@ fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn missing_asset_scope_roots(
|
||||
allowed_roots: &[PathBuf],
|
||||
requested_roots: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
requested_roots
|
||||
.iter()
|
||||
.filter(|root| !allowed_roots.contains(root))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub(crate) fn sync_vault_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -425,27 +437,22 @@ pub(crate) fn sync_vault_asset_scope(
|
||||
) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
|
||||
let next_roots = vault_asset_scope_roots(vault_path)?;
|
||||
let requested_roots = vault_asset_scope_roots(vault_path)?;
|
||||
let scope = app_handle.asset_protocol_scope();
|
||||
let state: tauri::State<'_, ActiveAssetScopeRoots> = app_handle.state();
|
||||
let mut active_roots = state
|
||||
let state: tauri::State<'_, AllowedAssetScopeRoots> = app_handle.state();
|
||||
let mut allowed_roots = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
|
||||
.map_err(|_| "Failed to lock asset scope state".to_string())?;
|
||||
let roots_to_allow = missing_asset_scope_roots(&allowed_roots, &requested_roots);
|
||||
|
||||
for root in &next_roots {
|
||||
for root in &roots_to_allow {
|
||||
scope
|
||||
.allow_directory(root, true)
|
||||
.map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
|
||||
}
|
||||
|
||||
for previous_root in active_roots.iter() {
|
||||
if !next_roots.contains(previous_root) {
|
||||
let _ = scope.forbid_directory(previous_root, true);
|
||||
}
|
||||
}
|
||||
|
||||
*active_roots = next_roots;
|
||||
allowed_roots.extend(roots_to_allow);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -563,7 +570,7 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(AllowedAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(window_state::MainWindowFrameState::default())
|
||||
.manage(vault_watcher::VaultWatcherState::new());
|
||||
|
||||
@@ -586,9 +593,13 @@ mod tests {
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use super::{selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path};
|
||||
use super::{
|
||||
missing_asset_scope_roots, selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path,
|
||||
};
|
||||
#[cfg(desktop)]
|
||||
use crate::vault_list::VaultList;
|
||||
#[cfg(desktop)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
use super::vault_asset_scope_roots;
|
||||
@@ -735,4 +746,18 @@ mod tests {
|
||||
assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
|
||||
assert!(roots.contains(&symlinked_vault));
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn missing_asset_scope_roots_keeps_previously_allowed_vaults() {
|
||||
let vault_a = PathBuf::from("/vault-a");
|
||||
let vault_b = PathBuf::from("/vault-b");
|
||||
let allowed_roots = vec![vault_a.clone()];
|
||||
|
||||
assert_eq!(
|
||||
missing_asset_scope_roots(&allowed_roots, &[vault_b.clone()]),
|
||||
vec![vault_b]
|
||||
);
|
||||
assert!(missing_asset_scope_roots(&allowed_roots, &[vault_a]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user