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 @@
+
+ Storage directory unwritable.
+ Adding a node will fail until you pick a working folder in
+ Settings.
+ {{ settingsStore.storageDirProbeError }}
+
@@ -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'),