From 896afcfec7faf85026dac936b23964412a9beebb Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Tue, 12 May 2026 11:10:05 +0100 Subject: [PATCH 1/2] fix(nodes): pre-validate storage_dir + unwrap daemon error envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the customer-reported Windows toast: Failed to add nodes: {"error":"I/O error: Access is denied. (os error 5)"} The PR #34 release (v0.6.7) was the first build where storage_dir is actually honored at node-add time — any path that's writable in Explorer but not to the daemon process (USB read-only volume, OneDrive placeholder, second-drive default ACL, network share) now produces this error. The toast also shows the daemon's raw JSON envelope verbatim, which looks broken even when the underlying error is real. Three defences: 1. probe_writable Tauri command — writes a tiny sentinel, fsyncs, deletes. Surfaces the raw OS message + path on failure. 2. Settings → Storage Directory picker calls probe_writable before persisting. On failure, the path is not saved and the error is surfaced both inline under the card and as a global banner in the default layout. Toast on success is "Storage directory verified" instead of the previous unconditional "Storage directory updated". 3. Startup re-probe — app.vue calls settingsStore.revalidateStorageDir() after loadConfig so a previously-working path that's gone offline (USB unplugged, OneDrive offline, perms changed) is caught before the user reaches Add Node. Non-blocking; does not clear the saved path. 4. daemon-api request() unwraps the {"error": "..."} envelope from 4xx/5xx bodies, falling back to the raw body when it isn't JSON or doesn't have the expected shape. Covers every daemon error toast in the app, not just addNodes. Tests: 5 cases for the envelope unwrap (JSON envelope, plain string, unrelated JSON shape, malformed JSON, Error instance with .message). Co-Authored-By: Claude Opus 4.7 (1M context) --- app.vue | 5 +++ layouts/default.vue | 16 +++++++++ pages/settings.vue | 21 ++++++++++-- src-tauri/src/lib.rs | 36 ++++++++++++++++++++ stores/settings.ts | 38 +++++++++++++++++++++ tests/utils/daemon-api.test.ts | 62 ++++++++++++++++++++++++++++++++++ utils/daemon-api.ts | 20 +++++++++-- 7 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 tests/utils/daemon-api.test.ts diff --git a/app.vue b/app.vue index 226d6e1..9e373b3 100644 --- a/app.vue +++ b/app.vue @@ -35,6 +35,11 @@ onMounted(async () => { await settingsStore.loadConfig() await settingsStore.loadDevnetManifest() nodesStore.init() + // Re-probe the saved storage_dir if any — surfaces a banner if it's no + // longer writable (USB unplugged, OneDrive offline, perms changed). Don't + // await: the probe is a single fs round-trip and we don't want it to + // sequence in front of unrelated startup work. + settingsStore.revalidateStorageDir() // Await: any persistHistory triggered before this resolves would otherwise // run against an empty `files` array, and the fail-closed guard in the // store only kicks in once loadHistory has flipped its failure flag. diff --git a/layouts/default.vue b/layouts/default.vue index 3306604..b86d1fd 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -3,6 +3,16 @@
+
@@ -11,3 +21,9 @@
+ + diff --git a/pages/settings.vue b/pages/settings.vue index 706fffd..a58bef0 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -48,6 +48,9 @@

Only applies to newly added nodes. Existing nodes keep their current directory.

+

+ {{ settingsStore.storageDirProbeError }} +

@@ -757,10 +760,22 @@ onMounted(async () => { async function pickStorageDir() { try { const selected = await open({ directory: true, title: 'Select Storage Directory' }) - if (selected) { - await settingsStore.setStorageDir(selected as string) - toasts.add('Storage directory updated', 'info') + if (!selected) return + const path = selected as string + // Probe before persist — a path that's writable in Explorer can still be + // unwritable to the daemon process (USB read-only volume, OneDrive + // placeholder, restrictive ACL on a second drive). Write the failure to + // the store-level probe error so the global banner and this card both + // reflect the same state. + const result = await settingsStore.probeStorageDir(path) + if (!result.ok) { + settingsStore.storageDirProbeError = result.error + toasts.add('Cannot use that folder for node storage', 'error') + return } + settingsStore.storageDirProbeError = null + await settingsStore.setStorageDir(path) + toasts.add('Storage directory verified', 'info') } catch (e) { toasts.add('Failed to select directory', 'error') } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43e35f3..1b5b1b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -602,6 +602,41 @@ fn get_default_download_dir() -> Result { .ok_or_else(|| "Could not determine default download directory".to_string()) } +/// Check whether the user-chosen directory is actually writable by the app +/// process. Used to gate the Settings → Storage Directory save so a +/// volume-readonly / OneDrive-placeholder / restrictive-ACL path doesn't +/// surface as the daemon's "Access is denied (os error 5)" on the next Add +/// Node (Windows USB / second-drive / network-share categories). +/// +/// Writes a tiny sentinel, fsyncs it, then deletes it. Returns Ok(()) on +/// success; on failure returns the raw OS error message prefixed with the +/// path so the UI can surface it directly. The sentinel filename is fixed +/// (`.ant-gui-write-probe`) so a crash mid-probe leaves at most one stray +/// file rather than collision-suffixed garbage. +#[tauri::command] +fn probe_writable(path: String) -> Result<(), String> { + use std::io::Write; + let dir = std::path::Path::new(&path); + if !dir.is_dir() { + return Err(format!("{path}: not a directory")); + } + let probe = dir.join(".ant-gui-write-probe"); + let result: std::io::Result<()> = (|| { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&probe)?; + f.write_all(b"ant-gui write probe")?; + f.sync_all()?; + Ok(()) + })(); + // Always attempt cleanup, even if the write failed (the file may have been + // partially created). + let _ = std::fs::remove_file(&probe); + result.map_err(|e| format!("{path}: {e}")) +} + /// Get the size of a single file in bytes. #[tauri::command] fn get_file_size(path: String) -> Result { @@ -761,6 +796,7 @@ pub fn run() { get_dir_size, get_node_data_dir, get_default_download_dir, + probe_writable, read_file_bytes, load_upload_history, save_upload_history, diff --git a/stores/settings.ts b/stores/settings.ts index 9076b6c..4e230f6 100644 --- a/stores/settings.ts +++ b/stores/settings.ts @@ -68,6 +68,12 @@ export const useSettingsStore = defineStore('settings', { * apply" hint banner. Set once during loadConfig. */ prereleaseChannelBootValue: false, loaded: false, + /** Set by `revalidateStorageDir()` on startup if the saved storage_dir is + * no longer writable (USB unplugged, OneDrive offline, ACL changed). + * Drives the global banner in the default layout + the inline error on + * the Settings page. `null` means either no storage_dir is set or the + * last probe succeeded. */ + storageDirProbeError: null as string | null, // Devnet/testnet/direct-key mode (set by manifest or settings form). // devnetChainId picks the chain: // - null / not set → production mainnet (WalletConnect) @@ -144,6 +150,38 @@ export const useSettingsStore = defineStore('settings', { await this.saveConfig() }, + /** Probe whether `path` is writable by the app process before persisting + * it as the node storage directory. The daemon process spawned by the + * app inherits this user, so a path that fails here will also fail at + * Add Node time. + * + * Returns `{ ok: true }` on success or `{ ok: false, error }` with the + * raw OS message so the UI can surface it inline. Does not mutate + * state — the caller decides whether to call `setStorageDir` after. */ + async probeStorageDir(path: string): Promise<{ ok: true } | { ok: false; error: string }> { + try { + await invoke('probe_writable', { path }) + return { ok: true } + } catch (e: any) { + return { ok: false, error: typeof e === 'string' ? e : (e.message ?? String(e)) } + } + }, + + /** Re-probe the currently saved `storage_dir` (if any) on app startup. + * Catches the case where the user previously picked a path that's now + * unwritable: USB unplugged, OneDrive went offline, ACL changed. Sets + * `storageDirProbeError` so the layout banner and the Settings page + * both surface the same warning. Does not clear the saved path — the + * user picks the next step. */ + async revalidateStorageDir() { + if (!this.storageDir) { + this.storageDirProbeError = null + return + } + const result = await this.probeStorageDir(this.storageDir) + this.storageDirProbeError = result.ok ? null : result.error + }, + async setDownloadDir(path: string) { this.downloadDir = path await this.saveConfig() diff --git a/tests/utils/daemon-api.test.ts b/tests/utils/daemon-api.test.ts new file mode 100644 index 0000000..6906aa2 --- /dev/null +++ b/tests/utils/daemon-api.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mockInvoke, resetTauriMocks, setMockInvokeHandler } from '../mocks/tauri' +import { useSettingsStore } from '~/stores/settings' + +// daemonApi calls useSettingsStore() to read the daemon URL — make sure the +// store is fresh per test. +beforeEach(() => { + resetTauriMocks() + const store = useSettingsStore() + store.$reset() + store.daemonUrl = 'http://127.0.0.1:12500' +}) + +describe('daemon-api request — error envelope unwrap', () => { + it('strips the {"error": "..."} envelope from a 5xx body', async () => { + const { daemonApi } = await import('~/utils/daemon-api') + setMockInvokeHandler(() => { + throw '{"error":"I/O error: Access is denied. (os error 5)"}' + }) + + await expect(daemonApi.status()).rejects.toMatchObject({ + name: 'DaemonApiError', + message: 'I/O error: Access is denied. (os error 5)', + }) + }) + + it('passes through a plain-string error unchanged (Tauri/network errors)', async () => { + const { daemonApi } = await import('~/utils/daemon-api') + setMockInvokeHandler(() => { + throw 'Cannot connect to daemon — is it running?' + }) + + await expect(daemonApi.status()).rejects.toMatchObject({ + message: 'Cannot connect to daemon — is it running?', + }) + }) + + it('falls back to the raw body when JSON parses but has no .error field', async () => { + const { daemonApi } = await import('~/utils/daemon-api') + const body = '{"detail":"unexpected shape"}' + setMockInvokeHandler(() => { throw body }) + + await expect(daemonApi.status()).rejects.toMatchObject({ message: body }) + }) + + it('falls back to the raw body when the body is malformed JSON', async () => { + const { daemonApi } = await import('~/utils/daemon-api') + const body = '{"error": not-json}' + setMockInvokeHandler(() => { throw body }) + + await expect(daemonApi.status()).rejects.toMatchObject({ message: body }) + }) + + it('uses .message when invoke rejects with an Error instance', async () => { + const { daemonApi } = await import('~/utils/daemon-api') + mockInvoke.mockRejectedValueOnce(new Error('{"error":"validation failed: rewards_address"}')) + + await expect(daemonApi.status()).rejects.toMatchObject({ + message: 'validation failed: rewards_address', + }) + }) +}) diff --git a/utils/daemon-api.ts b/utils/daemon-api.ts index 0137b5d..2879e72 100644 --- a/utils/daemon-api.ts +++ b/utils/daemon-api.ts @@ -156,12 +156,26 @@ async function request(method: string, path: string, body?: unknown): Promise }) return JSON.parse(text) as T } catch (e: any) { - // Tauri invoke errors come as strings - const msg = typeof e === 'string' ? e : e.message ?? String(e) - throw new DaemonApiError(0, msg) + // Tauri invoke errors come as strings. The Rust proxy forwards the daemon's + // response body verbatim for 4xx/5xx, which is the ant-core JSON envelope + // `{"error":""}`. Surfacing the whole envelope in a toast looks + // broken to users — unwrap to the inner message before throwing. + const raw = typeof e === 'string' ? e : e.message ?? String(e) + throw new DaemonApiError(0, unwrapDaemonError(raw)) } } +/** Try to extract `.error` from a daemon JSON envelope. Falls back to the raw + * string when the body isn't JSON or doesn't have the expected shape, so + * network / Tauri errors pass through unchanged. */ +function unwrapDaemonError(raw: string): string { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed.error === 'string') return parsed.error + } catch { /* not JSON — use raw */ } + return raw +} + export const daemonApi = { /** GET /api/v1/status */ status: () => request('GET', '/api/v1/status'), From c26dd39c3974a16f992b60630387d40e989ea3d7 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Tue, 12 May 2026 11:18:02 +0100 Subject: [PATCH 2/2] fix(settings): success toast for verified storage_dir uses 'success' level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blue 'info' was matching the previous "updated" toast, but the new flow performs a real verification before persisting — green 'success' is the honest signal that we did work and it succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) --- pages/settings.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/settings.vue b/pages/settings.vue index a58bef0..f66761e 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -775,7 +775,7 @@ async function pickStorageDir() { } settingsStore.storageDirProbeError = null await settingsStore.setStorageDir(path) - toasts.add('Storage directory verified', 'info') + toasts.add('Storage directory verified', 'success') } catch (e) { toasts.add('Failed to select directory', 'error') }