Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ file is the human-readable summary of what changed and why.
is off, the dialog offers **Download** first and switches to **Install and
restart** when it lands.
([#305](https://github.com/quickbeard/codev-code/pull/305))
- **Check for Updates now tells you which version you are on** — every dialog
the menu item opened talked about versions without naming yours: "You're up
to date." with nothing to check it against, and an update prompt that named
the new version but not the one it was replacing. All of them now show the
running version, and the update prompts show both — so "update to what, from
what" is answerable without hunting through About or Settings. A check that
found an update you have not downloaded yet used to close with no dialog at
all if automatic downloading was off; it now names the version and offers
**Download**. The dialogs are also translated, along with the rest of the
app — they were English-only before, being built outside the web UI.
([#323](https://github.com/quickbeard/codev-code/pull/323))
- **The terminal app asks about updates instead of ignoring them** — `codev`
checked for a new version a second after launch and announced it with an
internal event that the interface subscribes to as it starts up. The check
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,24 @@ export const dict = {
"Your CoDev sign-in expired, so sessions are no longer uploaded for analysis. Log in again to resume — pending sessions upload automatically afterwards.",
"toast.sessionUpload.paused.action.login": "Log in",

// The updater dialogs, which the desktop main process builds itself — see
// desktop/src/main/updater.ts. Every one names the running version.
"updater.dialog.error.title": "Update Error",
"updater.dialog.error.message": "Update check failed.",
"updater.dialog.error.detail": "You're on CoDev {{version}}.",
"updater.dialog.upToDate.title": "No Updates",
"updater.dialog.upToDate.message": "You're up to date.",
"updater.dialog.upToDate.detail": "CoDev {{version}} is the latest version.",
"updater.dialog.available.title": "Update Available",
"updater.dialog.available.message": "CoDev {{version}} is available.",
"updater.dialog.available.detail": "You're on {{current}}. Download it now?",
"updater.dialog.ready.title": "Update Ready",
"updater.dialog.ready.message": "CoDev {{version}} downloaded. Restart now?",
"updater.dialog.ready.detail": "You're on {{current}}.",
"updater.dialog.action.download": "Download",
"updater.dialog.action.restart": "Restart",
"updater.dialog.action.later": "Later",

// The desktop app menu (macOS native menu bar and the Windows/Linux
// titlebar menu) — see desktop-menu.ts.
"menu.app": "CoDev",
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,24 @@ export const dict = {
"Tu sesión de CoDev caducó, así que las sesiones ya no se suben para su análisis. Vuelve a iniciar sesión para reanudarla: las sesiones pendientes se subirán automáticamente después.",
"toast.sessionUpload.paused.action.login": "Iniciar sesión",

// The updater dialogs, which the desktop main process builds itself — see
// desktop/src/main/updater.ts. Every one names the running version.
"updater.dialog.error.title": "Error de actualización",
"updater.dialog.error.message": "No se pudo comprobar si hay actualizaciones.",
"updater.dialog.error.detail": "Tienes CoDev {{version}}.",
"updater.dialog.upToDate.title": "Sin actualizaciones",
"updater.dialog.upToDate.message": "Todo está actualizado.",
"updater.dialog.upToDate.detail": "CoDev {{version}} es la versión más reciente.",
"updater.dialog.available.title": "Actualización disponible",
"updater.dialog.available.message": "CoDev {{version}} ya está disponible.",
"updater.dialog.available.detail": "Tienes la {{current}}. ¿Descargarla ahora?",
"updater.dialog.ready.title": "Actualización lista",
"updater.dialog.ready.message": "CoDev {{version}} descargada. ¿Reiniciar ahora?",
"updater.dialog.ready.detail": "Tienes la {{current}}.",
"updater.dialog.action.download": "Descargar",
"updater.dialog.action.restart": "Reiniciar",
"updater.dialog.action.later": "Más tarde",

// The desktop app menu (macOS native menu bar and the Windows/Linux
// titlebar menu) — see desktop-menu.ts.
"menu.app": "CoDev",
Expand Down
35 changes: 35 additions & 0 deletions packages/app/src/i18n/placeholders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"

import { dict as en } from "./en"
import { dict as es } from "./es"
import { dict as vi } from "./vi"

// A dropped or renamed `{{placeholder}}` renders as literal text — the version
// number silently missing from "You're on CoDev {{version}}." rather than an
// error anyone would notice. The desktop main process is where that hurts
// most: its dialogs and menu bar are assembled from these strings with no
// renderer, no fallback, and no test rendering them.
const placeholders = (value: string) =>
[...value.matchAll(/\{\{\s*(\w+)\s*\}\}/g)]
.map((match) => match[1])
.sort()
.join(",")

describe("locale placeholders", () => {
for (const [locale, dict] of [
["es", es],
["vi", vi],
] as const) {
test(`${locale} interpolates the same values as en`, () => {
const translated: Record<string, string> = dict
const mismatched = Object.entries(en as Record<string, string>).flatMap(([key, value]) => {
const other = translated[key]
if (other === undefined) return []
if (placeholders(value) === placeholders(other)) return []
return [`${key}: en has [${placeholders(value)}], ${locale} has [${placeholders(other)}]`]
})

expect(mismatched).toEqual([])
})
}
})
18 changes: 18 additions & 0 deletions packages/app/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,24 @@ export const dict = {
"Phiên đăng nhập CoDev của bạn đã hết hạn nên các phiên làm việc không còn được tải lên để phân tích. Hãy đăng nhập lại để tiếp tục — các phiên đang chờ sẽ tự động được tải lên sau đó.",
"toast.sessionUpload.paused.action.login": "Đăng nhập",

// The updater dialogs, which the desktop main process builds itself — see
// desktop/src/main/updater.ts. Every one names the running version.
"updater.dialog.error.title": "Lỗi cập nhật",
"updater.dialog.error.message": "Không thể kiểm tra cập nhật.",
"updater.dialog.error.detail": "Bạn đang dùng CoDev {{version}}.",
"updater.dialog.upToDate.title": "Không có bản cập nhật",
"updater.dialog.upToDate.message": "Bạn đang dùng bản mới nhất.",
"updater.dialog.upToDate.detail": "CoDev {{version}} là phiên bản mới nhất.",
"updater.dialog.available.title": "Có bản cập nhật",
"updater.dialog.available.message": "Đã có CoDev {{version}}.",
"updater.dialog.available.detail": "Bạn đang dùng {{current}}. Tải xuống ngay?",
"updater.dialog.ready.title": "Bản cập nhật đã sẵn sàng",
"updater.dialog.ready.message": "Đã tải xuống CoDev {{version}}. Khởi động lại ngay?",
"updater.dialog.ready.detail": "Bạn đang dùng {{current}}.",
"updater.dialog.action.download": "Tải xuống",
"updater.dialog.action.restart": "Khởi động lại",
"updater.dialog.action.later": "Để sau",

// The desktop app menu (macOS native menu bar and the Windows/Linux
// titlebar menu) — see desktop-menu.ts.
"menu.app": "CoDev",
Expand Down
40 changes: 40 additions & 0 deletions packages/desktop/src/main/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { resolveTemplate, translator } from "@solid-primitives/i18n"
import { app } from "electron"
import type { dict as english } from "@opencode-ai/app/i18n/en"

import { pickLocale, type Locale } from "./locale"
import { getStore } from "./store"

// Fork: the main process renders two surfaces of its own — the macOS native
// menu bar and the updater dialogs — and has no renderer i18n to resolve their
// strings with. It reads the app dictionaries directly instead, in the locale
// the app persisted into the renderer-owned global store, so a main-process
// string can never disagree with the UI around it.
const GLOBAL_STORE = "codev.global.dat"

export type TranslationKey = keyof typeof english

// Dynamically imported so a language nobody selected is never loaded, and so a
// caller that returns before translating anything — createMenu on Windows and
// Linux — pays for no dictionary at all. Repeat calls hit the module cache.
export async function createTranslator() {
const { dict } = await import("@opencode-ai/app/i18n/en")
const locale = pickLocale(getStore(GLOBAL_STORE).get("language"), app.getLocale())
// Layered over English rather than used alone: a locale dict can trail it by
// a key between translation passes, and an English string beats a blank one.
const translated: Record<string, string> = { ...dict, ...(await localeDict(locale)) }
const translate = translator(() => translated, resolveTemplate)
return (key: TranslationKey, params?: Record<string, string | number>) => translate(key, params) ?? key
}

async function localeDict(locale: Locale) {
if (locale === "es") {
const { dict } = await import("@opencode-ai/app/i18n/es")
return dict
}
if (locale === "vi") {
const { dict } = await import("@opencode-ai/app/i18n/vi")
return dict
}
return {}
}
28 changes: 28 additions & 0 deletions packages/desktop/src/main/locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"

import { pickLocale } from "./locale"

describe("pickLocale", () => {
// The shape the app actually persists (checked against a real
// codev.global.dat): the language record, JSON-stringified.
test("reads the locale out of the stored language record", () => {
expect(pickLocale('{"locale":"vi","direction":"ltr"}', "en-US")).toBe("vi")
expect(pickLocale('{ "locale" : "es" }', "en-US")).toBe("es")
})

test("accepts a bare locale and an already-parsed record", () => {
expect(pickLocale("vi", "en-US")).toBe("vi")
expect(pickLocale({ locale: "es" }, "en-US")).toBe("es")
})

test("falls back to the system locale when nothing was chosen", () => {
expect(pickLocale(undefined, "vi-VN")).toBe("vi")
expect(pickLocale(null, "es-ES")).toBe("es")
expect(pickLocale(undefined, "fr-FR")).toBe("en")
})

test("ignores a locale the menu has no dictionary for", () => {
expect(pickLocale('{"locale":"de"}', "vi-VN")).toBe("vi")
expect(pickLocale("th", "fr-FR")).toBe("en")
})
})
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
// Fork: the locale the macOS menu bar builds in, picked from the record the
// renderer persists into the global store. Kept apart from menu-i18n.ts so it
// Fork: the locale the main process renders in — the menu bar and the updater
// dialogs, which are built here rather than in the renderer. Picked from the
// record the app persists into the global store. Kept apart from i18n.ts so it
// stays importable — and testable — outside the Electron runtime.

export const MENU_LOCALES = ["en", "es", "vi"] as const
export const LOCALES = ["en", "es", "vi"] as const

export type MenuLocale = (typeof MENU_LOCALES)[number]
export type Locale = (typeof LOCALES)[number]

// `stored` is whatever the store holds under `language`: the app writes the
// language record JSON-stringified, so matching the locale out of the text
// avoids a parse that would have to be guarded (renderer/index.tsx reads the
// same record the same way). A bare locale string and an already-parsed record
// both still resolve. `system` is the fallback when nothing was ever chosen —
// the main-process equivalent of the renderer detecting navigator.languages.
export function pickMenuLocale(stored: unknown, system: string): MenuLocale {
export function pickLocale(stored: unknown, system: string): Locale {
const value =
typeof stored === "string"
? (stored.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] ?? stored)
: (stored as { locale?: unknown } | null | undefined)?.locale
const selected = MENU_LOCALES.find((locale) => locale === value)
const selected = LOCALES.find((locale) => locale === value)
if (selected) return selected
return MENU_LOCALES.find((locale) => system.toLowerCase().startsWith(locale)) ?? "en"
return LOCALES.find((locale) => system.toLowerCase().startsWith(locale)) ?? "en"
}
26 changes: 0 additions & 26 deletions packages/desktop/src/main/menu-i18n.ts

This file was deleted.

28 changes: 0 additions & 28 deletions packages/desktop/src/main/menu-locale.test.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/desktop/src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { menuTranslator } from "./menu-i18n"
import { createTranslator } from "./i18n"

type Deps = {
trigger: (id: string) => void
Expand All @@ -28,7 +28,7 @@ export async function createMenu(deps: Deps) {
if (process.platform !== "darwin") return
current = deps

const t = await menuTranslator()
const t = await createTranslator()
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
if (menu.role) return { role: nativeRole(menu.role) }
return {
Expand Down
48 changes: 43 additions & 5 deletions packages/desktop/src/main/updater.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
import { createTranslator } from "./i18n"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
Expand Down Expand Up @@ -83,25 +84,62 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
})
}

// Fork: every dialog names the version the user is on, and the one they would
// move to — the whole question "Check for Updates..." is asked to answer, which
// a bare "You're up to date." left the user to take on faith. Strings come from
// the app dictionaries (see i18n.ts): these run in the main process, so they
// have no renderer i18n of their own.
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
const state = await controller.check()
const t = await createTranslator()
const current = app.getVersion()

if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
await dialog.showMessageBox({
type: "error",
title: t("updater.dialog.error.title"),
message: t("updater.dialog.error.message"),
detail: t("updater.dialog.error.detail", { version: current }),
})
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",
title: t("updater.dialog.upToDate.title"),
message: t("updater.dialog.upToDate.message"),
detail: t("updater.dialog.upToDate.detail", { version: current }),
})
return
}
// Found but not fetched, which is where a check stops once automatic
// downloading is off. Silent until now: the one state that exists for the
// user to consent to the download had no dialog to consent in.
if (state.status === "available") {
const response = await dialog.showMessageBox({
type: "info",
title: t("updater.dialog.available.title"),
message: t("updater.dialog.available.message", { version: state.version }),
detail: t("updater.dialog.available.detail", { current }),
buttons: [t("updater.dialog.action.download"), t("updater.dialog.action.later")],
defaultId: 0,
cancelId: 1,
})
// Fire and forget: the renderer already surfaces the download and offers
// the restart once it lands.
if (response.response === 0) void controller.download()
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"],
title: t("updater.dialog.ready.title"),
message: t("updater.dialog.ready.message", { version: state.version }),
detail: t("updater.dialog.ready.detail", { current }),
buttons: [t("updater.dialog.action.restart"), t("updater.dialog.action.later")],
defaultId: 0,
cancelId: 1,
})
Expand Down
Loading