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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ file is the human-readable summary of what changed and why.
tarball, packed in by accident whenever a release was retried. Removing it
takes each platform package from ~112MB back to ~55MB.

### Updates

- **You can now decide when CoDev updates itself** — it checked for updates at
launch and every ten minutes after, and downloaded whatever it found straight
away, with no way to say otherwise. Settings → General → Updates now has two
switches: **Check for updates automatically** and **Download updates
automatically**. Turning downloading off stops a check at the discovery and
asks first — the installer is several hundred megabytes, which is not a
download to start on its own over a metered connection. **Check for updates**
still works with both switches off; it is the one check that was always yours
to ask for. There was a translated "Check for updates on startup" setting in
the app that no screen ever showed; these two replace it.
- **The update feed moved** — it is now `codev-storage/codev` rather than
`codev-storage/codev-code`, and each release's installers sit in their own
`<version>` folder beneath it instead of overwriting the previous release.
Builds already installed keep updating: the new location ships in the next
build's `app-update.yml`, so the change reaches a machine with the update
that carries it.

### Sandbox

- **CoDev installs WSL for you when a sandbox needs it** — a sandbox on Windows
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,40 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>

<Show when={platform.updater}>
{(updaterPlatform) => (
<>
<SettingsRow
title={language.t("settings.updates.row.autoCheck.title")}
description={language.t("settings.updates.row.autoCheck.description")}
>
<div data-action="settings-updates-auto-check">
<Switch
checked={updaterPlatform().prefs().autoCheck}
onChange={(checked) => void updaterPlatform().setPrefs({ autoCheck: checked })}
/>
</div>
</SettingsRow>
{/* Nested under the check toggle in meaning, not in markup: with
checking off nothing finds an update to download, so the
control is shown disabled rather than hidden — hiding it would
read as the setting having been lost. */}
<SettingsRow
title={language.t("settings.updates.row.autoDownload.title")}
description={language.t("settings.updates.row.autoDownload.description")}
>
<div data-action="settings-updates-auto-download">
<Switch
checked={updaterPlatform().prefs().autoDownload}
disabled={!updaterPlatform().prefs().autoCheck}
onChange={(checked) => void updaterPlatform().setPrefs({ autoDownload: checked })}
/>
</div>
</SettingsRow>
</>
)}
</Show>

<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/components/updater-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ describe("updaterAction", () => {
run: "check",
})
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
// Found but not fetched: the action is the download the user has to
// consent to, not an install of something that is not on disk.
expect(updaterAction({ status: "available", version: "2.0.0" })).toEqual({
label: "settings.updates.action.download",
run: "download",
})
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
label: "settings.updates.action.downloading",
})
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/updater-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export function updaterAction(state: UpdaterState | undefined) {
switch (state.status) {
case "checking":
return { label: "settings.updates.action.checking" as const }
// Found but not fetched, because automatic downloading is off. The
// download is the step the user is being asked to consent to.
case "available":
return { label: "settings.updates.action.download" as const, run: "download" as const }
case "downloading":
return { label: "settings.updates.action.downloading" as const }
case "ready":
Expand All @@ -32,6 +36,7 @@ export function useUpdaterAction() {
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
if (run === "download") return void platform.updater?.download()
if (run !== "check") return

const state = await platform.updater?.check()
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ export const dict = {

"toast.update.title": "Update available",
"toast.update.description": "A new version of CoDev ({{version}}) is now available to install.",
"toast.update.description.available": "A new version of CoDev ({{version}}) is available to download.",
"toast.update.action.installRestart": "Install and restart",
"toast.update.action.notYet": "Not yet",

Expand Down Expand Up @@ -1058,8 +1059,13 @@ export const dict = {
"settings.general.row.releaseNotes.title": "Release notes",
"settings.general.row.releaseNotes.description": "Show What's New popups after updates",

"settings.updates.row.startup.title": "Check for updates on startup",
"settings.updates.row.startup.description": "Automatically check for updates when CoDev launches",
"settings.updates.row.autoCheck.title": "Check for updates automatically",
"settings.updates.row.autoCheck.description":
"Look for updates when CoDev launches, and every ten minutes while it runs",
"settings.updates.row.autoDownload.title": "Download updates automatically",
"settings.updates.row.autoDownload.description":
"Fetch an update as soon as it is found. Turn this off to be asked first — the download is several hundred megabytes",
"settings.updates.action.download": "Download",
"settings.updates.row.check.title": "Check for updates",
"settings.updates.row.check.description": "Manually check for updates and install if available",
"settings.updates.action.checkNow": "Check now",
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ export const dict = {

"toast.update.title": "Actualización disponible",
"toast.update.description": "Una nueva versión de CoDev ({{version}}) está disponible para instalar.",
"toast.update.description.available": "Hay una nueva versión de CoDev ({{version}}) disponible para descargar.",
"toast.update.action.installRestart": "Instalar y reiniciar",
"toast.update.action.notYet": "Todavía no",

Expand Down Expand Up @@ -974,8 +975,13 @@ export const dict = {
"settings.general.row.releaseNotes.description":
'Mostrar ventanas emergentes de "Novedades" después de las actualizaciones',

"settings.updates.row.startup.title": "Buscar actualizaciones al iniciar",
"settings.updates.row.startup.description": "Buscar actualizaciones automáticamente cuando se inicia CoDev",
"settings.updates.row.autoCheck.title": "Buscar actualizaciones automáticamente",
"settings.updates.row.autoCheck.description":
"Buscar actualizaciones al iniciar CoDev y cada diez minutos mientras se ejecuta",
"settings.updates.row.autoDownload.title": "Descargar actualizaciones automáticamente",
"settings.updates.row.autoDownload.description":
"Descargar una actualización en cuanto se encuentre. Desactívalo para que te pregunten primero: la descarga ocupa varios cientos de megabytes",
"settings.updates.action.download": "Descargar",
"settings.updates.row.check.title": "Buscar actualizaciones",
"settings.updates.row.check.description": "Buscar actualizaciones manualmente e instalarlas si hay alguna",
"settings.updates.action.checkNow": "Buscar ahora",
Expand Down
36 changes: 31 additions & 5 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,16 @@ export default function LegacyLayout(props: ParentProps) {
if (state?.status !== "ready") return
return state.version
}
// Deliberately separate from updateVersion, which the titlebar pill reads:
// that pill installs, and an update that has not been downloaded cannot be
// installed. The toast is the surface that can offer either.
const availableVersion = () => {
const state = platform.updater?.state()
if (state?.status !== "available") return
return state.version
}
const installUpdate = () => void platform.updater?.install()
const downloadUpdate = () => void platform.updater?.download()
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
installing: () => platform.updater?.state().status === "installing",
Expand Down Expand Up @@ -2223,8 +2232,16 @@ export default function LegacyLayout(props: ParentProps) {
: undefined
}
/>
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
<Show when={updateVersion() ?? availableVersion()}>
{(version) => (
<UpdateAvailableToast
version={version()}
downloaded={updateVersion() !== undefined}
install={installUpdate}
download={downloadUpdate}
language={language}
/>
)}
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
Expand Down Expand Up @@ -2376,7 +2393,11 @@ export default function LegacyLayout(props: ParentProps) {

function UpdateAvailableToast(props: {
version: string
// Whether the installer is already on disk. Off, the toast offers to fetch
// it; the description says which of the two is being asked for.
downloaded: boolean
install: () => void
download: () => void
language: ReturnType<typeof useLanguage>
}) {
let toastId: number | undefined
Expand All @@ -2386,11 +2407,16 @@ function UpdateAvailableToast(props: {
persistent: true,
icon: "download",
title: props.language.t("toast.update.title"),
description: props.language.t("toast.update.description", { version: props.version }),
description: props.language.t(
props.downloaded ? "toast.update.description" : "toast.update.description.available",
{ version: props.version },
),
actions: [
{
label: props.language.t("toast.update.action.installRestart"),
onClick: props.install,
label: props.language.t(
props.downloaded ? "toast.update.action.installRestart" : "settings.updates.action.download",
),
onClick: props.downloaded ? props.install : props.download,
},
{
label: props.language.t("toast.update.action.notYet"),
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,35 @@ export type UpdaterState =
| { status: "disabled" }
| { status: "idle" }
| { status: "checking" }
// Found, not fetched: where a check stops when automatic downloading is off.
// The download is the expensive half, so this is the state that exists for
// the user to consent to it.
| { status: "available"; version: string }
| { status: "downloading"; version: string; percent?: number }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
| { status: "error"; message: string }

// Owned by the main process, which is where the update timer runs. The
// renderer only ever renders and edits them.
export type UpdaterPrefs = {
// Gates the check at launch and the periodic one after it. Never gates a
// check the user asked for: "Check now" works with this off.
autoCheck: boolean
// Whether finding an update also fetches it. Off stops the check at
// "available" and waits to be told.
autoDownload: boolean
}

// Both on: the behaviour before either setting existed.
export const UPDATER_PREFS_DEFAULT: UpdaterPrefs = { autoCheck: true, autoDownload: true }

export type UpdaterPlatform = {
state: Accessor<UpdaterState>
prefs: Accessor<UpdaterPrefs>
check(): Promise<UpdaterState>
download(): Promise<UpdaterState>
install(): Promise<void>
setPrefs(next: Partial<UpdaterPrefs>): Promise<void>
}
8 changes: 5 additions & 3 deletions packages/desktop/electron-builder.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,16 @@ test("resolves artifacts against the bucket prefix, not its parent", async () =>

const resolved = provider.resolveFiles({
version: "1.18.4-9",
files: [{ url: "codev-code-desktop-mac-universal.zip", sha512: "x", size: 1 }],
path: "codev-code-desktop-mac-universal.zip",
// Staged manifests name artifacts under their version (stage-release.ts);
// this pins the relative resolution the whole layout depends on.
files: [{ url: "1.18.4-8/codev-code-desktop-mac-universal.zip", sha512: "x", size: 1 }],
path: "1.18.4-8/codev-code-desktop-mac-universal.zip",
sha512: "x",
releaseDate: "2026-08-11T00:00:00.000Z",
})

expect(resolved[0]?.url.href).toBe(
"https://netmind.viettel.vn/codev-storage/codev-code/codev-code-desktop-mac-universal.zip",
"https://netmind.viettel.vn/codev-storage/codev/1.18.4-8/codev-code-desktop-mac-universal.zip",
)
})

Expand Down
8 changes: 7 additions & 1 deletion packages/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ const APP_IDS = {
// Base64 (like packages/codev-brand/src/index.ts) to keep the host out of
// casual greps, not as a secret — the decoded URL ships in every packaged
// build's app-update.yml.
const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldi1jb2Rl")
//
// Deliberately unversioned. This is the one URL an installed build ever reads
// (app-update.yml is baked at package time and nothing calls setFeedURL), so a
// version in it would leave that build looking only at its own release folder,
// unable to see any later one. The manifests sit here; the artifacts they name
// live under <version>/, which scripts/stage-release.ts arranges.
const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldg==")

// electron-builder bakes this into app-update.yml; the installers are uploaded
// to the bucket by hand, so `generic` (which has no publisher) is the whole
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"package": "electron-builder --config electron-builder.config.ts",
"stage-release": "bun ./scripts/stage-release.ts",
"package:mac": "electron-builder --mac --config electron-builder.config.ts",
"package:win": "electron-builder --win --config electron-builder.config.ts",
"package:linux": "electron-builder --linux --config electron-builder.config.ts",
Expand Down
100 changes: 100 additions & 0 deletions packages/desktop/scripts/stage-release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bun
// Lays a packaged build out the way the update bucket expects it.
// Usage: bun scripts/stage-release.ts [--dist dist] [--out dist/release]
//
// electron-builder names every artifact in latest*.yml by bare filename, which
// resolves against the feed's base URL — so out of the box a release can only
// live at that base, and each one overwrites the last. Prefixing those names
// with the version puts the artifacts in a per-version folder while the
// manifest stays at the base URL, which is the one path an installed build
// ever reads (its app-update.yml is baked at package time and nothing calls
// setFeedURL). electron-updater resolves the prefixed name with
// `new URL(name, base)`, so no client change is needed.
//
// Produces, for base https://…/codev:
// <out>/latest-mac.yml -> …/codev/latest-mac.yml
// <out>/<version>/<artifacts> -> …/codev/<version>/…
import { $ } from "bun"
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"
import { join } from "node:path"

const distDir = argValue("--dist") ?? join(import.meta.dir, "..", "dist")
const outDir = argValue("--out") ?? join(distDir, "release")

const manifests = (await readdir(distDir)).filter((name) => /^latest(-\w+)*\.yml$/.test(name))
if (!manifests.length) {
console.error(`no latest*.yml in ${distDir}`)
console.error("update manifests are only written for beta/prod builds: CODEV_CHANNEL=prod bun run package:mac")
process.exit(1)
}

for (const manifest of manifests) {
const original = await readFile(join(distDir, manifest), "utf8")
const version = /^version:\s*(\S+)\s*$/m.exec(original)?.[1]
if (!version) {
console.error(`${manifest} has no version line`)
process.exit(1)
}

// Only the lines that name a file. Rewriting is idempotent so a second run
// over an already-staged manifest is a no-op rather than a double prefix.
const named: string[] = []
const staged = original
.split("\n")
.map((line) => {
const match = /^(\s*-?\s*(?:url|path):\s*)(\S+)\s*$/.exec(line)
if (!match) return line
const [, prefix, name] = match
if (name!.startsWith(`${version}/`)) return line
named.push(name!)
return `${prefix}${version}/${name}`
})
.join("\n")
// A format change upstream must fail here rather than quietly publish a
// manifest whose files resolve to the wrong place.
if (!named.length) {
console.error(`${manifest}: no url/path entries were rewritten; check the electron-builder output format`)
process.exit(1)
}

await mkdir(join(outDir, version), { recursive: true })
await writeFile(join(outDir, manifest), staged)

// Blockmaps are not listed in the manifest — electron-updater derives their
// URL from the artifact's — so they have to travel with it or every update
// silently falls back to a full download.
for (const name of [...new Set(named)]) {
for (const file of [name, `${name}.blockmap`]) {
const source = join(distDir, file)
if (!(await stat(source).catch(() => null))?.isFile()) {
if (file.endsWith(".blockmap")) continue
console.error(`${manifest} names ${file}, which is not in ${distDir}`)
process.exit(1)
}
await copyFile(source, join(outDir, version, file))
}
}
console.log(`${manifest}: ${[...new Set(named)].length} artifact(s) staged under ${version}/`)
}

console.log(`\nstaged in ${outDir}`)
const target = process.env.CODEV_UPDATE_TARGET
const upload = (from: string, to: string) =>
target
? $`mc cp ${from} ${target}/${to}`
: Promise.resolve(console.log(` mc cp ${from} <alias>/<bucket>/codev/${to}`))

if (!target) console.log("\nCODEV_UPDATE_TARGET is unset; run these where the storage credentials live:")
// Artifacts first, manifests last: a client that reads the manifest must find
// every file it names already in place.
for (const manifest of manifests) {
const version = /^version:\s*(\S+)\s*$/m.exec(await readFile(join(outDir, manifest), "utf8"))![1]!
for (const file of await readdir(join(outDir, version)))
await upload(join(outDir, version, file), `${version}/${file}`)
}
for (const manifest of manifests) await upload(join(outDir, manifest), manifest)

function argValue(flag: string) {
const index = process.argv.indexOf(flag)
return index >= 0 ? process.argv[index + 1] : undefined
}
Loading
Loading