feat(i18n): localize hardcoded application copy (#40377)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6c3299103c
commit
e85735028c
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,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: [],
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user