diff --git a/bun.lock b/bun.lock index 13647ffa1..d02afd42d 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cc6b3af99..49e032339 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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", diff --git a/packages/desktop/src/cli.ts b/packages/desktop/src/cli.ts index 965ed6ddc..5a8875cf8 100644 --- a/packages/desktop/src/cli.ts +++ b/packages/desktop/src/cli.ts @@ -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 { + await initI18n() + try { const path = await invoke("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") }) } } diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts new file mode 100644 index 000000000..4008efca5 --- /dev/null +++ b/packages/desktop/src/i18n/en.ts @@ -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 diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts new file mode 100644 index 000000000..34a427c7a --- /dev/null +++ b/packages/desktop/src/i18n/index.ts @@ -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 + +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 +} + +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 | undefined, +} + +state.dict = build(state.locale) + +const translate = i18n.translator(() => state.dict, i18n.resolveTemplate) + +export function t(key: keyof Dictionary, params?: Record) { + return translate(key, params) +} + +export function initI18n(): Promise { + 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 +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b19adfeda..344c6be8d 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -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): 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): 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) => 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) => JSX. } >
-
OpenCode failed to start
+
{t("desktop.error.serverStartFailed.title")}
- 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")}
{errorMessage()}
diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 1b4c61135..2edeff42b 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -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", diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index 4753ee663..b48bb6be0 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -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 }