Files
opencode/packages/desktop/electron-builder.config.ts

278 lines
12 KiB
TypeScript

import { copyFile, readdir, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Arch, type Configuration } from "electron-builder"
import { NativeArch } from "../../script/native-arch"
import { DESKTOP_NATIVE_ENGLISH } from "@opencode/app/i18n/desktop-native"
import { CLI_BINARIES, getCurrentCli, nodePtyPackage } from "./script/utils"
const packageDir = path.dirname(fileURLToPath(import.meta.url))
// 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`
// rpmbuild adds /usr/lib/.build-id links for every ELF file. The bundled Electron
// binaries are byte-identical across apps on the same Electron version, so those
// links collide with other Electron RPMs (Slack, Unity Hub, ...) and rpm/zypper
// refuse to install or update. They only serve debuginfo lookup, so skip them.
// fpm only accepts flags before the first src=dest mapping, so keep this first.
const rpmNoBuildIdLinksFpm = "--rpm-rpmbuild-define=_build_id_links none"
const metainfoFpm = (appId: string) =>
`${path.join(packageDir, "resources", `${appId}.metainfo.xml`)}=/usr/share/metainfo/${appId}.metainfo.xml`
// Dev and beta package every commit, so they trade about 5% (deb) and 17% (rpm) larger files for xz -1, which
// compresses the payload about 3x faster than the deb default (-3) and 5x faster than the rpm default (-9).
const previewCompressionFpm = (target: "deb" | "rpm") => [`--${target}-compression-level`, "1"]
function resolveChannel() {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "latest") return "prod"
return "dev"
}
const APP_IDS = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
} as const
// libappimage (used by AppImageLauncher) resolves the .desktop entry inside an
// AppImage by globbing `*.desktop` at the squashfs root. If the main
// executable itself ends in `.desktop` (prod appId `ai.opencode.desktop`), the
// ELF binary matches the glob and is parsed as a desktop entry, so
// integration fails with `Malformed desktop entry: Unexpected char '\x7f' at 0`
// (0x7F is the first byte of the ELF magic).
// https://github.com/anomalyco/opencode/issues/51867
// Strip a trailing `.desktop` so the executable can never collide with the
// desktop-entry glob. Beta/dev appIds end in `.beta`/`.dev` and are unaffected.
function linuxExecutableName(appId: string): string {
return appId.replace(/\.desktop$/, "")
}
const getBase = (appId: string): Configuration => {
const target = getCurrentCli()
const desktopName = `${appId}.desktop`
return {
artifactName: "opencode-desktop-${os}-${arch}.${ext}",
directories: {
output: "dist",
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".
// https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html
// https://www.electron.build/docs/linux/
// Release builds take their version from OPENCODE_VERSION instead of rewriting package.json.
extraMetadata: process.env.OPENCODE_VERSION
? { desktopName, version: process.env.OPENCODE_VERSION }
: { desktopName },
files: [
"out/**/*",
"resources/**/*",
"!resources/opencode-cli*",
// Keep only the target platform's node-pty prebuild when cross-packaging with --cpu=*.
...CLI_BINARIES.filter((item) => item.os !== target.os || item.cpu !== target.cpu).map(
(item) => `!**/node_modules/@lydell/node-pty-${item.os}-${item.cpu}{,/**/*}`,
),
// Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker.
"!**/node_modules/@zip.js/zip.js/dist{,/**/*}",
"!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}",
// Nothing executes type declarations or source maps, and every entry costs startup time: the
// main process parses the whole asar header before it runs any JavaScript.
"!**/node_modules/**/*.d.{ts,cts,mts}",
"!**/node_modules/**/*.d.{ts,cts,mts}.map",
"!**/node_modules/**/*.{js,cjs,mjs}.map",
// These packages execute compiled JavaScript, not their sources.
"!**/node_modules/ajv/lib{,/**/*}",
"!**/node_modules/ajv-formats/src{,/**/*}",
// Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI.
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
"!**/node_modules/js-yaml/bin{,/**/*}",
],
extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
},
],
afterPack: async (context) => {
const arch = Arch[context.arch]
// The --arm64 flag picks Electron while OPENCODE_TARGET picks the CLI and node-pty, so they can disagree.
if (arch !== "x64" && arch !== "arm64") throw new Error(`Unsupported desktop architecture: ${arch}`)
if (arch !== target.cpu) throw new Error(`Packaging ${arch} Electron with the ${target.target} CLI`)
const resources = context.packager.getResourcesDir(context.appOutDir)
const cli = path.join(resources, context.electronPlatformName === "win32" ? "opencode-cli.exe" : "opencode-cli")
const file = await stat(cli)
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
await NativeArch.verify(cli, arch)
const version = path.join(path.dirname(cli), "opencode-cli.version")
if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`)
// With an Icon Composer mac.icon, electron-builder bundles the ICNS that actool generates as the pre-macOS 26
// fallback. That one tops out at 256px and draws a light ring around the squircle, so ship ours instead.
if (context.electronPlatformName === "darwin")
await copyFile(path.join(packageDir, "resources/icons/icon.icns"), path.join(resources, "icon.icns"))
if (context.electronPlatformName !== "win32") return
const prebuilds = path.join(
resources,
"app.asar.unpacked/node_modules",
nodePtyPackage(target.target),
"prebuilds",
`${target.os}-${target.cpu}`,
)
const addon = path.join(prebuilds, "conpty.node")
if ((await stat(addon)).size === 0) throw new Error(`Bundled node-pty must be a non-empty file: ${addon}`)
const entries = await readdir(prebuilds, { recursive: true, withFileTypes: true })
await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const file = path.join(entry.parentPath, entry.name)
if (await NativeArch.isNative(file)) await NativeArch.verify(file, arch)
}),
)
},
mac: {
category: "public.app-category.developer-tools",
icon: `resources/icons/icon.icns`,
extendInfo: {
NSMicrophoneUsageDescription: DESKTOP_NATIVE_ENGLISH["desktop.microphoneUsage"],
// The CLI server and its terminals run as children of the app, so macOS asks for their access to
// these protected locations in the app's name. The prompt shows the description (#56479).
NSDocumentsFolderUsageDescription: DESKTOP_NATIVE_ENGLISH["desktop.documentsFolderUsage"],
NSDesktopFolderUsageDescription: DESKTOP_NATIVE_ENGLISH["desktop.desktopFolderUsage"],
NSDownloadsFolderUsageDescription: DESKTOP_NATIVE_ENGLISH["desktop.downloadsFolderUsage"],
NSNetworkVolumesUsageDescription: DESKTOP_NATIVE_ENGLISH["desktop.networkVolumesUsage"],
NSRemovableVolumesUsageDescription: DESKTOP_NATIVE_ENGLISH["desktop.removableVolumesUsage"],
NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac: true,
},
target: ["dmg", "zip"],
},
protocols: {
name: "OpenCode",
schemes: ["opencode"],
},
win: {
icon: `resources/icons/icon.ico`,
target: ["nsis"],
verifyUpdateCodeSignature: false,
},
nsis: {
include: path.join(packageDir, "resources", "windows", "installer.nsh"),
oneClick: true,
perMachine: false,
installerIcon: `resources/icons/icon.ico`,
installerHeaderIcon: `resources/icons/icon.ico`,
},
linux: {
icon: `resources/icons`,
category: "Development",
executableName: linuxExecutableName(appId),
// Derive the installed `.desktop` filename from `desktopName`
// (`ai.opencode.desktop.desktop` for prod) instead of `executableName`,
// so window association is unaffected by the executable rename above.
syncDesktopName: true,
desktop: {
entry: {
// Match the installed .desktop file and hicolor icon basename so
// Linux shells can associate the running Electron window with its launcher.
StartupWMClass: appId,
},
},
target: ["AppImage", "deb", "rpm"],
},
}
}
export function getConfig() {
const channel = resolveChannel()
const appId = APP_IDS[channel]
const base = getBase(appId)
switch (channel) {
case "dev": {
return {
...base,
appId,
productName: "OpenCode Dev",
publish: {
provider: "generic",
url: "https://opencode.ai/update/api/dev/desktop/opencode/",
channel: "latest",
},
deb: { fpm: [...previewCompressionFpm("deb"), metainfoFpm(appId)] },
rpm: {
packageName: "opencode-dev",
fpm: [rpmNoBuildIdLinksFpm, ...previewCompressionFpm("rpm"), metainfoFpm(appId)],
},
}
}
case "beta": {
return {
...base,
appId,
productName: "OpenCode Beta",
protocols: { name: "OpenCode Beta", schemes: ["opencode"] },
publish: {
provider: "generic",
url: "https://opencode.ai/update/api/beta/desktop/opencode/",
channel: "latest",
},
deb: { fpm: [...previewCompressionFpm("deb"), metainfoFpm(appId)] },
rpm: {
packageName: "opencode-beta",
fpm: [rpmNoBuildIdLinksFpm, ...previewCompressionFpm("rpm"), metainfoFpm(appId)],
},
}
}
case "prod": {
return {
...base,
appId,
productName: "OpenCode",
protocols: { name: "OpenCode", schemes: ["opencode"] },
// An Icon Composer package compiles to Assets.car, which macOS 26 and later draw the app icon from
// (Dock, Finder, system dialogs and the system icon styles). Older macOS keeps using the ICNS.
mac: { ...base.mac, icon: "resources/icons/icon.icon" },
// The DMG volume icon would otherwise use the same actool-generated ICNS.
dmg: { icon: "resources/icons/icon.icns" },
publish: {
provider: "generic",
url: "https://opencode.ai/update/api/latest/desktop/opencode/",
channel: "latest",
},
deb: { fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [rpmNoBuildIdLinksFpm, metainfoFpm(appId), legacyDesktopEntryFpm] },
}
}
}
}
export default getConfig()