From 6db414a0f041a78fece4a0ebfbd826de68b10474 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 30 Sep 2024 20:55:10 -0700 Subject: [PATCH] Save --- emain/appstate.ts | 43 ++++ emain/emain.ts | 456 +--------------------------------- emain/globalhotkey.ts | 21 ++ emain/keybindings.ts | 0 emain/window.ts | 430 ++++++++++++++++++++++++++++++++ frontend/types/gotypes.d.ts | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + 8 files changed, 506 insertions(+), 447 deletions(-) create mode 100644 emain/appstate.ts create mode 100644 emain/globalhotkey.ts create mode 100644 emain/keybindings.ts create mode 100644 emain/window.ts diff --git a/emain/appstate.ts b/emain/appstate.ts new file mode 100644 index 000000000..149e095bb --- /dev/null +++ b/emain/appstate.ts @@ -0,0 +1,43 @@ +import { getWebServerEndpoint } from "@/util/endpoints"; +import { BrowserWindow } from "electron"; + +class AppStateType { + isQuitting: boolean; + isStarting: boolean; + isRelaunching: boolean; + + wasInFg: boolean; + wasActive: boolean; + + constructor() { + this.isQuitting = false; + this.isStarting = false; + this.isRelaunching = false; + this.wasInFg = false; + this.wasActive = false; + } + + async logActiveState() { + const activeState = { fg: this.wasInFg, active: this.wasActive, open: true }; + const url = new URL(getWebServerEndpoint() + "/wave/log-active-state"); + try { + const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) }); + if (!resp.ok) { + console.log("error logging active state", resp.status, resp.statusText); + return; + } + } catch (e) { + console.log("error logging active state", e); + } finally { + // for next iteration + this.wasInFg = BrowserWindow.getFocusedWindow()?.isFocused() ?? false; + this.wasActive = false; + } + } + + runActiveTimer() { + this.logActiveState().then(() => setTimeout(this.runActiveTimer, 60000)); + } +} + +export const AppState = new AppStateType(); diff --git a/emain/emain.ts b/emain/emain.ts index 29a7ee68f..0e1152648 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -2,25 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; -import { FastAverageColor } from "fast-average-color"; import fs from "fs"; import * as child_process from "node:child_process"; import * as path from "path"; -import { PNG } from "pngjs"; import * as readline from "readline"; import { sprintf } from "sprintf-js"; -import { debounce } from "throttle-debounce"; import * as util from "util"; import winston from "winston"; import { initGlobal } from "../frontend/app/store/global"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc } from "../frontend/app/store/wshrpcutil"; import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; -import { fetch } from "../frontend/util/fetchutil"; import * as keyutil from "../frontend/util/keyutil"; -import { fireAndForget } from "../frontend/util/util"; +import { AppState } from "./appstate"; import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; +import { configureGlobalHotkey } from "./globalhotkey"; import { getLaunchSettings } from "./launchsettings"; import { getAppMenu } from "./menu"; import { @@ -30,11 +27,11 @@ import { getWaveSrvCwd, getWaveSrvPath, isDev, - isDevVite, unameArch, unamePlatform, } from "./platform"; import { configureAutoUpdater, updater } from "./updater"; +import { createBrowserWindow, createNewWaveWindow, relaunchBrowserWindows } from "./window"; const electronApp = electron.app; let WaveVersion = "unknown"; // set by WAVESRV-ESTART @@ -44,15 +41,10 @@ const WaveAppPathVarName = "WAVETERM_APP_PATH"; const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; electron.nativeTheme.themeSource = "dark"; -type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; - let waveSrvReadyResolve = (value: boolean) => {}; const waveSrvReady: Promise = new Promise((resolve, _) => { waveSrvReadyResolve = resolve; }); -let globalIsQuitting = false; -let globalIsStarting = true; -let globalIsRelaunching = false; // for activity updates let wasActive = true; @@ -106,11 +98,6 @@ if (isDev) { initGlobal({ windowId: null, clientId: null, platform: unamePlatform, environment: "electron" }); -function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { - const windowId = event.sender.id; - return electron.BrowserWindow.fromId(windowId); -} - function setCtrlShift(wc: Electron.WebContents, state: boolean) { wc.send("control-shift-state-update", state); } @@ -165,7 +152,7 @@ function runWaveSrv(): Promise { env: envCopy, }); proc.on("exit", (e) => { - if (globalIsQuitting || updater?.status == "installing") { + if (AppState.isQuitting || updater?.status == "installing") { return; } console.log("wavesrv exited, shutting down"); @@ -250,325 +237,6 @@ async function handleWSEvent(evtMsg: WSEventType) { } } -async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { - if (win == null || win.isDestroyed() || win.fullScreen) { - return; - } - const bounds = win.getBounds(); - try { - await services.WindowService.SetWindowPosAndSize( - windowId, - { x: bounds.x, y: bounds.y }, - { width: bounds.width, height: bounds.height } - ); - } catch (e) { - console.log("error resizing window", e); - } -} - -function shNavHandler(event: Electron.Event, url: string) { - if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { - // this is a dev-mode hot-reload, ignore it - console.log("allowing hot-reload of index.html"); - return; - } - event.preventDefault(); - if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { - console.log("open external, shNav", url); - electron.shell.openExternal(url); - } else { - console.log("navigation canceled", url); - } -} - -function shFrameNavHandler(event: Electron.Event) { - if (!event.frame?.parent) { - // only use this handler to process iframe events (non-iframe events go to shNavHandler) - return; - } - const url = event.url; - console.log(`frame-navigation url=${url} frame=${event.frame.name}`); - if (event.frame.name == "webview") { - // "webview" links always open in new window - // this will *not* effect the initial load because srcdoc does not count as an electron navigation - console.log("open external, frameNav", url); - event.preventDefault(); - electron.shell.openExternal(url); - return; - } - if ( - event.frame.name == "pdfview" && - (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) - ) { - // allowed - return; - } - event.preventDefault(); - console.log("frame navigation canceled"); -} - -// note, this does not *show* the window. -// to show, await win.readyPromise and then win.show() -function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfig: FullConfigType): WaveBrowserWindow { - let winWidth = waveWindow?.winsize?.width; - let winHeight = waveWindow?.winsize?.height; - let winPosX = waveWindow.pos.x; - let winPosY = waveWindow.pos.y; - if (winWidth == null || winWidth == 0) { - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - winWidth = width - winPosX - 100; - if (winWidth > 2000) { - winWidth = 2000; - } - } - if (winHeight == null || winHeight == 0) { - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const { height } = primaryDisplay.workAreaSize; - winHeight = height - winPosY - 100; - if (winHeight > 1200) { - winHeight = 1200; - } - } - let winBounds = { - x: winPosX, - y: winPosY, - width: winWidth, - height: winHeight, - }; - winBounds = ensureBoundsAreVisible(winBounds); - const settings = fullConfig?.settings; - const winOpts: Electron.BrowserWindowConstructorOptions = { - titleBarStyle: - unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden", - titleBarOverlay: - unamePlatform !== "darwin" - ? { - symbolColor: "white", - color: "#00000000", - } - : false, - x: winBounds.x, - y: winBounds.y, - width: winBounds.width, - height: winBounds.height, - minWidth: 400, - minHeight: 300, - icon: - unamePlatform == "linux" - ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") - : undefined, - webPreferences: { - preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, - }, - show: false, - autoHideMenuBar: true, - }; - const isTransparent = settings?.["window:transparent"] ?? false; - const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); - if (isTransparent) { - winOpts.transparent = true; - } else if (isBlur) { - switch (unamePlatform) { - case "win32": { - winOpts.backgroundMaterial = "acrylic"; - break; - } - case "darwin": { - winOpts.vibrancy = "fullscreen-ui"; - break; - } - } - } else { - winOpts.backgroundColor = "#222222"; - } - const bwin = new electron.BrowserWindow(winOpts); - (bwin as any).waveWindowId = waveWindow.oid; - let readyResolve: (value: void) => void; - (bwin as any).readyPromise = new Promise((resolve, _) => { - readyResolve = resolve; - }); - const win: WaveBrowserWindow = bwin as WaveBrowserWindow; - const usp = new URLSearchParams(); - usp.set("clientid", clientId); - usp.set("windowid", waveWindow.oid); - const indexHtml = "index.html"; - if (isDevVite) { - console.log("running as dev server"); - win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`); - } else { - console.log("running as file"); - win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() }); - } - win.once("ready-to-show", () => { - readyResolve(); - }); - win.webContents.on("will-navigate", shNavHandler); - win.webContents.on("will-frame-navigate", shFrameNavHandler); - win.webContents.on("did-attach-webview", (event, wc) => { - wc.setWindowOpenHandler((details) => { - win.webContents.send("webview-new-window", wc.id, details); - return { action: "deny" }; - }); - }); - win.webContents.on("before-input-event", (e, input) => { - const waveEvent = keyutil.adaptFromElectronKeyEvent(input); - // console.log("WIN bie", waveEvent.type, waveEvent.code); - handleCtrlShiftState(win.webContents, waveEvent); - if (win.isFocused()) { - wasActive = true; - } - }); - win.on( - "resize", - debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) - ); - win.on( - "move", - debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) - ); - win.on("focus", () => { - wasInFg = true; - wasActive = true; - if (globalIsStarting) { - return; - } - console.log("focus", waveWindow.oid); - services.ClientService.FocusWindow(waveWindow.oid); - }); - win.on("blur", () => { - handleCtrlShiftFocus(win.webContents, false); - }); - win.on("enter-full-screen", async () => { - win.webContents.send("fullscreen-change", true); - }); - win.on("leave-full-screen", async () => { - win.webContents.send("fullscreen-change", false); - }); - win.on("close", (e) => { - if (globalIsQuitting || updater?.status == "installing") { - return; - } - const numWindows = electron.BrowserWindow.getAllWindows().length; - if (numWindows == 1) { - return; - } - const choice = electron.dialog.showMessageBoxSync(win, { - type: "question", - buttons: ["Cancel", "Yes"], - title: "Confirm", - message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?", - }); - if (choice === 0) { - e.preventDefault(); - } - }); - win.on("closed", () => { - if (globalIsQuitting || updater?.status == "installing") { - return; - } - const numWindows = electron.BrowserWindow.getAllWindows().length; - if (numWindows == 0) { - return; - } - services.WindowService.CloseWindow(waveWindow.oid); - }); - win.webContents.on("zoom-changed", (e) => { - win.webContents.send("zoom-changed"); - }); - win.webContents.setWindowOpenHandler(({ url, frameName }) => { - if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { - console.log("openExternal fallback", url); - electron.shell.openExternal(url); - } - console.log("window-open denied", url); - return { action: "deny" }; - }); - configureAuthKeyRequestInjection(win.webContents.session); - return win; -} - -function isWindowFullyVisible(bounds: electron.Rectangle): boolean { - const displays = electron.screen.getAllDisplays(); - - // Helper function to check if a point is inside any display - function isPointInDisplay(x: number, y: number) { - for (const display of displays) { - const { x: dx, y: dy, width, height } = display.bounds; - if (x >= dx && x < dx + width && y >= dy && y < dy + height) { - return true; - } - } - return false; - } - - // Check all corners of the window - const topLeft = isPointInDisplay(bounds.x, bounds.y); - const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); - const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); - const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); - - return topLeft && topRight && bottomLeft && bottomRight; -} - -function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { - const displays = electron.screen.getAllDisplays(); - let maxArea = 0; - let bestDisplay = null; - - for (let display of displays) { - const { x, y, width, height } = display.bounds; - const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); - const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); - const overlapArea = overlapX * overlapY; - - if (overlapArea > maxArea) { - maxArea = overlapArea; - bestDisplay = display; - } - } - - return bestDisplay; -} - -function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { - const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; - let { x, y, width, height } = bounds; - - // Adjust width and height to fit within the display's work area - width = Math.min(width, dWidth); - height = Math.min(height, dHeight); - - // Adjust x to ensure the window fits within the display - if (x < dx) { - x = dx; - } else if (x + width > dx + dWidth) { - x = dx + dWidth - width; - } - - // Adjust y to ensure the window fits within the display - if (y < dy) { - y = dy; - } else if (y + height > dy + dHeight) { - y = dy + dHeight - height; - } - return { x, y, width, height }; -} - -function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { - if (!isWindowFullyVisible(bounds)) { - let targetDisplay = findDisplayWithMostArea(bounds); - - if (!targetDisplay) { - targetDisplay = electron.screen.getPrimaryDisplay(); - } - - return adjustBoundsToFitDisplay(bounds, targetDisplay); - } - return bounds; -} - // Listen for the open-external event from the renderer process electron.ipcMain.on("open-external", (event, url) => { if (url && typeof url === "string") { @@ -650,59 +318,6 @@ electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { webviewKeys = keys ?? []; }); -if (unamePlatform !== "darwin") { - const fac = new FastAverageColor(); - - electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { - // Bail out if the user requests the native titlebar - const fullConfig = await services.FileService.GetFullConfig(); - if (fullConfig.settings["window:nativetitlebar"]) return; - - const zoomFactor = event.sender.getZoomFactor(); - const electronRect: Electron.Rectangle = { - x: rect.left * zoomFactor, - y: rect.top * zoomFactor, - height: rect.height * zoomFactor, - width: rect.width * zoomFactor, - }; - const overlay = await event.sender.capturePage(electronRect); - const overlayBuffer = overlay.toPNG(); - const png = PNG.sync.read(overlayBuffer); - const color = fac.prepareResult(fac.getColorFromArray4(png.data)); - const window = electron.BrowserWindow.fromWebContents(event.sender); - window.setTitleBarOverlay({ - color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. - symbolColor: color.isDark ? "white" : "black", - }); - }); -} - -async function createNewWaveWindow(): Promise { - const clientData = await services.ClientService.GetClientData(); - const fullConfig = await services.FileService.GetFullConfig(); - let recreatedWindow = false; - if (electron.BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) { - // reopen the first window - const existingWindowId = clientData.windowids[0]; - const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; - if (existingWindowData != null) { - const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig); - await win.readyPromise; - win.show(); - recreatedWindow = true; - } - } - if (recreatedWindow) { - return; - } - const newWindow = await services.ClientService.MakeWindow(); - const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig); - await newBrowserWindow.readyPromise; - newBrowserWindow.show(); -} - -electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); - electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { const window = electron.BrowserWindow.fromWebContents(event.sender); if (menuDefArr?.length === 0) { @@ -716,30 +331,6 @@ electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenu event.returnValue = true; }); -async function logActiveState() { - const activeState = { fg: wasInFg, active: wasActive, open: true }; - const url = new URL(getWebServerEndpoint() + "/wave/log-active-state"); - try { - const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) }); - if (!resp.ok) { - console.log("error logging active state", resp.status, resp.statusText); - return; - } - } catch (e) { - console.log("error logging active state", e); - } finally { - // for next iteration - wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false; - wasActive = false; - } -} - -// this isn't perfect, but gets the job done without being complicated -function runActiveTimer() { - logActiveState(); - setTimeout(runActiveTimer, 60000); -} - function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electron.Menu { const menuItems: electron.MenuItem[] = []; for (const menuDef of menuDefArr) { @@ -771,7 +362,7 @@ function makeAppMenu() { } electronApp.on("window-all-closed", () => { - if (globalIsRelaunching) { + if (AppState.isRelaunching) { return; } if (unamePlatform !== "darwin") { @@ -779,7 +370,7 @@ electronApp.on("window-all-closed", () => { } }); electronApp.on("before-quit", () => { - globalIsQuitting = true; + AppState.isQuitting = true; updater?.stop(); }); process.on("SIGINT", () => { @@ -805,36 +396,6 @@ process.on("uncaughtException", (error) => { electronApp.quit(); }); -async function relaunchBrowserWindows(): Promise { - globalIsRelaunching = true; - const windows = electron.BrowserWindow.getAllWindows(); - for (const window of windows) { - window.removeAllListeners(); - window.close(); - } - globalIsRelaunching = false; - - const clientData = await services.ClientService.GetClientData(); - const fullConfig = await services.FileService.GetFullConfig(); - const wins: WaveBrowserWindow[] = []; - for (const windowId of clientData.windowids.slice().reverse()) { - const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; - if (windowData == null) { - services.WindowService.CloseWindow(windowId).catch((e) => { - /* ignore */ - }); - continue; - } - const win = createBrowserWindow(clientData.oid, windowData, fullConfig); - wins.push(win); - } - for (const win of wins) { - await win.readyPromise; - console.log("show", win.waveWindowId); - win.show(); - } -} - process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error); console.error("Stack Trace:", error.stack); @@ -871,7 +432,8 @@ async function appMain() { await electronApp.whenReady(); configureAuthKeyRequestInjection(electron.session.defaultSession); await relaunchBrowserWindows(); - setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe + await configureGlobalHotkey(); + setTimeout(AppState.runActiveTimer, 5000); // start active timer, wait 5s just to be safe try { initElectronWshClient(); initElectronWshrpc(ElectronWshClient, { authKey: AuthKey }); @@ -880,7 +442,7 @@ async function appMain() { } await configureAutoUpdater(); - globalIsStarting = false; + AppState.isStarting = false; electronApp.on("activate", async () => { if (electron.BrowserWindow.getAllWindows().length === 0) { diff --git a/emain/globalhotkey.ts b/emain/globalhotkey.ts new file mode 100644 index 000000000..d8cfde088 --- /dev/null +++ b/emain/globalhotkey.ts @@ -0,0 +1,21 @@ +import { FileService } from "@/app/store/services"; +import { fireAndForget } from "@/util/util"; +import { app, globalShortcut } from "electron"; + +async function hotkeyCallback() { + app.focus(); +} + +export async function configureGlobalHotkey() { + const settings = (await FileService.GetFullConfig())?.settings; + + const globalhotkey = settings["window:globalhotkey"]; + if (globalhotkey) { + console.log(`Registering global hotkey: "${globalhotkey}"`); + + await app.whenReady(); + globalShortcut.register(globalhotkey, () => fireAndForget(() => hotkeyCallback())); + } + + app.on("before-quit", () => globalShortcut.unregisterAll()); +} diff --git a/emain/keybindings.ts b/emain/keybindings.ts new file mode 100644 index 000000000..e69de29bb diff --git a/emain/window.ts b/emain/window.ts new file mode 100644 index 000000000..5a0a2148b --- /dev/null +++ b/emain/window.ts @@ -0,0 +1,430 @@ +import { ClientService, FileService, ObjectService, WindowService } from "@/app/store/services"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { adaptFromElectronKeyEvent } from "@/util/keyutil"; +import { fireAndForget } from "@/util/util"; +import { + BrowserWindow, + BrowserWindowConstructorOptions, + dialog, + Display, + ipcMain, + Rectangle, + screen, + shell, +} from "electron"; +import { FastAverageColor } from "fast-average-color"; +import path from "node:path"; +import { PNG } from "pngjs"; +import { debounce } from "throttle-debounce"; +import { AppState } from "./appstate"; +import { configureAuthKeyRequestInjection } from "./authkey"; +import { getElectronAppBasePath, isDevVite, unamePlatform } from "./platform"; +import { updater } from "./updater"; + +type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; + +// note, this does not *show* the window. +// to show, await win.readyPromise and then win.show() +export function createBrowserWindow( + clientId: string, + waveWindow: WaveWindow, + fullConfig: FullConfigType +): WaveBrowserWindow { + let winWidth = waveWindow?.winsize?.width; + let winHeight = waveWindow?.winsize?.height; + let winPosX = waveWindow.pos.x; + let winPosY = waveWindow.pos.y; + if (winWidth == null || winWidth == 0) { + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + winWidth = width - winPosX - 100; + if (winWidth > 2000) { + winWidth = 2000; + } + } + if (winHeight == null || winHeight == 0) { + const primaryDisplay = screen.getPrimaryDisplay(); + const { height } = primaryDisplay.workAreaSize; + winHeight = height - winPosY - 100; + if (winHeight > 1200) { + winHeight = 1200; + } + } + let winBounds = { + x: winPosX, + y: winPosY, + width: winWidth, + height: winHeight, + }; + winBounds = ensureBoundsAreVisible(winBounds); + const settings = fullConfig?.settings; + const winOpts: BrowserWindowConstructorOptions = { + titleBarStyle: + unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden", + titleBarOverlay: + unamePlatform !== "darwin" + ? { + symbolColor: "white", + color: "#00000000", + } + : false, + x: winBounds.x, + y: winBounds.y, + width: winBounds.width, + height: winBounds.height, + minWidth: 400, + minHeight: 300, + icon: + unamePlatform == "linux" + ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") + : undefined, + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + show: false, + autoHideMenuBar: true, + }; + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + if (isTransparent) { + winOpts.transparent = true; + } else if (isBlur) { + switch (unamePlatform) { + case "win32": { + winOpts.backgroundMaterial = "acrylic"; + break; + } + case "darwin": { + winOpts.vibrancy = "fullscreen-ui"; + break; + } + } + } else { + winOpts.backgroundColor = "#222222"; + } + const bwin = new BrowserWindow(winOpts); + (bwin as any).waveWindowId = waveWindow.oid; + let readyResolve: (value: void) => void; + (bwin as any).readyPromise = new Promise((resolve, _) => { + readyResolve = resolve; + }); + const win: WaveBrowserWindow = bwin as WaveBrowserWindow; + const usp = new URLSearchParams(); + usp.set("clientid", clientId); + usp.set("windowid", waveWindow.oid); + const indexHtml = "index.html"; + if (isDevVite) { + console.log("running as dev server"); + win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`); + } else { + console.log("running as file"); + win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() }); + } + win.once("ready-to-show", () => { + readyResolve(); + }); + win.webContents.on("will-navigate", shNavHandler); + win.webContents.on("will-frame-navigate", shFrameNavHandler); + win.webContents.on("did-attach-webview", (event, wc) => { + wc.setWindowOpenHandler((details) => { + win.webContents.send("webview-new-window", wc.id, details); + return { action: "deny" }; + }); + }); + win.webContents.on("before-input-event", (e, input) => { + const waveEvent = adaptFromElectronKeyEvent(input); + // console.log("WIN bie", waveEvent.type, waveEvent.code); + handleCtrlShiftState(win.webContents, waveEvent); + if (win.isFocused()) { + AppState.wasActive = true; + } + }); + win.on( + "resize", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on( + "move", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on("focus", () => { + AppState.wasInFg = true; + AppState.wasActive = true; + if (AppState.isStarting) { + return; + } + console.log("focus", waveWindow.oid); + ClientService.FocusWindow(waveWindow.oid); + }); + win.on("blur", () => { + handleCtrlShiftFocus(win.webContents, false); + }); + win.on("enter-full-screen", async () => { + win.webContents.send("fullscreen-change", true); + }); + win.on("leave-full-screen", async () => { + win.webContents.send("fullscreen-change", false); + }); + win.on("close", (e) => { + if (AppState.isQuitting || updater?.status == "installing") { + return; + } + const numWindows = BrowserWindow.getAllWindows().length; + if (numWindows == 1) { + return; + } + const choice = dialog.showMessageBoxSync(win, { + type: "question", + buttons: ["Cancel", "Yes"], + title: "Confirm", + message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?", + }); + if (choice === 0) { + e.preventDefault(); + } + }); + win.on("closed", () => { + if (AppState.isQuitting || updater?.status == "installing") { + return; + } + const numWindows = BrowserWindow.getAllWindows().length; + if (numWindows == 0) { + return; + } + WindowService.CloseWindow(waveWindow.oid); + }); + win.webContents.on("zoom-changed", (e) => { + win.webContents.send("zoom-changed"); + }); + win.webContents.setWindowOpenHandler(({ url, frameName }) => { + if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); + shell.openExternal(url); + } + console.log("window-open denied", url); + return { action: "deny" }; + }); + configureAuthKeyRequestInjection(win.webContents.session); + return win; +} + +export async function createNewWaveWindow(): Promise { + const clientData = await ClientService.GetClientData(); + const fullConfig = await FileService.GetFullConfig(); + let recreatedWindow = false; + if (BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) { + // reopen the first window + const existingWindowId = clientData.windowids[0]; + const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; + if (existingWindowData != null) { + const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig); + await win.readyPromise; + win.show(); + recreatedWindow = true; + } + } + if (recreatedWindow) { + return; + } + const newWindow = await ClientService.MakeWindow(); + const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig); + await newBrowserWindow.readyPromise; + newBrowserWindow.show(); +} + +export async function relaunchBrowserWindows(): Promise { + AppState.isRelaunching = true; + const windows = BrowserWindow.getAllWindows(); + for (const window of windows) { + window.removeAllListeners(); + window.close(); + } + AppState.isRelaunching = false; + + const clientData = await ClientService.GetClientData(); + const fullConfig = await FileService.GetFullConfig(); + const wins: WaveBrowserWindow[] = []; + for (const windowId of clientData.windowids.slice().reverse()) { + const windowData: WaveWindow = (await ObjectService.GetObject("window:" + windowId)) as WaveWindow; + if (windowData == null) { + WindowService.CloseWindow(windowId).catch((e) => { + /* ignore */ + }); + continue; + } + const win = createBrowserWindow(clientData.oid, windowData, fullConfig); + wins.push(win); + } + for (const win of wins) { + await win.readyPromise; + console.log("show", win.waveWindowId); + win.show(); + } +} + +async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { + if (win == null || win.isDestroyed() || win.fullScreen) { + return; + } + const bounds = win.getBounds(); + try { + await WindowService.SetWindowPosAndSize( + windowId, + { x: bounds.x, y: bounds.y }, + { width: bounds.width, height: bounds.height } + ); + } catch (e) { + console.log("error resizing window", e); + } +} + +ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); + +function shNavHandler(event: Electron.Event, url: string) { + if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { + // this is a dev-mode hot-reload, ignore it + console.log("allowing hot-reload of index.html"); + return; + } + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } +} + +function shFrameNavHandler(event: Electron.Event) { + if (!event.frame?.parent) { + // only use this handler to process iframe events (non-iframe events go to shNavHandler) + return; + } + const url = event.url; + console.log(`frame-navigation url=${url} frame=${event.frame.name}`); + if (event.frame.name == "webview") { + // "webview" links always open in new window + // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); + event.preventDefault(); + shell.openExternal(url); + return; + } + if ( + event.frame.name == "pdfview" && + (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) + ) { + // allowed + return; + } + event.preventDefault(); + console.log("frame navigation canceled"); +} + +function isWindowFullyVisible(bounds: Rectangle): boolean { + const displays = screen.getAllDisplays(); + + // Helper function to check if a point is inside any display + function isPointInDisplay(x: number, y: number) { + for (const display of displays) { + const { x: dx, y: dy, width, height } = display.bounds; + if (x >= dx && x < dx + width && y >= dy && y < dy + height) { + return true; + } + } + return false; + } + + // Check all corners of the window + const topLeft = isPointInDisplay(bounds.x, bounds.y); + const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); + const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); + const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); + + return topLeft && topRight && bottomLeft && bottomRight; +} + +function findDisplayWithMostArea(bounds: Rectangle): Display { + const displays = screen.getAllDisplays(); + let maxArea = 0; + let bestDisplay = null; + + for (let display of displays) { + const { x, y, width, height } = display.bounds; + const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); + const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); + const overlapArea = overlapX * overlapY; + + if (overlapArea > maxArea) { + maxArea = overlapArea; + bestDisplay = display; + } + } + + return bestDisplay; +} + +function adjustBoundsToFitDisplay(bounds: Rectangle, display: Display): Rectangle { + const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; + let { x, y, width, height } = bounds; + + // Adjust width and height to fit within the display's work area + width = Math.min(width, dWidth); + height = Math.min(height, dHeight); + + // Adjust x to ensure the window fits within the display + if (x < dx) { + x = dx; + } else if (x + width > dx + dWidth) { + x = dx + dWidth - width; + } + + // Adjust y to ensure the window fits within the display + if (y < dy) { + y = dy; + } else if (y + height > dy + dHeight) { + y = dy + dHeight - height; + } + return { x, y, width, height }; +} + +function ensureBoundsAreVisible(bounds: Rectangle): Rectangle { + if (!isWindowFullyVisible(bounds)) { + let targetDisplay = findDisplayWithMostArea(bounds); + + if (!targetDisplay) { + targetDisplay = screen.getPrimaryDisplay(); + } + + return adjustBoundsToFitDisplay(bounds, targetDisplay); + } + return bounds; +} + +if (unamePlatform !== "darwin") { + const fac = new FastAverageColor(); + + ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { + // Bail out if the user requests the native titlebar + const fullConfig = await FileService.GetFullConfig(); + if (fullConfig.settings["window:nativetitlebar"]) return; + + const zoomFactor = event.sender.getZoomFactor(); + const electronRect: Rectangle = { + x: rect.left * zoomFactor, + y: rect.top * zoomFactor, + height: rect.height * zoomFactor, + width: rect.width * zoomFactor, + }; + const overlay = await event.sender.capturePage(electronRect); + const overlayBuffer = overlay.toPNG(); + const png = PNG.sync.read(overlayBuffer); + const color = fac.prepareResult(fac.getColorFromArray4(png.data)); + const window = BrowserWindow.fromWebContents(event.sender); + window.setTitleBarOverlay({ + color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. + symbolColor: color.isDark ? "white" : "black", + }); + }); +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 155980507..84a57f941 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -443,6 +443,7 @@ declare global { "window:tilegapsize"?: number; "window:nativetitlebar"?: boolean; "window:disablehardwareacceleration"?: boolean; + "window:globalhotkey"?: string; "telemetry:*"?: boolean; "telemetry:enabled"?: boolean; }; diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 847a7df60..0ea0fe6ec 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -49,6 +49,7 @@ const ( ConfigKey_WindowTileGapSize = "window:tilegapsize" ConfigKey_WindowNativeTitleBar = "window:nativetitlebar" ConfigKey_WindowDisableHardwareAcceleration = "window:disablehardwareacceleration" + ConfigKey_WindowGlobalHotkey = "window:globalhotkey" ConfigKey_TelemetryClear = "telemetry:*" ConfigKey_TelemetryEnabled = "telemetry:enabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index a8a01f7b5..2eeba23cb 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -83,6 +83,7 @@ type SettingsType struct { WindowTileGapSize *int64 `json:"window:tilegapsize,omitempty"` WindowNativeTitleBar bool `json:"window:nativetitlebar,omitempty"` WindowDisableHardwareAcceleration bool `json:"window:disablehardwareacceleration,omitempty"` + WindowGlobalHotkey string `json:"window:globalhotkey,omitempty"` TelemetryClear bool `json:"telemetry:*,omitempty"` TelemetryEnabled bool `json:"telemetry:enabled,omitempty"`