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..f66761e 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', 'success') } 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'),