249 lines
7.0 KiB
TypeScript
249 lines
7.0 KiB
TypeScript
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) {
|
|
// Reassign old key
|
|
obj[key] = value
|
|
// Done
|
|
return
|
|
}
|
|
}
|
|
// Insert at end instead
|
|
obj[keyToChange] = value
|
|
}
|