feat(i18n): localize hardcoded application copy (#40377)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-04 16:56:03 +10:00
committed by GitHub
parent 6c3299103c
commit e85735028c
121 changed files with 4758 additions and 331 deletions
+5
View File
@@ -2,3 +2,8 @@
- Renderer process should only call `window.api` from `src/preload`.
- Main process should register IPC handlers in `src/main/ipc.ts`.
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
import { nativeT } from "./native-translations"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
@@ -18,7 +19,7 @@ export function createPickedFileAuthorizations(
async read(sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error("File was not selected by the picker")
throw new Error(nativeT("desktop.picker.error.notSelected"))
const bytes = await read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
@@ -33,7 +34,7 @@ export function createPickedFileAuthorizations(
export function assertAttachmentBudget(files: { size: number }[]) {
const total = files.reduce((sum, file) => sum + file.size, 0)
if (total <= MAX_ATTACHMENT_BYTES) return
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
}
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
@@ -41,7 +42,7 @@ export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT
try {
const info = await file.stat()
if (info.size > maxBytes)
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
const bytes = Buffer.allocUnsafe(info.size)
let offset = 0
while (offset < info.size) {
+13 -14
View File
@@ -48,6 +48,7 @@ import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
import { setNativeTranslations } from "./native-translations"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -271,6 +272,14 @@ const main = Effect.gen(function* () {
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(stopSidecars)
const menuDeps = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater, true),
relaunch,
}
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
@@ -298,6 +307,9 @@ const main = Effect.gen(function* () {
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menuDeps)
},
})
registerWslIpcHandlers(wslServers)
void updater.start()
@@ -397,20 +409,7 @@ const main = Effect.gen(function* () {
yield* Fiber.await(loadingTask)
const windows = restoreMainWindows()
if (windows.length) {
createMenu({
trigger: (id) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
void showUpdaterDialog(updater, true)
},
relaunch: () => {
relaunch()
},
})
}
if (windows.length) createMenu(menuDeps)
})
Effect.runFork(main)
+21 -4
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
@@ -22,10 +23,11 @@ import {
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
import { createDesktopDraftStore } from "./draft-store"
import { nativeT } from "./native-translations"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
return [{ name: nativeT("desktop.dialog.files"), extensions: ext }]
}
const pickedFiles = createPickedFileAuthorizations()
@@ -49,6 +51,7 @@ type Deps = {
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function registerIpcHandlers(deps: Deps) {
@@ -99,6 +102,20 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
ipcMain.handle("set-native-translations", (event: IpcMainInvokeEvent, value: unknown) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (
!win ||
win.isDestroyed() ||
win.webContents !== event.sender ||
event.senderFrame !== event.sender.mainFrame
) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
})
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try {
const store = getStore(name)
@@ -142,7 +159,7 @@ export function registerIpcHandlers(deps: Deps) {
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? "Choose a folder",
title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: opts?.defaultPath,
})
if (result.canceled) return null
@@ -158,7 +175,7 @@ export function registerIpcHandlers(deps: Deps) {
) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file",
title: opts?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
})
@@ -188,7 +205,7 @@ export function registerIpcHandlers(deps: Deps) {
"save-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
const result = await dialog.showSaveDialog({
title: opts?.title ?? "Save file",
title: opts?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
if (result.canceled) return null
+5 -4
View File
@@ -10,6 +10,7 @@ import {
import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { openExternalURL } from "./windows"
import { nativeT } from "./native-translations"
type Deps = {
trigger: (id: string) => void
@@ -21,9 +22,9 @@ export function createMenu(deps: Deps) {
if (process.platform !== "darwin") return
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
if (menu.role) return { role: nativeRole(menu.role) }
if (menu.role) return { role: nativeRole(menu.role), label: nativeT(menu.labelKey) }
return {
label: menu.label,
label: nativeT(menu.labelKey),
submenu: menu.items
?.filter((entry) => desktopMenuVisible(entry, "macos"))
.map((entry) => nativeItem(entry, deps)),
@@ -35,10 +36,10 @@ export function createMenu(deps: Deps) {
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
if (entry.type === "separator") return { type: "separator" }
if (entry.role) return { role: nativeRole(entry.role) }
if (entry.role) return { role: nativeRole(entry.role), label: entry.labelKey ? nativeT(entry.labelKey) : undefined }
const item: MenuItemConstructorOptions = {
label: entry.label,
label: entry.labelKey ? nativeT(entry.labelKey) : undefined,
accelerator: entry.accelerator?.macos,
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
}
@@ -0,0 +1,24 @@
import {
DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_KEYS,
formatDesktopNativeMessage,
type DesktopNativeBundle,
type DesktopNativeKey,
} from "@opencode-ai/app/i18n/desktop-native"
let bundle: DesktopNativeBundle = { locale: "en", messages: { ...DESKTOP_NATIVE_ENGLISH } }
export function setNativeTranslations(next: DesktopNativeBundle) {
if (
next.locale === bundle.locale &&
DESKTOP_NATIVE_KEYS.every((key) => next.messages[key] === bundle.messages[key])
) {
return false
}
bundle = next
return true
}
export function nativeT(key: DesktopNativeKey, params?: Record<string, string | number>) {
return formatDesktopNativeMessage(bundle.messages[key], params)
}
+14 -5
View File
@@ -5,6 +5,7 @@ import { createUpdaterController, type UpdaterReadyRecord } from "./updater-cont
import { getLogger } from "./logging"
import { getStore } from "./store"
import { setAppQuitting } from "./windows"
import { nativeT } from "./native-translations"
const { autoUpdater } = pkg
const key = "ready"
@@ -63,21 +64,29 @@ export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoU
const state = await controller.check()
if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
await dialog.showMessageBox({
type: "error",
message: nativeT("desktop.updater.dialog.checkFailed.message"),
title: nativeT("desktop.updater.dialog.checkFailed.title"),
})
return
}
if (state.status === "up-to-date") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
await dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.upToDate.message"),
title: nativeT("desktop.updater.dialog.upToDate.title"),
})
return
}
if (state.status !== "ready") return
const response = await dialog.showMessageBox({
type: "info",
message: `Update ${state.version} downloaded. Restart now?`,
title: "Update Ready",
buttons: ["Restart", "Later"],
message: nativeT("desktop.updater.dialog.ready.message", { version: state.version }),
title: nativeT("desktop.updater.dialog.ready.title"),
buttons: [nativeT("desktop.updater.dialog.restart"), nativeT("desktop.updater.dialog.later")],
defaultId: 0,
cancelId: 1,
})
+33 -12
View File
@@ -12,6 +12,7 @@ import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore, removeStoreFile } from "./store"
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
import { nativeT } from "./native-translations"
import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
@@ -345,19 +346,20 @@ function wireWindowRecovery(win: BrowserWindow, name: string) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
const handle = async (button: string | undefined, wait: boolean) => {
if (button === "Export Logs") {
type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit"
const handle = async (action: RecoveryAction | undefined, wait: boolean) => {
if (action === "export-logs") {
const sampling = sampler.stopAndFlush()
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
if (wait && sampling) sampler.start()
return true
}
if (button === "Relaunch") {
if (action === "relaunch") {
sampler.stopAndFlush()
relaunchHandler()
return false
}
if (button === "Quit") {
if (action === "quit") {
sampler.stopAndFlush()
app.quit()
}
@@ -369,16 +371,26 @@ function wireWindowRecovery(win: BrowserWindow, name: string) {
showing = true
try {
while (!win.isDestroyed()) {
const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
const actions: { id: RecoveryAction; label: string }[] = wait
? [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "keep-waiting", label: nativeT("desktop.recovery.action.keepWaiting") },
]
: [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "quit", label: nativeT("desktop.recovery.action.quit") },
]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons,
buttons: actions.map((action) => action.label),
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(buttons[result.response], wait)) continue
if (await handle(actions[result.response]?.id, wait)) continue
return
}
} finally {
@@ -410,8 +422,13 @@ function wireWindowRecovery(win: BrowserWindow, name: string) {
if (!isMainFrame || errorCode === -3) return
void show(
"OpenCode failed to load",
[`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
nativeT("desktop.recovery.loadFailed"),
nativeT("desktop.recovery.loadFailed.detail", {
window: name,
url: validatedURL,
code: errorCode,
description: errorDescription,
}),
false,
)
}
@@ -426,15 +443,19 @@ function wireWindowRecovery(win: BrowserWindow, name: string) {
sampler.stopAndFlush()
writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error")
void show(
"OpenCode window terminated unexpectedly",
[`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
nativeT("desktop.recovery.terminated"),
nativeT("desktop.recovery.terminated.detail", {
window: name,
reason: details.reason,
code: details.exitCode ?? nativeT("desktop.recovery.unknown"),
}),
false,
)
})
win.on("unresponsive", () => {
writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error")
sampler.start()
void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true)
})
win.on("responsive", () => {
writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error")
+3 -2
View File
@@ -3,6 +3,7 @@ import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString, requireWslIpcStrings } from "./policy"
import type { WslServersState } from "../../preload/types"
import { nativeT } from "../native-translations"
export function registerWslIpcHandlers(controller: WslServersController) {
if (process.platform !== "win32") {
@@ -68,13 +69,13 @@ export function registerWslIpcHandlers(controller: WslServersController) {
function registerUnavailableWslIpcHandlers() {
const unavailable = () => {
throw new Error("WSL is only available on Windows")
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
}
const state = (): WslServersState => ({
runtime: {
available: false,
version: null,
error: "WSL is only available on Windows",
error: nativeT("desktop.wsl.error.windowsOnly"),
},
installed: [],
online: [],
+15 -6
View File
@@ -4,6 +4,7 @@ import { join } from "node:path"
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
import { wslTerminalArgs } from "./policy"
import { nativeT } from "../native-translations"
export type WslCommandLine = {
stream: "stdout" | "stderr"
@@ -68,7 +69,11 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
} catch {
/* ignore */
}
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
reject(
new Error(
nativeT("desktop.wsl.error.commandTimeout", { command, args: args.join(" "), timeout: timeoutMs }),
),
)
}, timeoutMs)
let stdout = ""
@@ -139,7 +144,11 @@ function runInteractiveCommand(command: string, args: string[], opts: RunWslOpti
if (settled) return
settled = true
cleanup()
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
reject(
new Error(
nativeT("desktop.wsl.error.commandTimeout", { command, args: args.join(" "), timeout: timeoutMs }),
),
)
}, timeoutMs)
const abortHandler = () => {
@@ -214,7 +223,7 @@ export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeC
return {
available: false,
version: null,
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
error: summarize(version.stderr || version.stdout) || nativeT("desktop.wsl.error.unavailable"),
}
}
@@ -228,7 +237,7 @@ export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeC
export async function listInstalledWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--verbose"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.listInstalled"))
}
return parseInstalledDistros(result.stdout)
}
@@ -236,7 +245,7 @@ export async function listInstalledWslDistros(opts?: RunWslOptions) {
export async function listOnlineWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--online"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.listOnline"))
}
return parseOnlineDistros(result.stdout)
}
@@ -284,7 +293,7 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
canExecute: false,
hasBash: false,
hasCurl: false,
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
error: summarize(executable.stderr || executable.stdout) || nativeT("desktop.wsl.error.executeDistro"),
}
}
+9 -7
View File
@@ -15,6 +15,7 @@ import { WSL_SERVERS_KEY } from "../store-keys"
import { getStore } from "../store"
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
import { nativeT } from "../native-translations"
import {
installWslDistro,
installWslOpencode,
@@ -327,7 +328,7 @@ export function createWslServersController(
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
const result = await installWslRuntimeElevated({ signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl")
throw new Error(message)
}
const runtime = await probeWslRuntime({ signal: abort.signal })
@@ -339,7 +340,8 @@ export function createWslServersController(
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslDistro(name, { signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
const message =
summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name })
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal })
@@ -362,7 +364,7 @@ export function createWslServersController(
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
}
await refreshOpencodeCheck(name, { signal: abort.signal })
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
@@ -378,7 +380,7 @@ export function createWslServersController(
async addServer(distro: string): Promise<WslServerConfig> {
const id = wslServerIdForDistro(distro)
if (state.servers.some((item) => item.config.id === id)) {
throw new Error(`${distro} is already added`)
throw new Error(nativeT("desktop.wsl.error.alreadyAdded", { distro }))
}
const config: WslServerConfig = {
id,
@@ -475,7 +477,7 @@ function opencodeCheck(
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is not installed in this distro",
error: nativeT("desktop.wsl.error.opencodeMissing"),
}
}
if (!version) {
@@ -485,7 +487,7 @@ function opencodeCheck(
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is installed but could not run",
error: nativeT("desktop.wsl.error.opencodeCannotRun"),
}
}
return {
@@ -503,7 +505,7 @@ function distroProbeReady(probe: WslDistroProbe | undefined) {
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" })
}
// Re-export types used by callers
+9 -4
View File
@@ -5,6 +5,7 @@ import { app } from "electron"
import { checkHealth } from "../server"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { pollWslHealth } from "./startup"
import { nativeT } from "../native-translations"
export type WslSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
@@ -18,7 +19,7 @@ export async function spawnWslSidecar(
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
const port = await allocatePort()
const password = randomUUID()
@@ -64,7 +65,7 @@ export async function spawnWslSidecar(
const timedOut = new Promise<never>(
(_, reject) =>
(timeout = setTimeout(
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
() => reject(new Error(nativeT("desktop.wsl.error.healthTimeout", { distro, timeout: timeoutMs }))),
timeoutMs,
)),
)
@@ -97,7 +98,7 @@ function allocatePort() {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
reject(new Error("Failed to get port"))
reject(new Error(nativeT("desktop.wsl.error.failedPort")))
return
}
server.close(() => resolve(address.port))
@@ -125,5 +126,9 @@ function forwardLines(
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
return nativeT("desktop.wsl.error.serverExitedBeforeHealthy", {
code: code ?? "null",
signal: signal ?? "null",
output: suffix,
})
}
+7 -1
View File
@@ -1,3 +1,5 @@
import { nativeT } from "../native-translations"
export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
return servers.map((server) => server.id)
}
@@ -5,7 +7,11 @@ export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
if (installed === expected) return
throw new Error(
`OpenCode update finished but ${distro} still reports ${installed ?? "no version"}; expected ${expected}`,
nativeT("desktop.wsl.error.updateVersion", {
distro,
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
expected,
}),
)
}
+1
View File
@@ -132,6 +132,7 @@ const api: ElectronAPI = {
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
setNativeTranslations: (bundle) => ipcRenderer.invoke("set-native-translations", bundle),
}
contextBridge.exposeInMainWorld("api", api)
+2
View File
@@ -1,6 +1,7 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
export type {
WslDistroProbe,
WslInstalledDistro,
@@ -111,4 +112,5 @@ export type ElectronAPI = {
exportDebugLogs: () => Promise<string>
setForceFocus: (enabled: boolean) => Promise<void>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
setNativeTranslations: (bundle: DesktopNativeBundle) => Promise<void>
}
+1 -1
View File
@@ -89,7 +89,7 @@ export type Locale =
| "th"
type RawDictionary = typeof appEn & typeof desktopEn
type Dictionary = i18n.Flatten<RawDictionary>
type Dictionary = Record<keyof i18n.Flatten<RawDictionary>, string>
const LOCALES: readonly Locale[] = [
"en",
+12 -9
View File
@@ -13,6 +13,7 @@ import {
ServerConnection,
useCommand,
useWslServers,
useLanguage,
} from "@opencode-ai/app"
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
@@ -21,7 +22,7 @@ import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidj
import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { t } from "./i18n"
import { initializationData } from "./initialization"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
@@ -59,8 +60,6 @@ if (import.meta.env.VITE_SENTRY_DSN) {
})
}
void initI18n()
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
void window.api.updater.subscribe(setUpdaterState)
@@ -175,14 +174,14 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
async openDirectoryPickerDialog(opts) {
return window.api.openDirectoryPicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
title: opts?.title,
})
},
async openAttachmentPickerDialog(opts, onFile) {
const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
title: opts?.title,
defaultPath: opts?.defaultPath,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
})
@@ -204,7 +203,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
async saveFilePickerDialog(opts) {
return window.api.saveFilePicker({
title: opts?.title ?? t("desktop.dialog.saveFile"),
title: opts?.title,
defaultPath: opts?.defaultPath,
})
},
@@ -376,6 +375,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
function App() {
const wslServers = useWslServers()
const language = useLanguage()
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
)
@@ -384,7 +384,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const list: ServerConnection.Any[] = []
if (data) {
list.push({
displayName: "Local Server",
displayName: language.t("desktop.server.local"),
type: "sidecar",
variant: "base",
http: {
@@ -394,7 +394,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
},
})
}
list.push(...readyWslConnections(wslServers.data))
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
return list
})
const effectiveDefaultServer = createMemo(() =>
@@ -426,7 +426,10 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<AppBaseProviders
locale={locale.latest}
onNativeTranslations={(bundle) => void window.api.setNativeTranslations(bundle).catch(() => undefined)}
>
<Show when={true}>{(_) => <App />}</Show>
</AppBaseProviders>
</PlatformProvider>
@@ -34,6 +34,10 @@ describe("WSL desktop connections", () => {
])
})
test("uses the renderer translation for the WSL connection label", () => {
expect(readyWslConnections(state("ready"), "Translated WSL")[0]?.label).toBe("Translated WSL")
})
test("does not block desktop startup on a configured WSL default", () => {
const key = "wsl:Debian"
expect(availableStartupServer(key, undefined)).toBe("sidecar")
@@ -1,12 +1,12 @@
import type { WslServersState } from "@opencode-ai/app/wsl/types"
export function readyWslConnections(state?: WslServersState) {
export function readyWslConnections(state?: WslServersState, label = "WSL") {
return (state?.servers ?? []).flatMap((item) => {
if (item.runtime.kind !== "ready") return []
return [
{
displayName: item.config.distro,
label: "WSL",
label,
type: "sidecar" as const,
variant: "wsl" as const,
distro: item.config.distro,