fix(desktop): bootstrap v2 background service (#39309)
This commit is contained in:
parent
302e9b45ab
commit
1be6d94267
9 changed files with 228 additions and 120 deletions
|
|
@ -56,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
|
||||||
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
|
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
|
||||||
expect(desktop).toContain("NoDisplay=true")
|
expect(desktop).toContain("NoDisplay=true")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("bundles the CLI outside the dev app archive", async () => {
|
||||||
|
const previous = process.env.OPENCODE_CHANNEL
|
||||||
|
process.env.OPENCODE_CHANNEL = "dev"
|
||||||
|
const module = await import("./electron-builder.config.ts?cli-resource")
|
||||||
|
const config = module.default as Configuration
|
||||||
|
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||||
|
else process.env.OPENCODE_CHANNEL = previous
|
||||||
|
|
||||||
|
expect(config.files).toContain("!resources/opencode-cli*")
|
||||||
|
expect(config.extraResources).toContainEqual({
|
||||||
|
from: "resources/",
|
||||||
|
to: "",
|
||||||
|
filter: ["opencode-cli*"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const channel of ["beta", "prod"] as const) {
|
||||||
|
test(`does not bundle the CLI in ${channel} builds`, async () => {
|
||||||
|
const previous = process.env.OPENCODE_CHANNEL
|
||||||
|
process.env.OPENCODE_CHANNEL = channel
|
||||||
|
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
|
||||||
|
const config = module.default as Configuration
|
||||||
|
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||||
|
else process.env.OPENCODE_CHANNEL = previous
|
||||||
|
|
||||||
|
expect(config.extraResources).not.toContainEqual({
|
||||||
|
from: "resources/",
|
||||||
|
to: "",
|
||||||
|
filter: ["opencode-cli*"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({
|
||||||
extraMetadata: {
|
extraMetadata: {
|
||||||
desktopName: `${appId}.desktop`,
|
desktopName: `${appId}.desktop`,
|
||||||
},
|
},
|
||||||
files: ["out/**/*", "resources/**/*"],
|
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
|
||||||
extraResources: [
|
extraResources: [
|
||||||
|
...(channel === "dev"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
from: "resources/",
|
||||||
|
to: "",
|
||||||
|
filter: ["opencode-cli*"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
from: "native/",
|
from: "native/",
|
||||||
to: "native/",
|
to: "native/",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||||
import { defineConfig } from "electron-vite"
|
import { defineConfig } from "electron-vite"
|
||||||
import appPlugin from "@opencode-ai/app/vite"
|
import appPlugin from "@opencode-ai/app/vite"
|
||||||
import * as fs from "node:fs/promises"
|
|
||||||
|
|
||||||
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
|
|
||||||
|
|
||||||
const channel = (() => {
|
const channel = (() => {
|
||||||
const raw = process.env.OPENCODE_CHANNEL
|
const raw = process.env.OPENCODE_CHANNEL
|
||||||
|
|
@ -38,7 +35,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
|
input: { index: "src/main/index.ts" },
|
||||||
// Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can
|
// Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can
|
||||||
// corrupt bundled TypeScript, while a Rollup banner places the shim safely.
|
// corrupt bundled TypeScript, while a Rollup banner places the shim safely.
|
||||||
output: {
|
output: {
|
||||||
|
|
@ -61,22 +58,6 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||||
if (s === "@lydell/node-pty") return nodePtyPkg
|
if (s === "@lydell/node-pty") return nodePtyPkg
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "opencode:virtual-server-module",
|
|
||||||
enforce: "pre",
|
|
||||||
resolveId(id) {
|
|
||||||
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "opencode:copy-server-assets",
|
|
||||||
async writeBundle() {
|
|
||||||
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
|
|
||||||
if (!l.endsWith(".wasm")) continue
|
|
||||||
await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
import { resolveChannel } from "./utils"
|
import { downloadCliToResources, resolveChannel } from "./utils"
|
||||||
|
|
||||||
const channel = resolveChannel()
|
const channel = resolveChannel()
|
||||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||||
|
|
||||||
await $`cd ../opencode && bun script/build-node.ts`
|
if (channel === "dev") await downloadCliToResources()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
import { downloadCliToResources } from "./utils"
|
||||||
|
|
||||||
await $`bun run install-electron`
|
await $`bun run install-electron`
|
||||||
|
|
||||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||||
|
|
||||||
await $`cd ../opencode && bun script/build-node.ts`
|
await downloadCliToResources()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
const CLI_VERSION = "0.0.0-next-16365"
|
||||||
|
|
||||||
export type Channel = "dev" | "beta" | "prod"
|
export type Channel = "dev" | "beta" | "prod"
|
||||||
|
|
||||||
|
|
@ -8,36 +13,42 @@ export function resolveChannel(): Channel {
|
||||||
return "dev"
|
return "dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [
|
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
|
||||||
{
|
{
|
||||||
rustTarget: "aarch64-apple-darwin",
|
rustTarget: "aarch64-apple-darwin",
|
||||||
ocBinary: "opencode-darwin-arm64",
|
package: "@opencode-ai/cli-darwin-arm64",
|
||||||
assetExt: "zip",
|
os: "darwin",
|
||||||
|
cpu: "arm64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "x86_64-apple-darwin",
|
rustTarget: "x86_64-apple-darwin",
|
||||||
ocBinary: "opencode-darwin-x64-baseline",
|
package: "@opencode-ai/cli-darwin-x64-baseline",
|
||||||
assetExt: "zip",
|
os: "darwin",
|
||||||
|
cpu: "x64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "aarch64-pc-windows-msvc",
|
rustTarget: "aarch64-pc-windows-msvc",
|
||||||
ocBinary: "opencode-windows-arm64",
|
package: "@opencode-ai/cli-windows-arm64",
|
||||||
assetExt: "zip",
|
os: "win32",
|
||||||
|
cpu: "arm64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "x86_64-pc-windows-msvc",
|
rustTarget: "x86_64-pc-windows-msvc",
|
||||||
ocBinary: "opencode-windows-x64-baseline",
|
package: "@opencode-ai/cli-windows-x64-baseline",
|
||||||
assetExt: "zip",
|
os: "win32",
|
||||||
|
cpu: "x64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "x86_64-unknown-linux-gnu",
|
rustTarget: "x86_64-unknown-linux-gnu",
|
||||||
ocBinary: "opencode-linux-x64-baseline",
|
package: "@opencode-ai/cli-linux-x64-baseline",
|
||||||
assetExt: "tar.gz",
|
os: "linux",
|
||||||
|
cpu: "x64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "aarch64-unknown-linux-gnu",
|
rustTarget: "aarch64-unknown-linux-gnu",
|
||||||
ocBinary: "opencode-linux-arm64",
|
package: "@opencode-ai/cli-linux-arm64",
|
||||||
assetExt: "tar.gz",
|
os: "linux",
|
||||||
|
cpu: "arm64",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -51,24 +62,33 @@ function nativeTarget() {
|
||||||
throw new Error(`Unsupported platform: ${platform}/${arch}`)
|
throw new Error(`Unsupported platform: ${platform}/${arch}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
|
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
|
||||||
const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target)
|
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
|
||||||
if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`)
|
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
|
||||||
|
|
||||||
return binaryConfig
|
return binaryConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyBinaryToSidecarFolder(source: string) {
|
export async function downloadCliToResources() {
|
||||||
const dir = `resources`
|
const cli = getCurrentCli()
|
||||||
await $`mkdir -p ${dir}`
|
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||||
const dest = windowsify(`${dir}/opencode-cli`)
|
const dest = windowsify("resources/opencode-cli")
|
||||||
await $`cp ${source} ${dest}`
|
try {
|
||||||
|
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||||
|
await copyFile(
|
||||||
|
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
|
||||||
|
dest,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
if (process.platform !== "win32") await chmod(dest, 0o755)
|
||||||
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
||||||
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
|
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
|
||||||
}
|
}
|
||||||
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
||||||
|
|
||||||
console.log(`Copied ${source} to ${dest}`)
|
console.log(`Copied ${cli.package} to ${dest}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function windowsify(path: string) {
|
export function windowsify(path: string) {
|
||||||
|
|
|
||||||
125
packages/desktop/src/main/background-cli.ts
Normal file
125
packages/desktop/src/main/background-cli.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { execFile } from "node:child_process"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { promisify } from "node:util"
|
||||||
|
import { app } from "electron"
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const stateHome = process.env.XDG_STATE_HOME
|
||||||
|
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
|
||||||
|
|
||||||
|
type Logger = {
|
||||||
|
log(message: string, meta?: Record<string, unknown>): void
|
||||||
|
error(message: string, meta?: Record<string, unknown>): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
|
||||||
|
const bundled = app.isPackaged
|
||||||
|
? join(process.resourcesPath, executableName())
|
||||||
|
: join(root, "../../resources", executableName())
|
||||||
|
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||||
|
const version = await run(bundled, ["--version"], logger)
|
||||||
|
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
|
||||||
|
].filter((candidate) => candidate === undefined || existsSync(candidate))
|
||||||
|
const discovered = await Promise.all(
|
||||||
|
candidates.map(async (candidate) => ({
|
||||||
|
stateHome: candidate,
|
||||||
|
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const found = discovered.find((candidate) => candidate.url !== undefined)
|
||||||
|
logger.log("v2 CLI background instance checked", {
|
||||||
|
detected: Boolean(found),
|
||||||
|
...endpoint(found?.url),
|
||||||
|
})
|
||||||
|
|
||||||
|
const daemonStateHome = found?.stateHome ?? stateHome
|
||||||
|
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
|
||||||
|
const password = await run(binary, ["service", "get", "password"], logger, {
|
||||||
|
redact: true,
|
||||||
|
stateHome: daemonStateHome,
|
||||||
|
})
|
||||||
|
logger.log("v2 CLI background service ready", {
|
||||||
|
existing: Boolean(found),
|
||||||
|
username: "opencode",
|
||||||
|
...endpoint(url),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
username: "opencode",
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installCli(source: string, version: string, logger: Logger) {
|
||||||
|
const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
|
||||||
|
const destination = join(directory, executableName())
|
||||||
|
if (existsSync(destination)) {
|
||||||
|
logger.log("v2 CLI staged executable reused", { path: destination, version })
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
const temp = destination + `.${process.pid}.tmp`
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
await copyFile(source, temp)
|
||||||
|
if (process.platform !== "win32") await chmod(temp, 0o755)
|
||||||
|
await rename(temp, destination).catch(async (error) => {
|
||||||
|
await rm(temp, { force: true })
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
logger.log("v2 CLI executable staged", { source, path: destination, version })
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
binary: string,
|
||||||
|
args: string[],
|
||||||
|
logger: Logger,
|
||||||
|
options: { redact?: boolean; stateHome?: string } = {},
|
||||||
|
) {
|
||||||
|
logger.log("v2 CLI command started", { binary, args })
|
||||||
|
const env = { ...process.env }
|
||||||
|
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
|
||||||
|
else env.XDG_STATE_HOME = options.stateHome
|
||||||
|
return execFileAsync(binary, args, { env, windowsHide: true }).then(
|
||||||
|
(result) => {
|
||||||
|
const stdout = result.stdout.trim()
|
||||||
|
const stderr = result.stderr.trim()
|
||||||
|
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
|
||||||
|
return stdout
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
const output = error as { stdout?: string; stderr?: string }
|
||||||
|
logger.error("v2 CLI command failed", {
|
||||||
|
args,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
|
||||||
|
stderr: output.stderr?.trim() ?? "",
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceUrl(status: string) {
|
||||||
|
if (URL.canParse(status)) return status
|
||||||
|
if (!status.startsWith("running ")) return
|
||||||
|
const url = status.slice("running ".length).trim()
|
||||||
|
return URL.canParse(url) ? url : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(url: string | undefined) {
|
||||||
|
if (!url || !URL.canParse(url)) return {}
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return { url, hostname: parsed.hostname, port: parsed.port }
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableName() {
|
||||||
|
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { mkdirSync, rmSync } from "node:fs"
|
import { mkdirSync, rmSync } from "node:fs"
|
||||||
import * as http from "node:http"
|
import * as http from "node:http"
|
||||||
import { createServer } from "node:net"
|
|
||||||
import { homedir, tmpdir } from "node:os"
|
import { homedir, tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||||
|
|
@ -25,13 +24,7 @@ import {
|
||||||
isFirstLaunchOnboardingPending,
|
isFirstLaunchOnboardingPending,
|
||||||
isOldLayoutEligible,
|
isOldLayoutEligible,
|
||||||
} from "./onboarding"
|
} from "./onboarding"
|
||||||
import {
|
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
||||||
getDefaultServerUrl,
|
|
||||||
preferAppEnv,
|
|
||||||
setDefaultServerUrl,
|
|
||||||
spawnLocalServer,
|
|
||||||
type SidecarListener,
|
|
||||||
} from "./server"
|
|
||||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||||
import { safeWebContentsURL } from "./window-state"
|
import { safeWebContentsURL } from "./window-state"
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,6 +41,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||||
import { spawnWslSidecar } from "./wsl/sidecar"
|
import { spawnWslSidecar } from "./wsl/sidecar"
|
||||||
import { migrate } from "./migrate"
|
import { migrate } from "./migrate"
|
||||||
import { cleanupStoreFiles } from "./store-cleanup"
|
import { cleanupStoreFiles } from "./store-cleanup"
|
||||||
|
import { startBackgroundCli } from "./background-cli"
|
||||||
|
|
||||||
const APP_NAMES: Record<string, string> = {
|
const APP_NAMES: Record<string, string> = {
|
||||||
dev: "OpenCode Dev",
|
dev: "OpenCode Dev",
|
||||||
|
|
@ -63,7 +57,6 @@ const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||||
|
|
||||||
let logger: ReturnType<typeof initLogging>
|
let logger: ReturnType<typeof initLogging>
|
||||||
let server: SidecarListener | null = null
|
|
||||||
|
|
||||||
const pendingDeepLinks: string[] = []
|
const pendingDeepLinks: string[] = []
|
||||||
|
|
||||||
|
|
@ -83,13 +76,6 @@ function emitDeepLinks(urls: string[]) {
|
||||||
if (win) sendDeepLinks(win, urls)
|
if (win) sendDeepLinks(win, urls)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killSidecar() {
|
|
||||||
if (!server) return
|
|
||||||
const current = server
|
|
||||||
server = null
|
|
||||||
await current.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureLoopbackNoProxy() {
|
function ensureLoopbackNoProxy() {
|
||||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||||
const upsert = (key: string) => {
|
const upsert = (key: string) => {
|
||||||
|
|
@ -162,10 +148,7 @@ const main = Effect.gen(function* () {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const stopSidecars = async () => {
|
const stopSidecars = async () => wslServers.stopAll()
|
||||||
await killSidecar()
|
|
||||||
wslServers.stopAll()
|
|
||||||
}
|
|
||||||
const relaunch = () => {
|
const relaunch = () => {
|
||||||
setAppQuitting()
|
setAppQuitting()
|
||||||
void stopSidecars().finally(() => {
|
void stopSidecars().finally(() => {
|
||||||
|
|
@ -198,7 +181,7 @@ const main = Effect.gen(function* () {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
preferAppEnv(app.getPath("userData"))
|
const shellEnv = preferAppEnv(app.getPath("userData"))
|
||||||
|
|
||||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
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("opencode://"))
|
||||||
|
|
@ -271,7 +254,7 @@ const main = Effect.gen(function* () {
|
||||||
setDockIcon()
|
setDockIcon()
|
||||||
const updater = setupAutoUpdater(stopSidecars)
|
const updater = setupAutoUpdater(stopSidecars)
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
killSidecar: () => killSidecar(),
|
killSidecar: () => undefined,
|
||||||
relaunch,
|
relaunch,
|
||||||
awaitInitialization: Effect.fnUntraced(
|
awaitInitialization: Effect.fnUntraced(
|
||||||
function* () {
|
function* () {
|
||||||
|
|
@ -312,68 +295,22 @@ const main = Effect.gen(function* () {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const port = yield* Effect.gen(function* () {
|
|
||||||
const fromEnv = process.env.OPENCODE_PORT
|
|
||||||
if (fromEnv) {
|
|
||||||
const parsed = Number.parseInt(fromEnv, 10)
|
|
||||||
if (!Number.isNaN(parsed)) return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = yield* Deferred.make<number, unknown>()
|
|
||||||
const server = createServer()
|
|
||||||
server.on("error", (e) => Deferred.failSync(res, () => e))
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address()
|
|
||||||
if (typeof address !== "object" || !address) {
|
|
||||||
server.close()
|
|
||||||
Deferred.failSync(res, () => new Error("Failed to get port"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const port = address.port
|
|
||||||
server.close(() => Effect.runSync(Deferred.succeed(res, port)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return yield* Deferred.await(res)
|
|
||||||
})
|
|
||||||
const hostname = "127.0.0.1"
|
|
||||||
const url = `http://${hostname}:${port}`
|
|
||||||
const password = randomUUID()
|
|
||||||
|
|
||||||
const loadingTask = yield* Effect.gen(function* () {
|
const loadingTask = yield* Effect.gen(function* () {
|
||||||
logger.log("sidecar connection started", { url })
|
|
||||||
|
|
||||||
ensureLoopbackNoProxy()
|
ensureLoopbackNoProxy()
|
||||||
useEnvProxy()
|
useEnvProxy()
|
||||||
|
|
||||||
logger.log("spawning sidecar", { url })
|
logger.log("starting v2 background service")
|
||||||
const { listener, health } = yield* Effect.promise(() =>
|
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
|
||||||
spawnLocalServer(hostname, port, password, {
|
|
||||||
userDataPath: app.getPath("userData"),
|
|
||||||
onStdout: (message) => writeLog("server", "stdout", { message }),
|
|
||||||
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
|
|
||||||
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
server = listener
|
|
||||||
yield* Deferred.succeed(serverReady, {
|
yield* Deferred.succeed(serverReady, {
|
||||||
url,
|
url: sidecar.url,
|
||||||
username: "opencode",
|
username: sidecar.username,
|
||||||
password,
|
password: sidecar.password,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.promise(() => health.wait).pipe(
|
|
||||||
Effect.timeout("30 seconds"),
|
|
||||||
Effect.catch((e) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
logger.error("sidecar health check failed", e.toString())
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.log("loading task finished")
|
logger.log("loading task finished")
|
||||||
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) {
|
||||||
|
|
||||||
export function preferAppEnv(userDataPath: string) {
|
export function preferAppEnv(userDataPath: string) {
|
||||||
const shell = process.platform === "win32" ? null : getUserShell()
|
const shell = process.platform === "win32" ? null : getUserShell()
|
||||||
|
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
|
||||||
Object.assign(process.env, {
|
Object.assign(process.env, {
|
||||||
...(shell ? loadShellEnv(shell, getLogger()) : null),
|
...shellEnv,
|
||||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
OPENCODE_CLIENT: "desktop",
|
OPENCODE_CLIENT: "desktop",
|
||||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||||
})
|
})
|
||||||
|
return shellEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function spawnLocalServer(
|
export async function spawnLocalServer(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue