Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Test User <test@test.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li <liqiping1991@gmail.com> Co-authored-by: liqiping <liqiping@msh.team> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
import windowState from "electron-window-state"
|
|
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
|
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
|
|
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
|
|
import { randomUUID } from "node:crypto"
|
|
import { rmSync } from "node:fs"
|
|
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
|
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
import type { TitlebarTheme } from "../preload/types"
|
|
import { exportDebugLogs, write as writeLog } from "./logging"
|
|
import { getStore, removeStoreFile } from "./store"
|
|
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
|
import { createUnresponsiveSampler } from "./unresponsive"
|
|
import { createWindowRegistry } from "./window-registry"
|
|
import { safeWindowURL } from "./window-state"
|
|
|
|
const root = dirname(fileURLToPath(import.meta.url))
|
|
const rendererRoot = join(root, "../renderer")
|
|
const rendererProtocol = "oc"
|
|
const rendererHost = "renderer"
|
|
const clipboardWritePermission = "clipboard-sanitized-write"
|
|
const notificationPermission = "notifications"
|
|
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
|
|
const oc2Theme = oc2ThemeJson as DesktopTheme
|
|
const oc2Background = {
|
|
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
|
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
|
}
|
|
const documentPolicyHeader = "Document-Policy"
|
|
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
|
|
|
protocol.registerSchemesAsPrivileged([
|
|
{
|
|
scheme: rendererProtocol,
|
|
privileges: {
|
|
secure: true,
|
|
standard: true,
|
|
supportFetchAPI: true,
|
|
stream: true,
|
|
},
|
|
},
|
|
])
|
|
|
|
let backgroundColor: string | undefined
|
|
let relaunchHandler = () => {
|
|
setAppQuitting()
|
|
app.relaunch()
|
|
app.exit(0)
|
|
}
|
|
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
|
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
|
const windowIDs = new WeakMap<BrowserWindow, string>()
|
|
const registry = createWindowRegistry<BrowserWindow>({
|
|
read: () => getStore().get(WINDOW_IDS_KEY),
|
|
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
|
|
cleanup: (id) => {
|
|
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
|
|
removeStoreFile(windowDataFile(id))
|
|
},
|
|
})
|
|
const titlebarHeight = 40
|
|
const maxZoomLevel = 10
|
|
const minZoomLevel = 0.2
|
|
|
|
export function setRelaunchHandler(handler: () => void) {
|
|
relaunchHandler = handler
|
|
}
|
|
|
|
export function setAppQuitting(quitting = true) {
|
|
registry.setQuitting(quitting)
|
|
}
|
|
|
|
export function setBackgroundColor(color: string) {
|
|
backgroundColor = color
|
|
BrowserWindow.getAllWindows().forEach((win) => {
|
|
win.setBackgroundColor(color)
|
|
if (process.platform === "darwin") win.invalidateShadow()
|
|
})
|
|
}
|
|
|
|
export function getBackgroundColor(): string | undefined {
|
|
return backgroundColor
|
|
}
|
|
|
|
function iconsDir() {
|
|
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
|
}
|
|
|
|
function iconPath() {
|
|
const ext = process.platform === "win32" ? "ico" : "png"
|
|
return join(iconsDir(), `icon.${ext}`)
|
|
}
|
|
|
|
function tone() {
|
|
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
|
}
|
|
|
|
function defaultBackgroundColor() {
|
|
return oc2Background[tone()]
|
|
}
|
|
|
|
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)),
|
|
}
|
|
}
|
|
|
|
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
|
|
titlebarThemes.set(win, theme)
|
|
// macOS draws the window frame hairline and shadow using the NSWindow
|
|
// appearance, which follows nativeTheme rather than the rendered content.
|
|
// Align it with the app theme so a light app on a dark system does not get
|
|
// the dark-appearance border and shadow. A "system" scheme must map to
|
|
// "system" (not the resolved mode) or prefers-color-scheme stops tracking
|
|
// OS appearance changes in the renderer.
|
|
if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
|
|
updateTitlebar(win)
|
|
}
|
|
|
|
export function updateTitlebar(win: BrowserWindow) {
|
|
if (process.platform !== "win32") return
|
|
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
|
|
}
|
|
|
|
export function setPinchZoomEnabled(enabled: boolean) {
|
|
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
pinchZoomEnabled.set(win, enabled)
|
|
win.webContents.send("pinch-zoom-enabled-changed", enabled)
|
|
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
|
updateZoom(win)
|
|
}
|
|
}
|
|
|
|
export function getPinchZoomEnabled() {
|
|
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
|
|
}
|
|
|
|
export function getWindowID(win: BrowserWindow) {
|
|
return windowIDs.get(win)
|
|
}
|
|
|
|
export function getLastFocusedWindow() {
|
|
const focused = BrowserWindow.getFocusedWindow()
|
|
if (focused) return focused
|
|
const win = registry.lastFocused()
|
|
if (!win || win.isDestroyed()) return null
|
|
return win
|
|
}
|
|
|
|
export function restoreMainWindows() {
|
|
const ids = registry.persisted()
|
|
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
|
|
}
|
|
|
|
export function setDockIcon() {
|
|
if (process.platform !== "darwin") return
|
|
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
|
|
if (!icon.isEmpty()) app.dock?.setIcon(icon)
|
|
}
|
|
|
|
export function createMainWindow(id: string = randomUUID()) {
|
|
const state = windowState({
|
|
file: windowStateFile(id),
|
|
defaultWidth: 1280,
|
|
defaultHeight: 800,
|
|
})
|
|
|
|
const mode = tone()
|
|
const win = new BrowserWindow({
|
|
x: state.x,
|
|
y: state.y,
|
|
width: state.width,
|
|
height: state.height,
|
|
show: false,
|
|
autoHideMenuBar: true,
|
|
title: "OpenCode",
|
|
icon: iconPath(),
|
|
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
|
|
...(process.platform === "darwin"
|
|
? {
|
|
titleBarStyle: "hidden" as const,
|
|
trafficLightPosition: { x: 14, y: 14 },
|
|
}
|
|
: {}),
|
|
...(process.platform === "win32"
|
|
? {
|
|
frame: false,
|
|
titleBarStyle: "hidden" as const,
|
|
titleBarOverlay: overlay({ mode }),
|
|
}
|
|
: {}),
|
|
webPreferences: {
|
|
preload: join(root, "../preload/index.js"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
},
|
|
})
|
|
|
|
allowRendererPermissions(win)
|
|
wireWindowRecovery(win, id)
|
|
|
|
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
|
const { requestHeaders } = details
|
|
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
|
|
callback({ requestHeaders })
|
|
})
|
|
|
|
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
|
const { responseHeaders = {} } = details
|
|
addRendererHeaders(details.url, responseHeaders)
|
|
callback({ responseHeaders })
|
|
})
|
|
|
|
state.manage(win)
|
|
registerWindow(win, id)
|
|
loadWindow(win, "index.html")
|
|
wireZoom(win)
|
|
|
|
win.once("ready-to-show", () => {
|
|
win.show()
|
|
})
|
|
|
|
return win
|
|
}
|
|
|
|
function registerWindow(win: BrowserWindow, id: string) {
|
|
windowIDs.set(win, id)
|
|
registry.register(id, win)
|
|
|
|
win.on("focus", () => registry.focused(id))
|
|
// Windows never emits before-quit on OS shutdown/logoff, but each window
|
|
// gets session-end before it closes; flag the quit so ids stay persisted.
|
|
win.on("session-end", () => registry.setQuitting())
|
|
win.on("closed", () => registry.closed(id))
|
|
}
|
|
|
|
function windowStateFile(id: string) {
|
|
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
|
}
|
|
|
|
// Mirrors windowStorage() in packages/app/src/utils/persist.ts, which names
|
|
// the per-window renderer store this window persists its tabs into.
|
|
function windowDataFile(id: string) {
|
|
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
|
|
}
|
|
|
|
export function registerRendererProtocol() {
|
|
if (protocol.isProtocolHandled(rendererProtocol)) return
|
|
|
|
protocol.handle(rendererProtocol, async (request) => {
|
|
const url = new URL(request.url)
|
|
if (url.host !== rendererHost) {
|
|
writeLog("protocol", "rejected host", { url: request.url }, "warn")
|
|
return new Response("Not found", { status: 404 })
|
|
}
|
|
|
|
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
|
const rel = relative(rendererRoot, file)
|
|
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
|
|
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) {
|
|
writeLog(
|
|
"protocol",
|
|
"fetch failed",
|
|
{
|
|
url: request.url,
|
|
file,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
},
|
|
"error",
|
|
)
|
|
}
|
|
return addDocumentPolicy(response, file)
|
|
} catch (error) {
|
|
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
|
|
return new Response("Not found", { status: 404 })
|
|
}
|
|
})
|
|
}
|
|
|
|
function loadWindow(win: BrowserWindow, html: string) {
|
|
const devUrl = process.env.ELECTRON_RENDERER_URL
|
|
if (devUrl) {
|
|
const url = new URL(html, devUrl)
|
|
void win.loadURL(url.toString())
|
|
return
|
|
}
|
|
|
|
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
|
}
|
|
|
|
function wireWindowRecovery(win: BrowserWindow, name: string) {
|
|
let showing = false
|
|
const sampler = createUnresponsiveSampler(win, name)
|
|
|
|
const handle = async (button: string | undefined, wait: boolean) => {
|
|
if (button === "Export Logs") {
|
|
const sampling = sampler.stopAndFlush()
|
|
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
|
|
if (wait && sampling) sampler.start()
|
|
return true
|
|
}
|
|
if (button === "Relaunch") {
|
|
sampler.stopAndFlush()
|
|
relaunchHandler()
|
|
return false
|
|
}
|
|
if (button === "Quit") {
|
|
sampler.stopAndFlush()
|
|
app.quit()
|
|
}
|
|
return false
|
|
}
|
|
|
|
const show = async (message: string, detail: string, wait: boolean) => {
|
|
if (showing || win.isDestroyed()) return
|
|
showing = true
|
|
try {
|
|
while (!win.isDestroyed()) {
|
|
const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
|
|
const result = await dialog.showMessageBox(win, {
|
|
type: "warning",
|
|
buttons,
|
|
defaultId: 0,
|
|
cancelId: 2,
|
|
message,
|
|
detail,
|
|
})
|
|
if (await handle(buttons[result.response], wait)) continue
|
|
return
|
|
}
|
|
} finally {
|
|
showing = false
|
|
}
|
|
}
|
|
|
|
const failed = (
|
|
event: string,
|
|
errorCode: number,
|
|
errorDescription: string,
|
|
validatedURL: string,
|
|
isMainFrame: boolean,
|
|
) => {
|
|
writeLog(
|
|
"window",
|
|
"renderer load failed",
|
|
{
|
|
window: name,
|
|
event,
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
currentURL: safeWindowURL(win),
|
|
isMainFrame,
|
|
},
|
|
"error",
|
|
)
|
|
|
|
if (!isMainFrame || errorCode === -3) return
|
|
void show(
|
|
"OpenCode failed to load",
|
|
[`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
|
|
false,
|
|
)
|
|
}
|
|
|
|
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
|
|
})
|
|
win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
|
|
})
|
|
win.webContents.on("render-process-gone", (_event, details) => {
|
|
sampler.stopAndFlush()
|
|
writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error")
|
|
void show(
|
|
"OpenCode window terminated unexpectedly",
|
|
[`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
|
|
false,
|
|
)
|
|
})
|
|
win.on("unresponsive", () => {
|
|
writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error")
|
|
sampler.start()
|
|
void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
|
|
})
|
|
win.on("responsive", () => {
|
|
writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error")
|
|
sampler.stopAndFlush()
|
|
})
|
|
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
|
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
|
|
writeLog("pty", "console", { window: name, level, message, line, sourceId })
|
|
}
|
|
})
|
|
win.webContents.on("preload-error", (_event, preloadPath, error) => {
|
|
writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
|
|
})
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
function allowRendererPermissions(win: BrowserWindow) {
|
|
const webContentsId = win.webContents.id
|
|
|
|
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
|
callback(
|
|
rendererPermissions.has(permission) &&
|
|
isTrustedRendererUrl(details.requestingUrl) &&
|
|
webContents.id === webContentsId,
|
|
)
|
|
})
|
|
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
|
if (!rendererPermissions.has(permission)) return false
|
|
if (webContents && webContents.id !== webContentsId) return false
|
|
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
|
|
})
|
|
}
|
|
|
|
function isTrustedRendererUrl(value?: string) {
|
|
return isRendererUrl(value)
|
|
}
|
|
|
|
function addRendererHeaders(value: string, headers: Record<string, any>) {
|
|
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
|
|
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
|
|
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
|
}
|
|
|
|
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 wireZoom(win: BrowserWindow) {
|
|
pinchZoomEnabled.set(win, getPinchZoomEnabled())
|
|
win.webContents.setZoomFactor(1)
|
|
win.webContents.on("zoom-changed", (event, zoomDirection) => {
|
|
event.preventDefault()
|
|
if (pinchZoomEnabled.get(win)) {
|
|
win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)))
|
|
updateZoom(win)
|
|
return
|
|
}
|
|
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
|
updateZoom(win)
|
|
})
|
|
}
|
|
|
|
function clampZoom(value: number) {
|
|
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
|
|
}
|
|
|
|
function updateZoom(win: BrowserWindow) {
|
|
updateTitlebar(win)
|
|
win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
|
|
}
|
|
|
|
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
|
|
const keyToChangeLower = keyToChange.toLowerCase()
|
|
for (const key of Object.keys(obj)) {
|
|
if (key.toLowerCase() === keyToChangeLower) {
|
|
// Reassign old key
|
|
obj[key] = value
|
|
// Done
|
|
return
|
|
}
|
|
}
|
|
// Insert at end instead
|
|
obj[keyToChange] = value
|
|
}
|