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
5f29a82190
commit
bf04bbffe9
@@ -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