mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-23 00:57:38 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77baed670 | ||
|
|
db4da867e1 |
@@ -142,6 +142,14 @@ console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpP
|
||||
if (service === "warm") await warmService()
|
||||
|
||||
const samples: Sample[] = []
|
||||
// A launch that never produces a renderer would otherwise leave an instance behind that every later
|
||||
// launch hands off to through the single-instance lock.
|
||||
process.on("uncaughtException", async (error) => {
|
||||
console.error(error)
|
||||
await killApp()
|
||||
await stopService()
|
||||
process.exit(1)
|
||||
})
|
||||
for (let run = 1 - warmup; run <= runs; run++) {
|
||||
for (const build of builds) {
|
||||
const sample = await launch(build, run)
|
||||
@@ -516,11 +524,18 @@ function mainLog() {
|
||||
const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : []
|
||||
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
|
||||
const timeline: [number, string, string][] = []
|
||||
let windowShownAt: number | undefined
|
||||
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
|
||||
for (const line of readFileSync(join(dir!, name), "utf8").split(/\r?\n/)) {
|
||||
const text = readFileSync(join(dir!, name), "utf8")
|
||||
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
|
||||
for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) {
|
||||
const line = entry.split(/\r?\n/)[0]
|
||||
const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/)
|
||||
if (!m) continue
|
||||
const message = m[2].replace(/\s*\{.*$/, "").trim()
|
||||
// A window shown before the logger existed reports when it was shown; the line itself is later.
|
||||
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
|
||||
if (shown) windowShownAt = Number(shown)
|
||||
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
|
||||
}
|
||||
}
|
||||
@@ -533,7 +548,7 @@ function mainLog() {
|
||||
versionDone: at(/v2 CLI command completed/),
|
||||
serviceStarting: at(/v2 CLI background service starting/),
|
||||
serviceReady: at(/background service ready/),
|
||||
windowVisible: at(/main window visible/),
|
||||
windowVisible: windowShownAt ?? at(/main window visible/),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,8 +607,12 @@ function bundledCli(exe: string) {
|
||||
async function warmService() {
|
||||
await stopService()
|
||||
const clis = builds.map((build) => bundledCli(build.exe))
|
||||
if (new Set(clis.map((cli) => statSync(cli).size)).size > 1)
|
||||
console.warn("warning: the compared builds bundle different CLIs; the desktop will restart the service on the mismatch")
|
||||
const identity = (cli: string) => {
|
||||
const version = join(dirname(cli), "opencode-cli.version")
|
||||
return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size)
|
||||
}
|
||||
if (new Set(clis.map(identity)).size > 1)
|
||||
throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch")
|
||||
serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" })
|
||||
serviceProcess.unref()
|
||||
const deadline = Date.now() + 60_000
|
||||
|
||||
@@ -6,3 +6,17 @@ export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "bet
|
||||
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
|
||||
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
// Local renderer/server mode keeps the dev application identity.
|
||||
export const APP_NAME = app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev"
|
||||
export const APP_ID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
export {}
|
||||
import { app } from "electron"
|
||||
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
import { registerRendererScheme } from "./windows/scheme"
|
||||
|
||||
await import("./desktop")
|
||||
// This module stays small on purpose. Electron holds the ready event until the entry module has
|
||||
// finished, and the first window should be on screen before the rest of the main process — a few
|
||||
// hundred milliseconds of module evaluation and layers — loads. Configuration and the scheme must
|
||||
// precede ready; the window is created the moment ready fires; everything else is imported after.
|
||||
configureApplication()
|
||||
if (acquireApplicationLock()) {
|
||||
registerRendererScheme()
|
||||
// Window first, then the bundle: starting the import before ready delays ready itself, because the
|
||||
// module graph evaluates on the same thread Chromium needs to finish initialising.
|
||||
void app.whenReady().then(() => {
|
||||
createEarlyWindow()
|
||||
return import("./desktop")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,12 +65,16 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
const connect = () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
})
|
||||
}
|
||||
win.webContents.on("did-finish-load", connect)
|
||||
// The first window starts loading before this layer exists; a renderer that has already finished
|
||||
// loading is waiting for its port right now.
|
||||
if (!win.webContents.isLoading() && win.webContents.getURL()) connect()
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
app.on("browser-window-created", wire)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
import { APP_ID, APP_NAME } from "../constants"
|
||||
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
// Runs synchronously from the entry module, before Chromium is ready: command-line switches only
|
||||
// take effect before ready, the single-instance lock is scoped to userData, and the early window
|
||||
// needs userData to find the persisted window list and state.
|
||||
export function configureApplication() {
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAppUserModelId(APP_ID)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
|
||||
|
||||
const testRoot = createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
function createTestRoot() {
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) rmSync(root, { recursive: true, force: true })
|
||||
for (const dir of ["data", "config", "cache", "state", "desktop", "session", "documents"])
|
||||
mkdirSync(path.join(root, dir), { recursive: true })
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = path.join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = path.join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = path.join(root, "state")
|
||||
return root
|
||||
}
|
||||
@@ -1,59 +1,12 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import http from "node:http"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import { app } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { CHANNEL } from "../constants"
|
||||
import { Effect, Path } from "effect"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
export const configureApplication = Effect.fn("Application.configure")(function* () {
|
||||
const path = yield* Path.Path
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appID)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
|
||||
|
||||
const testRoot = yield* createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
})
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
export const prepareApplicationEnvironment = Effect.gen(function* () {
|
||||
yield* loadSystemCertificates
|
||||
yield* loadProxyEnvironment
|
||||
@@ -91,29 +44,6 @@ export const loadProxyEnvironment = Effect.gen(function* () {
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
|
||||
})
|
||||
|
||||
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
|
||||
yield* Effect.forEach(
|
||||
["data", "config", "cache", "state", "desktop", "session", "documents"],
|
||||
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = path.join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = path.join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = path.join(root, "state")
|
||||
return root
|
||||
})
|
||||
|
||||
const loadSystemCertificates = Effect.try({
|
||||
try: () => {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { acquireApplicationLock, configureApplication } from "./environment"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
import { Shutdown } from "./shutdown"
|
||||
|
||||
@@ -145,7 +144,7 @@ const runtime = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// Storage opens after configureApplication has set userData and before windows exist, so window
|
||||
// Storage opens after the entry module has set userData and before the renderer loads, so window
|
||||
// teardown can clear a window's persisted state and every renderer request finds it ready.
|
||||
const platform = Layer.mergeAll(
|
||||
DesktopLogging.layer,
|
||||
@@ -155,9 +154,6 @@ const platform = Layer.mergeAll(
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Electron scopes the single-instance lock to userData.
|
||||
yield* configureApplication()
|
||||
if (!acquireApplicationLock()) return yield* Effect.interrupt
|
||||
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
|
||||
// otherwise read as evidence of an earlier launch on a fresh install.
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
|
||||
import type { Path } from "effect"
|
||||
import { type TitlebarTheme } from "../../shared/ipc-contract"
|
||||
@@ -9,16 +6,10 @@ import { emitIpcEvent } from "../ipc-events"
|
||||
import type { DesktopPaths } from "../paths"
|
||||
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay, tone } from "./defaults"
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
const titlebarHeight = 44
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
let backgroundColor: string | undefined
|
||||
@@ -28,7 +19,7 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
|
||||
return {
|
||||
title: "OpenCode",
|
||||
icon: iconPath(path, paths),
|
||||
backgroundColor: getBackgroundColor() ?? oc2Background[mode],
|
||||
backgroundColor: backgroundColor ?? storedBackgroundColor(),
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -137,17 +128,8 @@ function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
return titlebarOverlay(theme.mode ?? tone(), zoom)
|
||||
}
|
||||
|
||||
function clampZoom(value: number) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode/ui/theme/types"
|
||||
import { nativeTheme } from "electron"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { BACKGROUND_COLOR_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
// Frame defaults shared by the early window (created on ready, before the renderer exists) and the
|
||||
// full window setup in appearance.ts, so both draw the same frame.
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
export const titlebarHeight = 44
|
||||
|
||||
export function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
|
||||
// a window shown before the renderer paints already has the right background.
|
||||
export function storedBackgroundColor() {
|
||||
const stored = getStore().get(BACKGROUND_COLOR_KEY)
|
||||
return typeof stored === "string" ? stored : oc2Background[tone()]
|
||||
}
|
||||
|
||||
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, BrowserWindow, screen, shell } from "electron"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { resolveExternalURL } from "../files/external-url"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
|
||||
import { handleRendererProtocol, loadWindow } from "./scheme"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
|
||||
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
|
||||
|
||||
let pending: EarlyWindow | undefined
|
||||
|
||||
const displays = {
|
||||
all: () => screen.getAllDisplays().map((display) => display.bounds),
|
||||
primary: () => screen.getPrimaryDisplay().bounds,
|
||||
matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds,
|
||||
}
|
||||
|
||||
// Creates and shows the first restored window the moment Electron is ready, before the rest of the
|
||||
// main process has loaded, and starts loading the renderer into it so the renderer process boots in
|
||||
// parallel with the main bundle and its layers. The frame options mirror windowAppearance(); the
|
||||
// persisted background colour stands in for the theme until the renderer applies it. The renderer's
|
||||
// IPC connection waits for a port the IPC layer hands over once it exists. restoreWindows() adopts the
|
||||
// window through takeEarlyWindow().
|
||||
export function createEarlyWindow() {
|
||||
const ids = getStore().get(WINDOW_IDS_KEY)
|
||||
const id = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : randomUUID()
|
||||
const root = path.dirname(fileURLToPath(import.meta.url))
|
||||
const file = path.join(app.getPath("userData"), windowStateFile(id))
|
||||
const state = resolveWindowState(readWindowState(file), { width: 1280, height: 800 }, displays)
|
||||
const icons = app.isPackaged ? path.join(process.resourcesPath, "icons") : path.join(root, "../../resources/icons")
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: path.join(icons, `icon.${process.platform === "win32" ? "ico" : "png"}`),
|
||||
backgroundColor: storedBackgroundColor(),
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, trafficLightPosition: { x: 14, y: 14 } } : {}),
|
||||
...(process.platform === "win32" ? { frame: false, titleBarStyle: "hidden" as const, titleBarOverlay: titlebarOverlay() } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(root, "../preload/index.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
manageWindowState(win, file, state, displays)
|
||||
allowRendererPermissions(win)
|
||||
wireNavigationPolicy(win, (url) => {
|
||||
const target = resolveExternalURL(url)
|
||||
if (target) void shell.openExternal(target)
|
||||
})
|
||||
wireRendererHeaders(win)
|
||||
handleRendererProtocol(path.join(root, "../renderer"))
|
||||
loadWindow(win, "index.html")
|
||||
// Closing the only window before the rest of the app has adopted it is a quit.
|
||||
win.once("closed", () => {
|
||||
if (pending?.win !== win) return
|
||||
pending = undefined
|
||||
app.quit()
|
||||
})
|
||||
pending = { id, win, state, shownAt: Date.now() }
|
||||
}
|
||||
|
||||
export function takeEarlyWindow() {
|
||||
const taken = pending
|
||||
pending = undefined
|
||||
return taken
|
||||
}
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
import { loadWindow, registerRendererProtocol } from "./protocol"
|
||||
import { createWindowRegistry } from "./registry"
|
||||
import { makeWindowRecovery } from "./recovery"
|
||||
import { manageWindowState, readWindowState, resolveWindowState } from "./window-state"
|
||||
import { takeEarlyWindow, type EarlyWindow } from "./early"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile } from "./window-state"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
|
||||
const themeReady = new WeakMap<BrowserWindow, () => void>()
|
||||
@@ -88,48 +89,65 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
const wireWindowRecovery = yield* makeWindowRecovery
|
||||
|
||||
const restore = () => {
|
||||
// The entry module created and showed the first restored window on ready; it is adopted here,
|
||||
// before any renderer loads, so the user never waited for the layers to see a window.
|
||||
const early = takeEarlyWindow()
|
||||
const usable = early && !early.win.isDestroyed() ? early : undefined
|
||||
const ids = registry.persisted()
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => create(id))
|
||||
const list = ids.length ? ids : [usable?.id ?? randomUUID()]
|
||||
if (usable && !list.includes(usable.id)) usable.win.destroy()
|
||||
return list.map((id) => create(id, usable?.id === id ? usable : undefined))
|
||||
}
|
||||
|
||||
const create = (id: string = randomUUID()) => {
|
||||
const create = (id: string = randomUUID(), early?: EarlyWindow) => {
|
||||
const stateFile = path.join(app.getPath("userData"), windowStateFile(id))
|
||||
const state = resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays)
|
||||
const state = early?.state ?? resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays)
|
||||
const appearance = windowAppearance(path, paths)
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
const win =
|
||||
early?.win ??
|
||||
new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
// The early window arrives with its state, security wiring and renderer load already done.
|
||||
if (!early) {
|
||||
allowRendererPermissions(win)
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
manageWindowState(win, stateFile, state, displays)
|
||||
}
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
manageWindowState(win, stateFile, state, displays)
|
||||
register(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
if (!early) loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
let revealed = false
|
||||
let revealed = !!early
|
||||
const focusForTests = () => {
|
||||
if (app.isPackaged || process.env.OPENCODE_TEST_ONBOARDING !== "1") return
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
if (early) {
|
||||
focusForTests()
|
||||
runFork(Effect.logInfo("main window visible", { window: id, shownAt: early.shownAt }))
|
||||
}
|
||||
const reveal = () => {
|
||||
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
|
||||
revealed = true
|
||||
win.show()
|
||||
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
focusForTests()
|
||||
runFork(Effect.logInfo("main window visible", { window: id }))
|
||||
}
|
||||
const ready = () => {
|
||||
@@ -167,10 +185,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
return { create, restore }
|
||||
})
|
||||
|
||||
function windowStateFile(id: string) {
|
||||
return `window-state-${safeWindowID(id)}.json`
|
||||
}
|
||||
|
||||
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
|
||||
// namespace the renderer persists this window's tabs under.
|
||||
function windowDataFile(id: string) {
|
||||
|
||||
@@ -1,92 +1,18 @@
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { handleRendererProtocol, setRendererProtocolReporter } from "./scheme"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
export { isRendererUrl, loadWindow } from "./scheme"
|
||||
|
||||
// The entry module usually installs the handler before this runs; either way, route its diagnostics
|
||||
// through the application log from here on.
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(paths.rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
runFork(
|
||||
scoped(
|
||||
"protocol",
|
||||
Effect.logError("fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
setRendererProtocolReporter({
|
||||
warn: (message, data) => runFork(scoped("protocol", Effect.logWarning(message, data))),
|
||||
error: (message, data) => runFork(scoped("protocol", Effect.logError(message, data))),
|
||||
})
|
||||
handleRendererProtocol(paths.rendererRoot)
|
||||
})
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(new URL(html, devUrl).toString())
|
||||
return
|
||||
}
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
export function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
|
||||
// The renderer scheme, its file handler and the window loader, kept free of Effect and logging so
|
||||
// the entry module can register the scheme before ready and load the first window before the rest
|
||||
// of the main process exists. Diagnostics go through a reporter the logging layer installs later.
|
||||
|
||||
export const rendererProtocol = "oc"
|
||||
export const rendererHost = "renderer"
|
||||
|
||||
type Reporter = {
|
||||
warn: (message: string, data: Record<string, unknown>) => void
|
||||
error: (message: string, data: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
let report: Reporter = {
|
||||
warn: (message, data) => console.warn(message, data),
|
||||
error: (message, data) => console.error(message, data),
|
||||
}
|
||||
|
||||
export function setRendererProtocolReporter(reporter: Reporter) {
|
||||
report = reporter
|
||||
}
|
||||
|
||||
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
|
||||
// before it loads anything else.
|
||||
export function registerRendererScheme() {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function handleRendererProtocol(rendererRoot: string) {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
report.warn("rejected host", { url: request.url })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
report.warn("rejected path", { url: request.url, file })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
report.error("fetch failed", { url: request.url, file, status: response.status, statusText: response.statusText })
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
report.error("fetch error", { url: request.url, file, error })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(new URL(html, devUrl).toString())
|
||||
return
|
||||
}
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
export function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
import { isRendererUrl } from "./scheme"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ export function resolveWindowState(saved: unknown, defaults: { width: number; he
|
||||
} satisfies WindowState
|
||||
}
|
||||
|
||||
export function windowStateFile(id: string) {
|
||||
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
export function readWindowState(file: string): unknown {
|
||||
if (!existsSync(file)) return undefined
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user