import windowState from "electron-window-state"
import { app, BrowserWindow, 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"
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])
protocol.registerSchemesAsPrivileged([
{
scheme: rendererProtocol,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
},
},
])
let backgroundColor: string | undefined
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const titlebarHeight = 40
export function setBackgroundColor(color: string) {
backgroundColor = color
}
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 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)
updateTitlebar(win)
}
export function updateTitlebar(win: BrowserWindow) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
}
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() {
const state = windowState({
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,
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 12, 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)
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
upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
callback({ responseHeaders })
})
state.manage(win)
loadWindow(win, "index.html")
wireZoom(win)
win.once("ready-to-show", () => {
win.show()
})
return win
}
export function createLoadingWindow() {
const mode = tone()
const win = new BrowserWindow({
width: 640,
height: 480,
resizable: false,
center: true,
show: true,
autoHideMenuBar: true,
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(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)
loadWindow(win, "loading.html")
return win
}
export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
return new Response("Not found", { status: 404 })
}
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) {
return new Response("Not found", { status: 404 })
}
return net.fetch(pathToFileURL(file).toString())
})
}
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 allowRendererPermissions(win: BrowserWindow) {
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(
rendererPermissions.has(permission) &&
isTrustedRendererUrl(details.requestingUrl) &&
webContents.id === win.webContents.id,
)
})
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
if (!rendererPermissions.has(permission)) return false
if (webContents && webContents.id !== win.webContents.id) return false
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
})
}
function isTrustedRendererUrl(value?: string) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
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) {
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", () => {
win.webContents.setZoomFactor(1)
updateTitlebar(win)
})
}
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) {
obj[key] = value
return
}
}
obj[keyToChange] = value
}