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 5f29a82190
commit bf04bbffe9
121 changed files with 4758 additions and 331 deletions
@@ -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,