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
7 changes: 5 additions & 2 deletions DIVERGENCES.md

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions packages/app/src/components/windows-app-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"

import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import {
DESKTOP_MENU,
desktopMenuItemDisabled,
desktopMenuVisible,
type DesktopMenuAction,
type DesktopMenuEntry,
} from "@/desktop-menu"
import { usePlatform } from "@/context/platform"

export function WindowsAppMenu(props: {
Expand Down Expand Up @@ -89,7 +95,10 @@ export function WindowsAppMenu(props: {
<DesktopMenuItem
label={entry.label ?? ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
disabled={desktopMenuItemDisabled(entry, {
updaterStatus: props.platform.updater?.state().status,
commandDisabled,
})}
onSelect={() => runEntry(entry)}
/>
),
Expand Down
59 changes: 58 additions & 1 deletion packages/app/src/desktop-menu.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
import { DESKTOP_MENU, desktopMenuItemDisabled, desktopMenuVisible, type DesktopMenuItem } from "./desktop-menu"

describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
Expand All @@ -10,4 +10,61 @@ describe("desktop menu", () => {
expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
})

test("offers an update check on every platform exactly once", () => {
for (const platform of ["macos", "windows"] as const) {
const items = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, platform)).flatMap((menu) =>
(menu.items ?? [])
.filter((item) => desktopMenuVisible(item, platform))
.filter((item) => item.type === "item" && item.action === "app.checkForUpdates"),
)

expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({ enabled: "updater" })
}
})

describe("item disabled state", () => {
const updateCheck = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).find(
(item): item is DesktopMenuItem => item.type === "item" && item.action === "app.checkForUpdates",
)!
const never = () => false

test("greys out the update check while the updater reports itself disabled", () => {
expect(desktopMenuItemDisabled(updateCheck, { updaterStatus: "disabled", commandDisabled: never })).toBe(true)
})

test("greys out the update check when there is no updater at all", () => {
expect(desktopMenuItemDisabled(updateCheck, { commandDisabled: never })).toBe(true)
})

for (const status of ["idle", "checking", "downloading", "ready", "up-to-date", "installing", "error"] as const) {
test(`enables the update check while the updater is ${status}`, () => {
expect(desktopMenuItemDisabled(updateCheck, { updaterStatus: status, commandDisabled: never })).toBe(false)
})
}

test("defers to the command registry for command entries", () => {
const entry = { type: "item", label: "Export Logs...", command: "logs.export" } as const

expect(desktopMenuItemDisabled(entry, { commandDisabled: (id) => id === "logs.export" })).toBe(true)
expect(desktopMenuItemDisabled(entry, { commandDisabled: never })).toBe(false)
})

test("leaves link and role entries alone", () => {
expect(
desktopMenuItemDisabled({ type: "item", label: "Report a Bug", href: "https://example.test" }, {
commandDisabled: never,
}),
).toBe(false)
expect(desktopMenuItemDisabled({ type: "item", role: "quit" }, { commandDisabled: never })).toBe(false)
})
})

test("leaves the macOS help menu without a trailing separator", () => {
const help = DESKTOP_MENU.find((menu) => menu.id === "help")!
const visible = help.items!.filter((item) => desktopMenuVisible(item, "macos"))

expect(visible.at(-1)?.type).toBe("item")
})
})
27 changes: 27 additions & 0 deletions packages/app/src/desktop-menu.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DOCS_URL } from "@codev/brand"
import type { UpdaterState } from "./updater"

export type DesktopMenuPlatform = "macos" | "windows"

Expand Down Expand Up @@ -215,10 +216,36 @@ export const DESKTOP_MENU: DesktopMenu[] = [
label: "Report a Bug",
href: "https://github.com/quickbeard/codev-code/issues/new?template=bug-report.yml",
},
// macOS has this in the app menu, which the Windows/Linux menu does not
// render. Both the item and its separator are windows-only so macOS does
// not get a duplicate entry and a trailing separator.
{ type: "separator", platforms: ["windows"] },
{
type: "item",
label: "Check for Updates...",
action: "app.checkForUpdates",
enabled: "updater",
platforms: ["windows"],
},
],
},
]

export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}

/**
* Whether a menu item renders greyed out. The native macOS menu asks the main
* process for `UPDATER_ENABLED` directly; in the renderer the equivalent signal
* is the updater reporting itself disabled, which it does for unpackaged and
* dev-channel builds — and for a missing updater, as on the web.
*/
export function desktopMenuItemDisabled(
item: DesktopMenuItem,
input: { updaterStatus?: UpdaterState["status"]; commandDisabled: (id: string) => boolean },
) {
if (item.enabled === "updater") return (input.updaterStatus ?? "disabled") === "disabled"
if (item.command) return input.commandDisabled(item.command)
return false
}
73 changes: 73 additions & 0 deletions packages/desktop/electron-builder.config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import type { Configuration } from "electron-builder"
import type { GenericServerOptions } from "builder-util-runtime"
import { GenericProvider } from "electron-updater/out/providers/GenericProvider"

const legacyDesktopEntry = "resources/linux/opencode-desktop.desktop"

Expand Down Expand Up @@ -27,6 +29,77 @@ for (const channel of channels) {
})
}

for (const channel of ["beta", "prod"] as const) {
test(`points ${channel} at the bucket update feed`, async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = channel

const module = await import(`./electron-builder.config.ts?publish=${channel}`)
const config = module.default as Configuration

if (previous === undefined) delete process.env.CODEV_CHANNEL
else process.env.CODEV_CHANNEL = previous

const publish = config.publish as GenericServerOptions
expect(publish.provider).toBe("generic")
expect(publish.url).toStartWith("https://")
// Without an explicit channel, detectUpdateChannel derives one from the
// version's first prerelease component ("8" for 1.18.4-8) and names the
// metadata 8.yml, which no client ever requests.
expect(publish.channel).toBe("latest")
// MinIO answers a multipart range request with the whole file.
expect(publish.useMultipleRangeRequest).toBe(false)
})
}

// The bucket prefix has no trailing slash, and `new URL(name, base)` would drop
// its last segment without one — every artifact would resolve a directory too
// high and 404. electron-updater normalises that in newBaseUrl, which is
// internal, so drive the real provider instead of restating the rule.
test("resolves artifacts against the bucket prefix, not its parent", async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = "prod"

const module = await import("./electron-builder.config.ts?feed=prod")
const config = module.default as Configuration

if (previous === undefined) delete process.env.CODEV_CHANNEL
else process.env.CODEV_CHANNEL = previous

// resolveFiles touches neither the updater nor the executor; it only reads the
// base URL the constructor derived from our publish config.
const provider = new GenericProvider(config.publish as GenericServerOptions, undefined as never, {
platform: "darwin",
isUseMultipleRangeRequest: false,
executor: undefined as never,
})

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",
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",
)
})

test("leaves dev builds without an update feed", async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = "dev"

const module = await import("./electron-builder.config.ts?publish=dev")
const config = module.default as Configuration

if (previous === undefined) delete process.env.CODEV_CHANNEL
else process.env.CODEV_CHANNEL = previous

expect(config.publish).toBeUndefined()
})

test("keeps a hidden prod launcher for old Linux pins", async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = "prod"
Expand Down
31 changes: 29 additions & 2 deletions packages/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ const APP_IDS = {
prod: "ai.opencode.desktop",
} as const

// 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")

// 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
// story on the build side.
const publish = {
provider: "generic",
url: process.env.CODEV_UPDATE_URL ?? UPDATE_URL,
// Required. Without it `detectUpdateChannel` derives the channel from the
// first semver prerelease component of the app version — "8" for 1.18.4-8 —
// and electron-builder writes 8.yml/8-mac.yml/8-linux.yml while the app asks
// for latest.yml (autoUpdater.channel in src/main/updater.ts).
channel: "latest",
// MinIO does not serve multipart range requests, which breaks differential
// downloads. electron-updater hardcodes the same false for its s3 provider
// (electron-updater/out/providerFactory.js) citing minio/minio#5285, but
// defaults it to true for generic URLs.
useMultipleRangeRequest: false,
} as const

const getBase = (appId: string): Configuration => ({
artifactName: "codev-code-desktop-${os}-${arch}.${ext}",
directories: {
Expand Down Expand Up @@ -135,7 +158,11 @@ function getConfig() {
appId,
productName: "CoDev Code Beta",
protocols: { name: "CoDev Code Beta", schemes: ["opencode"] },
publish: { provider: "github", owner: "quickbeard", repo: "codev-code", channel: "latest" },
// The fork ships no beta installers, but UPDATER_ENABLED covers this
// channel, and a build with no publish config gets no app-update.yml at
// all — every check would fail with ENOENT. Sharing prod's feed keeps
// the unused channel functional rather than broken.
publish,
rpm: { packageName: "opencode-beta" },
}
}
Expand All @@ -145,7 +172,7 @@ function getConfig() {
appId,
productName: "CoDev Code",
protocols: { name: "CoDev Code", schemes: ["opencode"] },
publish: { provider: "github", owner: "quickbeard", repo: "codev-code", channel: "latest" },
publish,
deb: { fpm: [legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [legacyDesktopEntryFpm] },
}
Expand Down
Loading
Loading