diff --git a/app.vue b/app.vue
index 112f8ef..9da0a7f 100644
--- a/app.vue
+++ b/app.vue
@@ -6,6 +6,7 @@
diff --git a/components/AppSidebar.vue b/components/AppSidebar.vue
index 2262923..d06cb41 100644
--- a/components/AppSidebar.vue
+++ b/components/AppSidebar.vue
@@ -17,15 +17,15 @@
v-if="isPrereleaseBuild"
class="mb-2 rounded-md bg-teal-500/10 border border-teal-500/20 px-3 py-1.5 text-center text-xs font-medium text-teal-400"
>
- PRE-RELEASE
+ {{ $t('sidebar.pre_release') }}
- {{ networkLabel }}
+ {{ $t(networkLabelKey) }}
@@ -42,9 +42,9 @@
v-if="updaterStore.isPrerelease"
class="text-[10px] font-bold uppercase tracking-wider opacity-70"
>
- Pre-Release
+ {{ $t('sidebar.update_pre_release_tag') }}
- Update Available
+ {{ $t('sidebar.update_available') }}
v{{ updaterStore.version }}
@@ -60,7 +60,7 @@
: 'text-autonomi-muted hover:bg-autonomi-border/50 hover:text-autonomi-text'"
>
{{ item.icon }}
- {{ item.label }}
+ {{ $t(item.labelKey) }}
@@ -74,7 +74,7 @@
: 'text-autonomi-muted hover:bg-autonomi-border/50 hover:text-autonomi-text'"
>
⚙
- Settings
+ {{ $t('nav.settings') }}
@@ -109,18 +109,18 @@ const isPrereleaseBuild = computed(() =>
/-(?:rc|beta|alpha)\./.test(currentVersion.value ?? ''),
)
-const networkLabel = computed(() => {
+const networkLabelKey = computed(() => {
switch (settingsStore.devnetChainId) {
- case arbitrumSepolia.id: return 'SEPOLIA TESTNET'
- case ANVIL_CHAIN_ID: return 'DEVNET'
+ case arbitrumSepolia.id: return 'sidebar.network_sepolia'
+ case ANVIL_CHAIN_ID: return 'sidebar.network_devnet'
default: return null // mainnet or unset — no badge
}
})
const mainNav = computed(() => [
- { path: '/', label: 'Nodes', icon: '⬡' },
- { path: '/files', label: 'Files', icon: '◫' },
- { path: '/wallet', label: 'Wallet', icon: '◎' },
+ { path: '/', labelKey: 'nav.nodes', icon: '⬡' },
+ { path: '/files', labelKey: 'nav.files', icon: '◫' },
+ { path: '/wallet', labelKey: 'nav.wallet', icon: '◎' },
])
function isActive(path: string) {
diff --git a/components/StatusBadge.vue b/components/StatusBadge.vue
index 302cde6..507fc7a 100644
--- a/components/StatusBadge.vue
+++ b/components/StatusBadge.vue
@@ -15,35 +15,43 @@
diff --git a/plugins/i18n.client.ts b/plugins/i18n.client.ts
new file mode 100644
index 0000000..f8825d8
--- /dev/null
+++ b/plugins/i18n.client.ts
@@ -0,0 +1,27 @@
+import { createI18n } from 'vue-i18n'
+import en from '~/locales/en.json'
+import ja from '~/locales/ja.json'
+
+/**
+ * Exported so non-component code (Pinia options-API stores, plain utility
+ * modules) can call `i18n.global.t(...)` without a setup context — `useI18n()`
+ * only works inside Vue setup, but options-API store actions don't qualify.
+ */
+export const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ fallbackLocale: 'en',
+ messages: { en, ja },
+ missingWarn: true,
+ fallbackWarn: false,
+})
+
+export default defineNuxtPlugin((nuxtApp) => {
+ nuxtApp.vueApp.use(i18n)
+
+ // Dev-only handle for manual locale-swap testing from DevTools console.
+ // Remove when the Settings picker lands.
+ if (import.meta.dev && typeof window !== 'undefined') {
+ ;(window as unknown as { __i18n: typeof i18n }).__i18n = i18n
+ }
+})
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index ae5adc7..718ec17 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -863,6 +863,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
+ "tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-updater",
"tokio",
@@ -3351,6 +3352,16 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -4745,6 +4756,18 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.11.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -4892,6 +4915,27 @@ dependencies = [
"objc2-foundation",
]
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -4916,6 +4960,38 @@ dependencies = [
"objc2-io-surface",
]
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -4986,8 +5062,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.1",
+ "block2",
"objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
"objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
"objc2-foundation",
]
@@ -5114,6 +5209,22 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_info"
+version = "3.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
+dependencies = [
+ "android_system_properties",
+ "log",
+ "nix 0.30.1",
+ "objc2",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "serde",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "osakit"
version = "0.3.1"
@@ -6711,7 +6822,7 @@ dependencies = [
"keyring",
"libc",
"lru-slab",
- "nix",
+ "nix 0.29.0",
"once_cell",
"parking_lot",
"pin-project-lite",
@@ -7564,6 +7675,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "sysinfo"
version = "0.32.1"
@@ -7889,6 +8009,24 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "tauri-plugin-os"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
+dependencies = [
+ "gethostname",
+ "log",
+ "os_info",
+ "serde",
+ "serde_json",
+ "serialize-to-javascript",
+ "sys-locale",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 8eb9dc1..35b495c 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
+tauri-plugin-os = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index d6a7027..8a2cf4f 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -11,6 +11,8 @@
"dialog:allow-ask",
"opener:default",
"opener:allow-reveal-item-in-dir",
+ "os:default",
+ "os:allow-locale",
"process:default",
"updater:default",
"updater:allow-check",
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 4755cc8..8c99a2f 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -35,6 +35,11 @@ pub struct AppConfig {
/// init from this flag, so toggling requires an app restart to take effect.
#[serde(default)]
pub prerelease_channel: bool,
+ /// User-chosen UI locale (e.g. "en", "ja"). `None` means "follow system":
+ /// the frontend reads the OS locale via tauri-plugin-os and falls back to
+ /// English if it doesn't match a shipped locale.
+ #[serde(default)]
+ pub i18n_locale: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -132,6 +137,7 @@ impl Default for AppConfig {
theme_mode: default_theme_mode(),
upload_concurrency: default_upload_concurrency(),
prerelease_channel: false,
+ i18n_locale: None,
}
}
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 43e35f3..355281b 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -743,6 +743,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
+ .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(AutonomiState::new())
diff --git a/stores/files.ts b/stores/files.ts
index 9a0c5c9..13ddbfd 100644
--- a/stores/files.ts
+++ b/stores/files.ts
@@ -5,6 +5,12 @@ import { useToastStore } from './toasts'
import { useSettingsStore } from './settings'
import { payForQuotes, payForMerkleTree, formatNanoTokens, formatGasCost, type RawPayment, type SerializedPoolCommitment } from '~/utils/payment'
import { indelibleApi } from '~/utils/indelible-api'
+import { i18n } from '~/plugins/i18n.client'
+
+/** Module-scope translator. Options-API store actions can't call useI18n() —
+ * they run outside Vue's setup context — so we route through the global
+ * composer the plugin already configured. */
+const t = (key: string, params?: Record) => i18n.global.t(key, params ?? {})
// ── Lightweight cost estimate (no chunks parked) ──
// Matches ant_core::data::UploadCostEstimate.
@@ -605,10 +611,10 @@ export const useFilesStore = defineStore('files', {
stageTotal: undefined,
})
await this.persistHistory()
- toasts.add(`Upload complete: ${entry.name}`, 'info')
+ toasts.add(t('files.toast.upload_complete', { name: entry.name }), 'info')
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) })
- toasts.add(`Upload failed: ${entry.name} — ${e.message ?? e}`, 'error')
+ toasts.add(t('files.toast.upload_failed', { name: entry.name, error: e.message ?? e }), 'error')
}
return
}
@@ -691,7 +697,7 @@ export const useFilesStore = defineStore('files', {
})
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: `Payment failed: ${e.message}` })
- toasts.add(`Payment failed: ${e.message}`, 'error')
+ toasts.add(t('files.toast.payment_failed', { error: e.message }), 'error')
return
}
} else {
@@ -708,7 +714,7 @@ export const useFilesStore = defineStore('files', {
this.updateEntry(id, { gas_cost: formatGasCost(payResult.gasSpent.toString()) })
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: `Payment failed: ${e.message}` })
- toasts.add(`Payment failed: ${e.message}`, 'error')
+ toasts.add(t('files.toast.payment_failed', { error: e.message }), 'error')
return
}
}
@@ -737,10 +743,10 @@ export const useFilesStore = defineStore('files', {
}
await this.persistHistory()
- toasts.add(`Upload complete: ${entry.name}`, 'info')
+ toasts.add(t('files.toast.upload_complete', { name: entry.name }), 'info')
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) })
- toasts.add(`Upload failed: ${entry.name} — ${e.message ?? e}`, 'error')
+ toasts.add(t('files.toast.upload_failed', { name: entry.name, error: e.message ?? e }), 'error')
}
},
@@ -775,10 +781,10 @@ export const useFilesStore = defineStore('files', {
})
await this.persistHistory()
- toasts.add(`Upload complete: ${entry.name}`, 'info')
+ toasts.add(t('files.toast.upload_complete', { name: entry.name }), 'info')
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) })
- toasts.add(`Upload failed: ${entry.name} — ${e.message ?? e}`, 'error')
+ toasts.add(t('files.toast.upload_failed', { name: entry.name, error: e.message ?? e }), 'error')
}
},
@@ -837,7 +843,7 @@ export const useFilesStore = defineStore('files', {
this.updateEntry(id, { data_map_json: json })
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: `Failed to read datamap: ${e.message ?? e}` })
- toasts.add('Cannot download: datamap file missing or unreadable', 'error')
+ toasts.add(t('files.toast.datamap_missing'), 'error')
return
}
}
@@ -879,10 +885,10 @@ export const useFilesStore = defineStore('files', {
stageDone: undefined,
stageTotal: undefined,
})
- toasts.add(`Download complete: ${entry.name}`, 'info')
+ toasts.add(t('files.toast.download_complete', { name: entry.name }), 'info')
} catch (e: any) {
this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) })
- toasts.add(`Download failed: ${entry.name} — ${e.message ?? e}`, 'error')
+ toasts.add(t('files.toast.download_failed', { name: entry.name, error: e.message ?? e }), 'error')
}
},
@@ -923,7 +929,7 @@ export const useFilesStore = defineStore('files', {
try {
json = await invoke('read_datamap_file', { path: datamapPath })
} catch (e: any) {
- toasts.add(`Could not read datamap: ${e.message ?? e}`, 'error')
+ toasts.add(t('files.toast.datamap_read_failed', { error: e.message ?? e }), 'error')
return null
}
diff --git a/stores/nodes.ts b/stores/nodes.ts
index 8da21e2..7287534 100644
--- a/stores/nodes.ts
+++ b/stores/nodes.ts
@@ -6,6 +6,10 @@ import { daemonApi, connectSSE, disconnectSSE, type NodeEvent } from '~/utils/da
import type { NodeStatusSummary, ApiNodeStatus, DaemonStatus } from '~/utils/daemon-api'
import { POLL_INTERVAL, DETAIL_POLL_INTERVAL } from '~/utils/constants'
import type { UnlistenFn } from '@tauri-apps/api/event'
+import { i18n } from '~/plugins/i18n.client'
+
+/** Module-scope translator — see stores/files.ts for the rationale. */
+const t = (key: string, params?: Record) => i18n.global.t(key, params ?? {})
// Frontend node status — ant-core values + frontend-only states
export type NodeStatus = ApiNodeStatus | 'adding'
@@ -417,11 +421,11 @@ export const useNodesStore = defineStore('nodes', {
this.nodes = this.nodes.filter(n => !placeholderIds.includes(n.id))
await this.fetchNodes()
this.enrichNodeDetails() // fire-and-forget — populates data_dir/ports without waiting for the detail poll
- toasts.add(`Added ${result.nodes_added.length} node(s)`, 'info')
+ toasts.add(t('nodes.toast.added', { count: result.nodes_added.length }), 'info')
} catch (e: any) {
// Remove placeholders on failure
this.nodes = this.nodes.filter(n => !placeholderIds.includes(n.id))
- toasts.add(`Failed to add nodes: ${e.message}`, 'error')
+ toasts.add(t('nodes.toast.add_failed', { error: e.message }), 'error')
}
},
@@ -438,11 +442,11 @@ export const useNodesStore = defineStore('nodes', {
node.pid = result.pid
node.uptime_secs = 0
}
- toasts.add(`Node ${id} started`, 'info')
+ toasts.add(t('nodes.toast.started', { id }), 'info')
} catch (e: any) {
const node = this.nodes.find(n => n.id === id)
if (node) node.status = 'errored'
- toasts.add(`Failed to start node ${id}: ${e.message}`, 'error')
+ toasts.add(t('nodes.toast.start_failed', { id, error: e.message }), 'error')
}
},
@@ -458,9 +462,9 @@ export const useNodesStore = defineStore('nodes', {
node.pid = undefined
node.uptime_secs = undefined
}
- toasts.add(`Node ${id} stopped`, 'info')
+ toasts.add(t('nodes.toast.stopped', { id }), 'info')
} catch (e: any) {
- toasts.add(`Failed to stop node ${id}: ${e.message}`, 'error')
+ toasts.add(t('nodes.toast.stop_failed', { id, error: e.message }), 'error')
}
},
@@ -470,9 +474,9 @@ export const useNodesStore = defineStore('nodes', {
try {
await daemonApi.removeNode(id)
this.nodes = this.nodes.filter(n => n.id !== id)
- toasts.add(`Node ${id} removed`, 'info')
+ toasts.add(t('nodes.toast.removed', { id }), 'info')
} catch (e: any) {
- toasts.add(`Failed to remove node ${id}: ${e.message}`, 'error')
+ toasts.add(t('nodes.toast.remove_failed', { id, error: e.message }), 'error')
}
},
@@ -480,7 +484,7 @@ export const useNodesStore = defineStore('nodes', {
const toasts = useToastStore()
const stoppedNodes = this.nodes.filter(n => n.status === 'stopped')
if (stoppedNodes.length === 0) {
- toasts.add('No stopped nodes to start', 'warning')
+ toasts.add(t('nodes.toast.no_stopped'), 'warning')
return
}
@@ -495,17 +499,17 @@ export const useNodesStore = defineStore('nodes', {
for (const f of result.failed) {
const node = this.nodes.find(n => n.id === f.node_id)
if (node) node.status = 'errored'
- toasts.add(`Node ${f.node_id} failed: ${f.error}`, 'error')
+ toasts.add(t('nodes.toast.node_failed', { id: f.node_id, error: f.error }), 'error')
}
for (const id of result.already_running) {
const node = this.nodes.find(n => n.id === id)
if (node) node.status = 'running'
}
if (result.started.length > 0) {
- toasts.add(`Started ${result.started.length} node(s)`, 'info')
+ toasts.add(t('nodes.toast.started_count', { count: result.started.length }), 'info')
}
} catch (e: any) {
- toasts.add(`Failed to start all: ${e.message}`, 'error')
+ toasts.add(t('nodes.toast.start_all_failed', { error: e.message }), 'error')
await this.fetchNodes() // refresh to get real state
}
},
@@ -514,7 +518,7 @@ export const useNodesStore = defineStore('nodes', {
const toasts = useToastStore()
const runningNodes = this.nodes.filter(n => n.status === 'running')
if (runningNodes.length === 0) {
- toasts.add('No running nodes to stop', 'warning')
+ toasts.add(t('nodes.toast.no_running'), 'warning')
return
}
@@ -528,13 +532,13 @@ export const useNodesStore = defineStore('nodes', {
for (const f of result.failed) {
const node = this.nodes.find(n => n.id === f.node_id)
if (node) node.status = 'errored'
- toasts.add(`Node ${f.node_id} failed: ${f.error}`, 'error')
+ toasts.add(t('nodes.toast.node_failed', { id: f.node_id, error: f.error }), 'error')
}
if (result.stopped.length > 0) {
- toasts.add(`Stopped ${result.stopped.length} node(s)`, 'info')
+ toasts.add(t('nodes.toast.stopped_count', { count: result.stopped.length }), 'info')
}
} catch (e: any) {
- toasts.add(`Failed to stop all: ${e.message}`, 'error')
+ toasts.add(t('nodes.toast.stop_all_failed', { error: e.message }), 'error')
await this.fetchNodes()
}
},
diff --git a/stores/settings.ts b/stores/settings.ts
index 9076b6c..8ae5d4a 100644
--- a/stores/settings.ts
+++ b/stores/settings.ts
@@ -33,6 +33,7 @@ interface AppConfig {
theme_mode: string
upload_concurrency: number
prerelease_channel: boolean
+ i18n_locale: string | null
}
/** Allowed range for upload_concurrency (see AppConfig in Rust). */
@@ -67,6 +68,8 @@ export const useSettingsStore = defineStore('settings', {
/** Snapshot of `prereleaseChannel` at app boot — drives the "restart to
* apply" hint banner. Set once during loadConfig. */
prereleaseChannelBootValue: false,
+ /** Persisted UI locale choice. `null` = follow the OS. */
+ i18nLocale: null as string | null,
loaded: false,
// Devnet/testnet/direct-key mode (set by manifest or settings form).
// devnetChainId picks the chain:
@@ -99,6 +102,7 @@ export const useSettingsStore = defineStore('settings', {
this.themeMode = config.theme_mode === 'light' ? 'light' : 'dark'
this.uploadConcurrency = clampConcurrency(config.upload_concurrency)
this.prereleaseChannel = config.prerelease_channel ?? false
+ this.i18nLocale = config.i18n_locale ?? null
// Capture once on first load — represents the value the Rust side
// resolved its updater endpoint URL from. Subsequent toggles diverge
// from this until the next app restart.
@@ -122,6 +126,7 @@ export const useSettingsStore = defineStore('settings', {
theme_mode: this.themeMode,
upload_concurrency: this.uploadConcurrency,
prerelease_channel: this.prereleaseChannel,
+ i18n_locale: this.i18nLocale,
}
await invoke('save_config', { config })
} catch (e) {
@@ -169,6 +174,12 @@ export const useSettingsStore = defineStore('settings', {
await this.saveConfig()
},
+ /** Persist UI-locale choice. `null` means "follow the OS locale". */
+ async setI18nLocale(locale: string | null) {
+ this.i18nLocale = locale
+ await this.saveConfig()
+ },
+
async testIndelibleConnection(url: string, apiKey: string): Promise<{ ok: boolean; error?: string }> {
try {
const baseUrl = url.replace(/\/+$/, '').replace(/\/api\/v2.*$/, '')
diff --git a/stores/updater.ts b/stores/updater.ts
index baa64b1..9b1f9f0 100644
--- a/stores/updater.ts
+++ b/stores/updater.ts
@@ -2,6 +2,10 @@ import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { useToastStore } from './toasts'
+import { i18n } from '~/plugins/i18n.client'
+
+/** Module-scope translator — see stores/files.ts for the rationale. */
+const t = (key: string, params?: Record) => i18n.global.t(key, params ?? {})
export interface CheckResult {
ok: boolean
@@ -119,10 +123,7 @@ export const useUpdaterStore = defineStore('updater', {
this._restartFailedUnlisten = await listen('update-restart-failed', (event) => {
const v = event.payload.version
- toasts.add(
- `Update to v${v} installed but the app didn't restart. Quit and reopen Autonomi to finish updating.`,
- 'error',
- )
+ toasts.add(t('updater.toast.install_no_restart', { version: v }), 'error')
this.installing = false
this._cleanupInstallListeners()
})
@@ -139,7 +140,7 @@ export const useUpdaterStore = defineStore('updater', {
return
}
console.error('Update install failed:', e)
- toasts.add(`Update install failed: ${raw}`, 'error')
+ toasts.add(t('updater.toast.install_failed', { error: raw }), 'error')
this.installing = false
this._cleanupInstallListeners()
}
diff --git a/utils/validators.ts b/utils/validators.ts
index 5b49d49..5770188 100644
--- a/utils/validators.ts
+++ b/utils/validators.ts
@@ -10,16 +10,20 @@ const RESERVED_FILENAME_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i
/**
* Validate a user-typed "Save as" filename against the cross-platform reserved
- * set. Returns an error message for the UI, or null when the name is safe to
- * pass to the OS. An empty/whitespace-only string returns null so the caller
- * can decide whether emptiness is itself an error (most callers gate the
- * submit button on `name.trim().length > 0` separately).
+ * set. Returns a translation key the caller can pass to `$t(...)`, or null
+ * when the name is safe to pass to the OS. An empty/whitespace-only string
+ * returns null so the caller can decide whether emptiness is itself an error
+ * (most callers gate the submit button on `name.trim().length > 0` separately).
+ *
+ * Returns keys instead of pre-translated strings so this module stays usable
+ * outside a Vue setup context — the caller is always rendering in a
+ * component, so it owns translation.
*/
export function filenameError(name: string): string | null {
const trimmed = name.trim()
if (trimmed.length === 0) return null
- if (RESERVED_FILENAME_CHARS.test(trimmed)) return 'Filename cannot contain special characters'
- if (/[. ]$/.test(trimmed)) return 'Filename cannot end with a dot or space'
- if (RESERVED_FILENAME_NAMES.test(trimmed)) return `${trimmed} is a reserved name on Windows`
+ if (RESERVED_FILENAME_CHARS.test(trimmed)) return 'validators.filename.has_special_chars'
+ if (/[. ]$/.test(trimmed)) return 'validators.filename.ends_with_dot_or_space'
+ if (RESERVED_FILENAME_NAMES.test(trimmed)) return 'validators.filename.reserved_on_windows'
return null
}