Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker af127a643b refactor(desktop): run local server from source (#42194) 2026-08-13 10:44:45 +10:00
11 changed files with 30 additions and 359 deletions
-1
View File
@@ -591,7 +591,6 @@
"react-dom": "^19.2.7",
},
"devDependencies": {
"@napi-rs/canvas": "1.0.2",
"@types/bun": "catalog:",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+4 -14
View File
@@ -1,7 +1,6 @@
import { $ } from "bun"
import { homedir } from "node:os"
import { join } from "node:path"
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
import { downloadCliToResources, windowsify } from "./utils"
type ServerSource = { type: "build" } | { type: "download"; version: string }
type DevOptions = { server: ServerSource; electron: string[] }
@@ -38,18 +37,9 @@ function selectOptions(): DevOptions {
}
async function prepareServer(source: ServerSource) {
const destination = windowsify("resources/opencode-cli-dev")
if (source.type === "download") return downloadCliToResources(source.version, destination)
return buildCliToResources(destination, developmentStateHome())
}
function developmentStateHome() {
const appData = (() => {
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
})()
return join(appData, "ai.opencode.desktop.dev")
if (source.type === "download")
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
}
async function startDesktop(args: string[]) {
-28
View File
@@ -86,34 +86,6 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
try {
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
{
...process.env,
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
},
)
if (stateHome && (await Bun.file(dest).exists())) {
const child = Bun.spawn([dest, "service", "stop"], {
env: { ...process.env, XDG_STATE_HOME: stateHome },
stdout: "inherit",
stderr: "inherit",
})
const exitCode = await child.exited
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
}
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
} finally {
await rm(directory, { recursive: true, force: true })
}
await prepareCli(dest)
console.log(`Built local CLI at ${dest}`)
}
async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
+22 -10
View File
@@ -17,29 +17,31 @@ type Logger = {
export async function startBackgroundCli(logger: Logger) {
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
const cli = development
? {
version: "local",
command: ["bun", "run", "--cwd", development, "dev", "--"],
binary: undefined,
}
: await resolveBundledCli(isolated, logger)
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [binary, "serve", "--service"],
version: cli.version,
command: [...cli.command, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version,
version: cli.version,
...endpoint(service.url),
})
if (isolated) await cleanCliStages(binary, logger)
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
return {
url: service.url,
username: service.auth.username,
@@ -47,6 +49,16 @@ export async function startBackgroundCli(logger: Logger) {
}
}
async function resolveBundledCli(isolated: boolean, logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
return { version, binary, command: [binary] }
}
async function cleanCliStages(binary: string, logger: Logger) {
const current = dirname(binary)
const root = dirname(current)
+1 -37
View File
@@ -1,21 +1,6 @@
import { describe, expect, test } from "bun:test"
import wrangler from "../wrangler.jsonc"
import { assetPath, metaTags } from "../worker"
const catalog = {
variants: [
{ id: "opencode", label: "Opencode" },
{ id: "tokyonight", label: "Tokyo Night" },
],
screens: [
{
id: "home",
title: "Home",
summary: "",
frames: [{ variantId: "opencode" }, { variantId: "tokyonight" }],
},
],
}
import { assetPath } from "../worker"
describe("catalog worker", () => {
test("serves the app shell for catalog routes", () => {
@@ -32,25 +17,4 @@ describe("catalog worker", () => {
test("leaves HTML routing to the worker", () => {
expect(wrangler.assets.html_handling).toBe("none")
})
test("injects a per-capture Open Graph card for deep links", () => {
const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home&set=tokyonight"), catalog)
expect(tags).toContain('content="Home — OpenCode Terminal Catalog"')
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--tokyonight.png"')
expect(tags).toContain("Tokyo Night theme")
expect(tags).toContain('name="twitter:card" content="summary_large_image"')
})
test("defaults the theme to the first variant", () => {
const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home"), catalog)
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--opencode.png"')
})
test("falls back to the default card for unknown or missing captures", () => {
for (const search of ["", "?screen=missing", "?screen=home&set=missing"]) {
const tags = metaTags(new URL(`https://dev.opencode.ai/lab/catalog${search}`), catalog)
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/default.png"')
expect(tags).toContain('content="OpenCode Terminal Catalog"')
}
})
})
+1 -2
View File
@@ -10,7 +10,7 @@
"dev": "bun ./server.ts",
"lint": "cd ../../.. && bun run lint -- packages/lab/catalog/src packages/lab/catalog/catalog packages/lab/catalog/scripts packages/lab/catalog/scenarios packages/lab/catalog/server.ts packages/lab/catalog/worker.ts",
"typecheck": "tsc --noEmit",
"build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/ && bun ./scripts/generate-og.ts",
"build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/",
"deploy": "bun run generate && bun run build && bunx wrangler deploy",
"doctor": "bunx -y react-doctor@latest .",
"bench:capture-flow": "bun ./scripts/bench-capture-flow.ts",
@@ -28,7 +28,6 @@
"react-dom": "^19.2.7"
},
"devDependencies": {
"@napi-rs/canvas": "1.0.2",
"@types/bun": "catalog:",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
-199
View File
@@ -1,199 +0,0 @@
/**
* Generates Open Graph cards for the catalog: one 1200x630 PNG per captured
* screen/theme pair plus a default card, written to dist/og. Runs after the
* production build so the images deploy as plain static assets.
*/
import { fileURLToPath } from "node:url"
import { GlobalFonts, createCanvas } from "@napi-rs/canvas"
import type { SKRSContext2D, Canvas } from "@napi-rs/canvas"
import {
CellHeight,
CellWidth,
DimAlpha,
FontSize,
StrikethroughOffset,
TextStyle,
UnderlineOffset,
baselineOffset,
drawBlockGlyph,
} from "opencode-drive/frame"
const CardWidth = 1200
const CardHeight = 630
const Pad = 56
const FontStack = `"OpenCode Mono", "OpenCode Symbols", "OpenCode Symbols 2", "OpenCode Math"`
const driveRoot = new URL("../../", Bun.pathToFileURL(Bun.resolveSync("opencode-drive/frame", import.meta.dir)).href)
for (const [file, family] of [
["commit-mono/CommitMono-400-Regular.otf", "OpenCode Mono"],
["commit-mono/CommitMono-700-Regular.otf", "OpenCode Mono"],
["commit-mono/CommitMono-400-Italic.otf", "OpenCode Mono"],
["commit-mono/CommitMono-700-Italic.otf", "OpenCode Mono"],
["noto/NotoSansSymbols.ttf", "OpenCode Symbols"],
["noto/NotoSansSymbols2-Regular.ttf", "OpenCode Symbols 2"],
["noto/NotoSansMath-Regular.ttf", "OpenCode Math"],
] as const) {
const path = fileURLToPath(new URL(`assets/fonts/${file}`, driveRoot))
if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register OG font: ${path}`)
}
interface FrameSpan {
readonly text: string
readonly fg: readonly [number, number, number, number]
readonly bg: readonly [number, number, number, number]
readonly attributes: number
readonly width: number
}
interface FrameArtifact {
readonly cols: number
readonly rows: number
readonly lines: ReadonlyArray<{ readonly spans: ReadonlyArray<FrameSpan> }>
}
function color([red, green, blue, alpha]: FrameSpan["fg"], opacity = 1) {
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
}
/** Mirrors the browser canvas renderer in src/components/TerminalFrame.tsx. */
function renderFrame(frame: FrameArtifact) {
const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
const context = canvas.getContext("2d")
context.fillStyle = "#080808"
context.fillRect(0, 0, canvas.width, canvas.height)
context.textBaseline = "alphabetic"
context.textAlign = "center"
frame.lines.forEach((line, row) => {
let column = 0
line.spans.forEach((span) => {
const attributes = span.attributes & 0xff
const inverse = Boolean(attributes & TextStyle.inverse)
const hidden = Boolean(attributes & TextStyle.invisible)
const foreground = inverse ? span.bg : span.fg
const background = inverse ? span.fg : span.bg
const chars = [...span.text]
let remaining = span.width
chars.forEach((char, index) => {
const cells = Math.max(1, remaining - (chars.length - index - 1))
const x = column * CellWidth
const y = row * CellHeight
if (background[3]) {
context.fillStyle = color(background)
context.fillRect(x, y, cells * CellWidth, CellHeight)
}
if (!hidden && char.codePointAt(0) !== 0x0a00) {
context.fillStyle = color(foreground, attributes & TextStyle.dim ? DimAlpha : 1)
if (!drawBlockGlyph(context, char, x, y, cells)) {
const font = `${attributes & TextStyle.italic ? "italic " : ""}${attributes & TextStyle.bold ? "700 " : "400 "}${FontSize}px ${FontStack}`
context.font = font
context.fillText(char, x + (cells * CellWidth) / 2, y + baselineOffset(context, font), cells * CellWidth)
}
if (attributes & TextStyle.underline) context.fillRect(x, y + UnderlineOffset, cells * CellWidth, 1)
if (attributes & TextStyle.strikethrough) context.fillRect(x, y + StrikethroughOffset, cells * CellWidth, 1)
}
column += cells
remaining -= cells
})
while (remaining-- > 0) {
if (background[3]) {
context.fillStyle = color(background)
context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight)
}
column++
}
})
})
return canvas
}
function drawChrome(context: SKRSContext2D, kicker: string, title: string, chip: string) {
context.fillStyle = "#0a0a0a"
context.fillRect(0, 0, CardWidth, CardHeight)
context.textAlign = "left"
context.textBaseline = "alphabetic"
context.font = `700 20px ${FontStack}`
context.fillStyle = "#6f6e69"
context.fillText(kicker.toUpperCase(), Pad, Pad + 16)
context.font = `700 46px ${FontStack}`
context.fillStyle = "#ededed"
context.fillText(title, Pad - 2, Pad + 74, CardWidth - Pad * 2 - 220)
if (chip !== "") {
context.font = `400 20px ${FontStack}`
const width = context.measureText(chip).width + 36
const x = CardWidth - Pad - width
context.strokeStyle = "#2c2c2c"
context.lineWidth = 1
context.beginPath()
context.roundRect(x, Pad + 38, width, 40, 20)
context.stroke()
context.fillStyle = "#9c9b96"
context.fillText(chip, x + 18, Pad + 65)
}
}
/** Frame peeks from the bottom edge like a window, cropped by the card. */
function drawFramePeek(context: SKRSContext2D, frame: Canvas) {
const top = 182
const width = CardWidth - Pad * 2
const scale = width / frame.width
const visible = CardHeight - top
const radius = 10
context.save()
context.beginPath()
context.roundRect(Pad, top, width, visible + radius, radius)
context.clip()
context.drawImage(frame, 0, 0, frame.width, Math.min(frame.height, (visible + radius) / scale), Pad, top, width, Math.min(frame.height * scale, visible + radius))
context.restore()
context.strokeStyle = "#2c2c2c"
context.lineWidth = 2
context.beginPath()
context.roundRect(Pad + 1, top + 1, width - 2, visible + radius, radius)
context.stroke()
}
const root = new URL("../", import.meta.url)
const outDir = new URL("dist/og/", root)
const catalog = await Bun.file(new URL("dist/catalog.json", root)).json()
const variants = catalog.variants as ReadonlyArray<{ id: string; label: string }>
const screens = catalog.screens as ReadonlyArray<{
id: string
title: string
frames: ReadonlyArray<{ variantId: string; src: string }>
}>
let generated = 0
for (const screen of screens) {
for (const frame of screen.frames) {
const variant = variants.find((candidate) => candidate.id === frame.variantId)
if (!variant) continue
const artifact = (await Bun.file(new URL(frame.src, new URL("dist/", root))).json()) as FrameArtifact
const card = createCanvas(CardWidth, CardHeight)
const context = card.getContext("2d")
drawChrome(context, "OpenCode · Terminal Catalog", screen.title, variant.label)
drawFramePeek(context, renderFrame(artifact))
await Bun.write(new URL(`${screen.id}--${variant.id}.png`, outDir), card.toBuffer("image/png"))
generated++
}
}
const home = screens.find((screen) => screen.id === "home")
const homeFrame = home?.frames.find((frame) => frame.variantId === variants[0]?.id) ?? home?.frames[0]
const card = createCanvas(CardWidth, CardHeight)
const context = card.getContext("2d")
drawChrome(context, "OpenCode", "Terminal Catalog", `${screens.length} screens · ${variants.length} themes`)
if (homeFrame) {
const artifact = (await Bun.file(new URL(homeFrame.src, new URL("dist/", root))).json()) as FrameArtifact
drawFramePeek(context, renderFrame(artifact))
}
await Bun.write(new URL("default.png", outDir), card.toBuffer("image/png"))
console.log(`generated ${generated + 1} Open Graph cards in dist/og`)
+2 -68
View File
@@ -2,43 +2,11 @@ interface Env {
readonly ASSETS: { fetch(request: Request): Promise<Response> }
}
interface CatalogIndex {
readonly variants: ReadonlyArray<{ readonly id: string; readonly label: string }>
readonly screens: ReadonlyArray<{
readonly id: string
readonly title: string
readonly summary: string
readonly frames: ReadonlyArray<{ readonly variantId: string }>
}>
}
let catalogCache: Promise<CatalogIndex> | undefined
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const path = assetPath(url.pathname)
const assetUrl = new URL(url)
assetUrl.pathname = path
const response = await env.ASSETS.fetch(new Request(assetUrl, request))
if (path !== "/index.html" || !response.ok) return response
catalogCache ??= env.ASSETS.fetch(new Request(new URL("/catalog.json", url.origin)))
.then((asset) => asset.json() as Promise<CatalogIndex>)
.catch((cause) => {
catalogCache = undefined
throw cause
})
const catalog = await catalogCache.catch(() => undefined)
if (!catalog) return response
const html = await response.text()
const headers = new Headers(response.headers)
headers.delete("content-length")
return new Response(html.replace("</head>", `${metaTags(url, catalog)}</head>`), {
status: response.status,
headers,
})
url.pathname = assetPath(url.pathname)
return env.ASSETS.fetch(new Request(url, request))
},
}
@@ -46,37 +14,3 @@ export function assetPath(pathname: string) {
const path = pathname.slice("/lab/catalog".length)
return path === "" || path === "/" || !path.includes(".") ? "/index.html" : path
}
export function metaTags(url: URL, catalog: CatalogIndex) {
const screen = catalog.screens.find((candidate) => candidate.id === url.searchParams.get("screen"))
const variantId = url.searchParams.get("set") ?? catalog.variants[0]?.id
const variant = catalog.variants.find((candidate) => candidate.id === variantId)
const captured = screen && variant && screen.frames.some((frame) => frame.variantId === variant.id)
const title = captured ? `${screen.title} — OpenCode Terminal Catalog` : "OpenCode Terminal Catalog"
const description = captured
? screen.summary || `The ${screen.title} state of the OpenCode TUI, captured live in the ${variant.label} theme.`
: "A visual catalog of OpenCode TUI states, captured from the live terminal."
const image = `${url.origin}/lab/catalog/og/${captured ? `${screen.id}--${variant.id}` : "default"}.png`
const tags: ReadonlyArray<readonly [string, string]> = [
["og:title", title],
["og:description", description],
["og:type", "website"],
["og:url", url.href],
["og:image", image],
["og:image:width", "1200"],
["og:image:height", "630"],
]
return [
...tags.map(([property, content]) => `<meta property="${property}" content="${escapeAttribute(content)}" />`),
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeAttribute(title)}" />`,
`<meta name="twitter:image" content="${escapeAttribute(image)}" />`,
`<meta name="description" content="${escapeAttribute(description)}" />`,
].join("\n ")
}
function escapeAttribute(value: string) {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")
}