feat(desktop): i18n for tauri side

This commit is contained in:
adamelmore
2026-01-27 15:00:17 -06:00
parent acf0df1e98
commit 51edf68606
8 changed files with 204 additions and 28 deletions

View File

@@ -186,6 +186,7 @@
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",

View File

@@ -1,13 +1,15 @@
import { invoke } from "@tauri-apps/api/core"
import { message } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n"
export async function installCli(): Promise<void> {
await initI18n()
try {
const path = await invoke<string>("install_cli")
await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, {
title: "CLI Installed",
})
await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
} catch (e) {
await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" })
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })
}
}

View File

@@ -0,0 +1,31 @@
export const dict = {
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
"desktop.dialog.chooseFolder": "Choose a folder",
"desktop.dialog.chooseFile": "Choose a file",
"desktop.dialog.saveFile": "Save file",
"desktop.updater.checkFailed.title": "Update Check Failed",
"desktop.updater.checkFailed.message": "Failed to check for updates",
"desktop.updater.none.title": "No Update Available",
"desktop.updater.none.message": "You are already using the latest version of OpenCode",
"desktop.updater.downloadFailed.title": "Update Failed",
"desktop.updater.downloadFailed.message": "Failed to download update",
"desktop.updater.downloaded.title": "Update Downloaded",
"desktop.updater.downloaded.prompt":
"Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
"desktop.updater.installFailed.title": "Update Failed",
"desktop.updater.installFailed.message": "Failed to install update",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.error.serverStartFailed.title": "OpenCode failed to start",
"desktop.error.serverStartFailed.description":
"The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) and try again.",
} as const

View File

@@ -0,0 +1,134 @@
import * as i18n from "@solid-primitives/i18n"
import { Store } from "@tauri-apps/plugin-store"
import { dict as desktopEn } from "./en"
import { dict as appEn } from "../../../app/src/i18n/en"
import { dict as appZh } from "../../../app/src/i18n/zh"
import { dict as appZht } from "../../../app/src/i18n/zht"
import { dict as appKo } from "../../../app/src/i18n/ko"
import { dict as appDe } from "../../../app/src/i18n/de"
import { dict as appEs } from "../../../app/src/i18n/es"
import { dict as appFr } from "../../../app/src/i18n/fr"
import { dict as appDa } from "../../../app/src/i18n/da"
import { dict as appJa } from "../../../app/src/i18n/ja"
import { dict as appPl } from "../../../app/src/i18n/pl"
import { dict as appRu } from "../../../app/src/i18n/ru"
import { dict as appAr } from "../../../app/src/i18n/ar"
import { dict as appNo } from "../../../app/src/i18n/no"
import { dict as appBr } from "../../../app/src/i18n/br"
export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br"
type RawDictionary = typeof appEn & typeof desktopEn
type Dictionary = i18n.Flatten<RawDictionary>
const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"]
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"
}
if (language.toLowerCase().startsWith("ko")) return "ko"
if (language.toLowerCase().startsWith("de")) return "de"
if (language.toLowerCase().startsWith("es")) return "es"
if (language.toLowerCase().startsWith("fr")) return "fr"
if (language.toLowerCase().startsWith("da")) return "da"
if (language.toLowerCase().startsWith("ja")) return "ja"
if (language.toLowerCase().startsWith("pl")) return "pl"
if (language.toLowerCase().startsWith("ru")) return "ru"
if (language.toLowerCase().startsWith("ar")) return "ar"
if (
language.toLowerCase().startsWith("no") ||
language.toLowerCase().startsWith("nb") ||
language.toLowerCase().startsWith("nn")
)
return "no"
if (language.toLowerCase().startsWith("pt")) return "br"
}
return "en"
}
function parseLocale(value: unknown): Locale | null {
if (!value) return null
if (typeof value !== "string") return null
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
return null
}
function parseRecord(value: unknown) {
if (!value || typeof value !== "object") return null
if (Array.isArray(value)) return null
return value as Record<string, unknown>
}
function pickLocale(value: unknown): Locale | null {
const direct = parseLocale(value)
if (direct) return direct
const record = parseRecord(value)
if (!record) return null
return parseLocale(record.locale)
}
const base = i18n.flatten({ ...appEn, ...desktopEn })
function build(locale: Locale): Dictionary {
if (locale === "en") return base
if (locale === "zh") return { ...base, ...i18n.flatten(appZh) }
if (locale === "zht") return { ...base, ...i18n.flatten(appZht) }
if (locale === "de") return { ...base, ...i18n.flatten(appDe) }
if (locale === "es") return { ...base, ...i18n.flatten(appEs) }
if (locale === "fr") return { ...base, ...i18n.flatten(appFr) }
if (locale === "da") return { ...base, ...i18n.flatten(appDa) }
if (locale === "ja") return { ...base, ...i18n.flatten(appJa) }
if (locale === "pl") return { ...base, ...i18n.flatten(appPl) }
if (locale === "ru") return { ...base, ...i18n.flatten(appRu) }
if (locale === "ar") return { ...base, ...i18n.flatten(appAr) }
if (locale === "no") return { ...base, ...i18n.flatten(appNo) }
if (locale === "br") return { ...base, ...i18n.flatten(appBr) }
return { ...base, ...i18n.flatten(appKo) }
}
const state = {
locale: detectLocale(),
dict: base as Dictionary,
init: undefined as Promise<Locale> | undefined,
}
state.dict = build(state.locale)
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
return translate(key, params)
}
export function initI18n(): Promise<Locale> {
const cached = state.init
if (cached) return cached
const promise = (async () => {
const store = await Store.load("opencode.global.dat").catch(() => null)
if (!store) return state.locale
const raw = await store.get("language").catch(() => null)
const value = typeof raw === "string" ? JSON.parse(raw) : raw
const next = pickLocale(value) ?? state.locale
state.locale = next
state.dict = build(next)
return next
})().catch(() => state.locale)
state.init = promise
return promise
}

View File

@@ -18,16 +18,17 @@ import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup }
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
)
throw new Error(t("error.dev.rootNotFound"))
}
void initI18n()
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
const originalGetComputedStyle = window.getComputedStyle
@@ -54,7 +55,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a folder",
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
})
return result
},
@@ -63,14 +64,14 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a file",
title: opts?.title ?? t("desktop.dialog.chooseFile"),
})
return result
},
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? "Save file",
title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
return result
@@ -380,7 +381,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
const errorMessage = () => {
const error = serverData.error
if (!error) return "Unknown error"
if (!error) return t("error.chain.unknown")
if (typeof error === "string") return error
if (error instanceof Error) return error.message
return String(error)
@@ -410,16 +411,15 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
}
>
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
<div class="text-16-semibold">OpenCode failed to start</div>
<div class="text-16-semibold">{t("desktop.error.serverStartFailed.title")}</div>
<div class="text-12-regular opacity-70 text-center max-w-xl">
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy)
and try again.
{t("desktop.error.serverStartFailed.description")}
</div>
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
</div>
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
Restart App
{t("error.page.action.restart")}
</button>
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>

View File

@@ -5,10 +5,13 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli"
import { initI18n, t } from "./i18n"
export async function createMenu() {
if (ostype() !== "macos") return
await initI18n()
const menu = await Menu.new({
items: [
await Submenu.new({
@@ -20,22 +23,22 @@ export async function createMenu() {
await MenuItem.new({
enabled: UPDATER_ENABLED,
action: () => runUpdater({ alertOnFail: true }),
text: "Check For Updates...",
text: t("desktop.menu.checkForUpdates"),
}),
await MenuItem.new({
action: () => installCli(),
text: "Install CLI...",
text: t("desktop.menu.installCli"),
}),
await MenuItem.new({
action: async () => window.location.reload(),
text: "Reload Webview",
text: t("desktop.menu.reloadWebview"),
}),
await MenuItem.new({
action: async () => {
await invoke("kill_sidecar").catch(() => undefined)
await relaunch().catch(() => undefined)
},
text: "Restart",
text: t("desktop.menu.restart"),
}),
await PredefinedMenuItem.new({
item: "Separator",

View File

@@ -4,41 +4,45 @@ import { ask, message } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os"
import { initI18n, t } from "./i18n"
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
let update
try {
update = await check()
} catch {
if (alertOnFail) await message("Failed to check for updates", { title: "Update Check Failed" })
if (alertOnFail)
await message(t("desktop.updater.checkFailed.message"), { title: t("desktop.updater.checkFailed.title") })
return
}
if (!update) {
if (alertOnFail)
await message("You are already using the latest version of OpenCode", { title: "No Update Available" })
if (alertOnFail) await message(t("desktop.updater.none.message"), { title: t("desktop.updater.none.title") })
return
}
try {
await update.download()
} catch {
if (alertOnFail) await message("Failed to download update", { title: "Update Failed" })
if (alertOnFail)
await message(t("desktop.updater.downloadFailed.message"), { title: t("desktop.updater.downloadFailed.title") })
return
}
const shouldUpdate = await ask(
`Version ${update.version} of OpenCode has been downloaded, would you like to install it and relaunch?`,
{ title: "Update Downloaded" },
)
const shouldUpdate = await ask(t("desktop.updater.downloaded.prompt", { version: update.version }), {
title: t("desktop.updater.downloaded.title"),
})
if (!shouldUpdate) return
try {
if (ostype() === "windows") await invoke("kill_sidecar")
await update.install()
} catch {
await message("Failed to install update", { title: "Update Failed" })
await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
return
}