Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
<AppSidebar />
<div class="flex flex-1 flex-col overflow-hidden">
<AppHeader />
<div
v-if="settingsStore.storageDirProbeError"
class="border-b border-autonomi-error/40 bg-autonomi-error/10 px-6 py-2 text-xs text-autonomi-error"
role="alert"
>
<span class="font-medium">Storage directory unwritable.</span>
Adding a node will fail until you pick a working folder in
<NuxtLink to="/settings" class="underline hover:text-autonomi-error/80">Settings</NuxtLink>.
<span class="ml-2 font-mono text-autonomi-error/80">{{ settingsStore.storageDirProbeError }}</span>
</div>
<main class="flex-1 overflow-auto p-6">
<slot />
</main>
Expand All @@ -11,3 +21,9 @@
<UpdateDialog />
</div>
</template>

<script setup lang="ts">
import { useSettingsStore } from '~/stores/settings'

const settingsStore = useSettingsStore()
</script>
21 changes: 18 additions & 3 deletions pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
<p class="mt-2 text-xs text-autonomi-warning">
Only applies to newly added nodes. Existing nodes keep their current directory.
</p>
<p v-if="settingsStore.storageDirProbeError" class="mt-2 rounded border border-autonomi-error/40 bg-autonomi-error/10 px-2 py-1.5 text-xs text-autonomi-error">
{{ settingsStore.storageDirProbeError }}
</p>
</div>

<!-- Downloads Directory -->
Expand Down Expand Up @@ -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')
}
Expand Down
36 changes: 36 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,41 @@ fn get_default_download_dir() -> Result<String, String> {
.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<u64, String> {
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions stores/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
62 changes: 62 additions & 0 deletions tests/utils/daemon-api.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
20 changes: 17 additions & 3 deletions utils/daemon-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,26 @@ async function request<T>(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":"<message>"}`. 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<DaemonStatus>('GET', '/api/v1/status'),
Expand Down