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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **The desktop app now identifies itself as CoDev to your operating system** —
its application id changed from `ai.opencode.desktop` to `ai.codev.desktop`,
its deep links from `opencode://` to `codev://`, and its Linux packages from
`opencode` to `codev`. **This is a clean break with no migration**: the app
stores everything under a directory named after its id, so settings, session
history, the server list, sandboxes and the downloaded sandbox image do not
carry over — the old directory is left untouched under `ai.opencode.desktop*`
in your application data folder and can be deleted by hand. Any existing
`opencode://` link stops working, and an installed build will not update
across the rename: install the new one and remove the old.
- **A sandbox's project is named after its folder** — inside a sandbox your
folder is mounted at a fixed path, so every sandbox showed the same
meaningless **workspace** label in the sidebar, tabs and command palette.
Expand Down
11 changes: 7 additions & 4 deletions DIVERGENCES.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("buildRequestParts", () => {
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.codev.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
Expand All @@ -96,7 +96,7 @@ describe("buildRequestParts", () => {
})

expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
"C:\\Users\\Luke\\AppData\\Roaming\\ai.codev.desktop.beta\\opencode.global.dat",
)
})

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/pages/layout/deep-links.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const deepLinkEvent = "codev:deep-link"

const parseUrl = (input: string) => {
if (!input.startsWith("opencode://")) return
if (!input.startsWith("codev://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
try {
return new URL(input)
Expand Down
38 changes: 19 additions & 19 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,74 +38,74 @@ const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>

describe("layout deep links", () => {
test("parses open-project deep links", () => {
expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
expect(parseDeepLink("codev://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
})

test("ignores non-project deep links", () => {
expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined()
expect(parseDeepLink("codev://other?directory=/tmp/demo")).toBeUndefined()
expect(parseDeepLink("https://example.com")).toBeUndefined()
})

test("ignores malformed deep links safely", () => {
expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow()
expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined()
expect(() => parseDeepLink("codev://open-project/%E0%A4%A%")).not.toThrow()
expect(parseDeepLink("codev://open-project/%E0%A4%A%")).toBeUndefined()
})

test("parses links when URL.canParse is unavailable", () => {
const original = Object.getOwnPropertyDescriptor(URL, "canParse")
Object.defineProperty(URL, "canParse", { configurable: true, value: undefined })
try {
expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
expect(parseDeepLink("codev://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
} finally {
if (original) Object.defineProperty(URL, "canParse", original)
if (!original) Reflect.deleteProperty(URL, "canParse")
}
})

test("ignores open-project deep links without directory", () => {
expect(parseDeepLink("opencode://open-project")).toBeUndefined()
expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined()
expect(parseDeepLink("codev://open-project")).toBeUndefined()
expect(parseDeepLink("codev://open-project?directory=")).toBeUndefined()
})

test("collects only valid open-project directories", () => {
const result = collectOpenProjectDeepLinks([
"opencode://open-project?directory=/a",
"opencode://other?directory=/b",
"opencode://open-project?directory=/c",
"codev://open-project?directory=/a",
"codev://other?directory=/b",
"codev://open-project?directory=/c",
])
expect(result).toEqual(["/a", "/c"])
})

test("parses new-session deep links with optional prompt", () => {
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
expect(parseNewSessionDeepLink("codev://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
expect(parseNewSessionDeepLink("codev://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
directory: "/tmp/demo",
prompt: "hello world",
})
})

test("ignores new-session deep links without directory", () => {
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
expect(parseNewSessionDeepLink("codev://new-session")).toBeUndefined()
expect(parseNewSessionDeepLink("codev://new-session?directory=")).toBeUndefined()
})

test("collects only valid new-session deep links", () => {
const result = collectNewSessionDeepLinks([
"opencode://new-session?directory=/a",
"opencode://open-project?directory=/b",
"opencode://new-session?directory=/c&prompt=ship%20it",
"codev://new-session?directory=/a",
"codev://open-project?directory=/b",
"codev://new-session?directory=/c&prompt=ship%20it",
])
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
})

test("drains global deep links once", () => {
const target = {
__CODEV__: {
deepLinks: ["opencode://open-project?directory=/a"],
deepLinks: ["codev://open-project?directory=/a"],
},
} as unknown as Window & { __CODEV__?: { deepLinks?: string[] } }

expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"])
expect(drainPendingDeepLinks(target)).toEqual(["codev://open-project?directory=/a"])
expect(drainPendingDeepLinks(target)).toEqual([])
})
})
Expand Down
5 changes: 4 additions & 1 deletion packages/codev-brand/test/brand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ describe("rebrand", () => {
'import type { Plugin } from "@opencode-ai/plugin"',
'"$schema": "https://opencode.ai/config.json"',
'"opencode-gemini-auth", "opencode-foo@1.2.3", ["opencode-bar", { "option": "value" }]',
"the opencode:// deep link and the ai.opencode.desktop appId",
// The desktop appId and deep-link scheme used to be preserved here. They
// are now ai.codev.desktop and codev://, so upstream doc text mentioning
// opencode:// is stale rather than deliberately kept — rebrand still
// leaves it alone, which doc sync may want to revisit.
"state lives in opencode.db",
]
for (const line of preserved) expect(rebrand(line)).toBe(line)
Expand Down
2 changes: 1 addition & 1 deletion packages/codev-sandbox/src/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SANDBOX_SUN_PATH_MAX, sandboxSocketDir } from "./socket"
// The failure this guards against is silent: an over-long AF_UNIX path is
// truncated at bind time, so the socket appears where nobody is listening.
test("socket paths stay inside the AF_UNIX limit for realistic sandbox names", () => {
const appData = "/Users/someone/Library/Application Support/ai.opencode.desktop.dev/sandboxes"
const appData = "/Users/someone/Library/Application Support/ai.codev.desktop.dev/sandboxes"
for (const name of ["a", "codev-work", "a-rather-long-sandbox-name-someone-might-type"]) {
for (const file of ["server.sock", "broker.sock"]) {
const path = join(sandboxSocketDir(join(appData, name)), file)
Expand Down
47 changes: 23 additions & 24 deletions packages/desktop/electron-builder.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ 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"

const channels = [
{ channel: "dev", appId: "ai.opencode.desktop.dev" },
{ channel: "beta", appId: "ai.opencode.desktop.beta" },
{ channel: "prod", appId: "ai.opencode.desktop" },
{ channel: "dev", appId: "ai.codev.desktop.dev", packageName: "codev-dev" },
{ channel: "beta", appId: "ai.codev.desktop.beta", packageName: "codev-beta" },
{ channel: "prod", appId: "ai.codev.desktop", packageName: "codev" },
] as const

for (const channel of channels) {
Expand All @@ -26,6 +24,26 @@ for (const channel of channels) {
expect(config.extraMetadata?.desktopName).toBe(`${channel.appId}.desktop`)
expect(config.linux?.executableName).toBe(channel.appId)
expect(config.linux?.desktop?.entry?.StartupWMClass).toBe(channel.appId)
expect(config.rpm?.packageName).toBe(channel.packageName)
})
}

// The scheme the OS hands back to us on a deep link. It is registered here and
// consumed by app.setAsDefaultProtocolClient plus parseUrl in the renderer's
// deep-links module: all three have to agree or the link opens nothing.
for (const channel of channels) {
test(`registers the codev deep-link scheme for ${channel.channel}`, async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = channel.channel

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

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

const protocols = config.protocols as { schemes: string[] }
expect(protocols.schemes).toEqual(["codev"])
})
}

Expand Down Expand Up @@ -145,22 +163,3 @@ test("ships and re-signs the codev-vmm helper on macOS", async () => {
expect(typeof config.afterSign).toBe("function")
})

test("keeps a hidden prod launcher for old Linux pins", async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = "prod"

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

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

expect(config.deb?.fpm?.[0]).toEndWith(`${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`)
expect(config.rpm?.fpm?.[0]).toEndWith(`${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`)

const desktop = await Bun.file(legacyDesktopEntry).text()
expect(desktop).toContain("Exec=/opt/OpenCode/ai.opencode.desktop %U")
expect(desktop).toContain("Icon=ai.opencode.desktop")
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
expect(desktop).toContain("NoDisplay=true")
})
28 changes: 11 additions & 17 deletions packages/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@ const execFileAsync = promisify(execFile)
const packageDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(packageDir, "../..")
const signScript = path.join(rootDir, "script", "sign-windows.ps1")
// The Electron 42 packaging update briefly installed Linux launchers/icons under
// "opencode-desktop". Keep that hidden desktop entry around so existing GNOME/KDE
// pins still resolve after the canonical app id changes back to ai.opencode.desktop.
const legacyDesktopEntry = path.join(packageDir, "resources", "linux", "opencode-desktop.desktop")
const legacyDesktopEntryFpm = `${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`

async function signWindows(configuration: { path: string }) {
if (process.platform !== "win32") return
Expand Down Expand Up @@ -67,9 +62,9 @@ const channel = (() => {
})()

const APP_IDS = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
dev: "ai.codev.desktop.dev",
beta: "ai.codev.desktop.beta",
prod: "ai.codev.desktop",
} as const

// Base64 (like packages/codev-brand/src/index.ts) to keep the host out of
Expand Down Expand Up @@ -102,8 +97,8 @@ const getBase = (appId: string): Configuration => ({
buildResources: "resources",
},
// Linux launchers are .desktop files, so this is the desktop file name,
// not just the app id. For prod, app id "ai.opencode.desktop" becomes
// "ai.opencode.desktop.desktop".
// not just the app id. For prod, app id "ai.codev.desktop" becomes
// "ai.codev.desktop.desktop".
// https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html
// https://www.electron.build/docs/linux/
extraMetadata: {
Expand Down Expand Up @@ -162,7 +157,7 @@ const getBase = (appId: string): Configuration => ({
},
protocols: {
name: "CoDev Code",
schemes: ["opencode"],
schemes: ["codev"],
},
win: {
icon: `resources/icons/icon.ico`,
Expand Down Expand Up @@ -203,32 +198,31 @@ function getConfig() {
...base,
appId,
productName: "CoDev Code Dev",
rpm: { packageName: "opencode-dev" },
rpm: { packageName: "codev-dev" },
}
}
case "beta": {
return {
...base,
appId,
productName: "CoDev Code Beta",
protocols: { name: "CoDev Code Beta", schemes: ["opencode"] },
protocols: { name: "CoDev Code Beta", schemes: ["codev"] },
// 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" },
rpm: { packageName: "codev-beta" },
}
}
case "prod": {
return {
...base,
appId,
productName: "CoDev Code",
protocols: { name: "CoDev Code", schemes: ["opencode"] },
protocols: { name: "CoDev Code", schemes: ["codev"] },
publish,
deb: { fpm: [legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [legacyDesktopEntryFpm] },
rpm: { packageName: "codev" },
}
}
}
Expand Down
10 changes: 0 additions & 10 deletions packages/desktop/resources/linux/opencode-desktop.desktop

This file was deleted.

2 changes: 1 addition & 1 deletion packages/desktop/scripts/copy-metainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolveChannel } from "./utils"
const arg = process.argv[2]
const channel = arg === "dev" || arg === "beta" || arg === "prod" ? arg : resolveChannel()

const appId = channel === "prod" ? "ai.opencode.desktop" : `ai.opencode.desktop.${channel}`
const appId = channel === "prod" ? "ai.codev.desktop" : `ai.codev.desktop.${channel}`
const productName =
channel === "prod" ? "CoDev Code" : `CoDev Code ${channel.charAt(0).toUpperCase() + channel.slice(1)}`
const summary = `Open source AI coding agent${channel !== "prod" ? ` (${channel})` : ""}`
Expand Down
12 changes: 6 additions & 6 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ const APP_NAMES: Record<string, string> = {
prod: "CoDev Code",
}
const APP_IDS: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
dev: "ai.codev.desktop.dev",
beta: "ai.codev.desktop.beta",
prod: "ai.codev.desktop",
}
const TEST_ONBOARDING = process.env.CODEV_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
Expand Down Expand Up @@ -122,7 +122,7 @@ const main = Effect.gen(function* () {

process.env.CODEV_DISABLE_EMBEDDED_WEB_UI = "true"

const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.codev.desktop.dev"
const onboardingTestRoot = ((): string | undefined => {
if (!TEST_ONBOARDING) return

Expand Down Expand Up @@ -208,7 +208,7 @@ const main = Effect.gen(function* () {
preferAppEnv(app.getPath("userData"))

app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
const urls = argv.filter((arg: string) => arg.startsWith("codev://"))
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
Expand Down Expand Up @@ -273,7 +273,7 @@ const main = Effect.gen(function* () {
}),
),
)
app.setAsDefaultProtocolClient("opencode")
app.setAsDefaultProtocolClient("codev")
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(stopSidecars)
Expand Down
5 changes: 4 additions & 1 deletion packages/desktop/src/main/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ function tauriDir(id: string) {
}
}

// The Tauri app identifier changes between dev/beta/prod builds.
// The Tauri app identifier changes between dev/beta/prod builds. These name the
// directories the *old Tauri app* wrote, so they keep the ai.opencode.desktop
// ids even though the Electron app is now ai.codev.desktop — renaming them here
// would point the migration at a directory that never existed.
const TAURI_APP_IDS: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const cache = new Map<string, Store>()
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.codev.desktop.dev\opencode.settings).
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
Expand Down
Loading