// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; import * as path from "path"; import { debounce } from "throttle-debounce"; import { ClientService, FileService, ObjectService, WindowService } from "../frontend/app/store/services"; import * as keyutil from "../frontend/util/keyutil"; import { configureAuthKeyRequestInjection } from "./authkey"; import { getGlobalIsQuitting, getGlobalIsRelaunching, setWasActive, setWasInFg } from "./emain-activity"; import { delay, ensureBoundsAreVisible, handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler, } from "./emain-util"; import { getElectronAppBasePath, isDevVite } from "./platform"; import { updater } from "./updater"; let MaxCacheSize = 10; let HotSpareTab: WaveTabView = null; const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do) const wcvCache = new Map(); const wcIdToWaveTabMap = new Map(); let tabSwitchQueue: { bwin: WaveBrowserWindow; tabView: WaveTabView; tabInitialized: boolean }[] = []; export function setMaxTabCacheSize(size: number) { console.log("setMaxTabCacheSize", size); MaxCacheSize = size; } function computeBgColor(fullConfig: FullConfigType): string { const settings = fullConfig?.settings; const isTransparent = settings?.["window:transparent"] ?? false; const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); if (isTransparent) { return "#00000000"; } else if (isBlur) { return "#00000000"; } else { return "#222222"; } } function createBareTabView(fullConfig: FullConfigType): WaveTabView { console.log("createBareTabView"); const tabView = new electron.WebContentsView({ webPreferences: { preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), webviewTag: true, }, }) as WaveTabView; tabView.createdTs = Date.now(); tabView.savedInitOpts = null; tabView.initPromise = new Promise((resolve, _) => { tabView.initResolve = resolve; }); tabView.initPromise.then(() => { console.log("tabview init", Date.now() - tabView.createdTs + "ms"); }); tabView.waveReadyPromise = new Promise((resolve, _) => { tabView.waveReadyResolve = resolve; }); wcIdToWaveTabMap.set(tabView.webContents.id, tabView); if (isDevVite) { tabView.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); } else { tabView.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); } tabView.webContents.on("destroyed", () => { wcIdToWaveTabMap.delete(tabView.webContents.id); removeWaveTabView(tabView.waveWindowId, tabView.waveTabId); }); tabView.setBackgroundColor(computeBgColor(fullConfig)); return tabView; } function positionTabOffScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) { if (tabView == null) { return; } tabView.setBounds({ x: -15000, y: -15000, width: winBounds.width, height: winBounds.height, }); } async function repositionTabsSlowly(waveWindow: WaveBrowserWindow, delayMs: number) { const activeTabView = waveWindow.activeTabView; const winBounds = waveWindow.getContentBounds(); if (activeTabView == null) { return; } if (isOnScreen(activeTabView)) { activeTabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height, }); } else { activeTabView.setBounds({ x: winBounds.width - 10, y: winBounds.height - 10, width: winBounds.width, height: winBounds.height, }); } await delay(delayMs); if (waveWindow.activeTabView != activeTabView) { // another tab view has been set, do not finalize this layout return; } finalizePositioning(waveWindow); } function isOnScreen(tabView: WaveTabView) { const bounds = tabView.getBounds(); return bounds.x == 0 && bounds.y == 0; } function finalizePositioning(waveWindow: WaveBrowserWindow) { if (waveWindow.isDestroyed()) { return; } const curBounds = waveWindow.getContentBounds(); positionTabOnScreen(waveWindow.activeTabView, curBounds); for (const tabView of waveWindow.allTabViews.values()) { if (tabView == waveWindow.activeTabView) { continue; } positionTabOffScreen(tabView, curBounds); } } function positionTabOnScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) { if (tabView == null) { return; } const curBounds = tabView.getBounds(); if ( curBounds.width == winBounds.width && curBounds.height == winBounds.height && curBounds.x == 0 && curBounds.y == 0 ) { return; } tabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); } export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { return wcIdToWaveTabMap.get(webContentsId); } export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { const tabView = wcIdToWaveTabMap.get(webContentsId); if (tabView == null) { return null; } return waveWindowMap.get(tabView.waveWindowId); } export function getWaveWindowById(windowId: string): WaveBrowserWindow { return waveWindowMap.get(windowId); } export function getAllWaveWindows(): WaveBrowserWindow[] { return Array.from(waveWindowMap.values()); } export function getFocusedWaveWindow(): WaveBrowserWindow { return focusedWaveWindow; } export function ensureHotSpareTab(fullConfig: FullConfigType) { console.log("ensureHotSpareTab"); if (HotSpareTab == null) { HotSpareTab = createBareTabView(fullConfig); } } export function destroyWindow(waveWindow: WaveBrowserWindow) { if (waveWindow == null) { return; } console.log("destroy win", waveWindow.waveWindowId); for (const tabView of waveWindow.allTabViews.values()) { destroyTab(tabView); } waveWindowMap.delete(waveWindow.waveWindowId); } export function destroyTab(tabView: WaveTabView) { if (tabView == null) { return; } console.log("destroy tab", tabView.waveTabId); tabView.webContents.close(); wcIdToWaveTabMap.delete(tabView.webContents.id); removeWaveTabView(tabView.waveWindowId, tabView.waveTabId); const waveWindow = waveWindowMap.get(tabView.waveWindowId); if (waveWindow) { waveWindow.allTabViews.delete(tabView.waveTabId); } } function getSpareTab(fullConfig: FullConfigType): WaveTabView { setTimeout(ensureHotSpareTab, 500); if (HotSpareTab != null) { const rtn = HotSpareTab; HotSpareTab = null; console.log("getSpareTab: returning hotspare"); return rtn; } else { console.log("getSpareTab: creating new tab"); return createBareTabView(fullConfig); } } function getWaveTabView(waveWindowId: string, waveTabId: string): WaveTabView | undefined { const cacheKey = waveWindowId + "|" + waveTabId; const rtn = wcvCache.get(cacheKey); if (rtn) { rtn.lastUsedTs = Date.now(); } return rtn; } function setWaveTabView(waveWindowId: string, waveTabId: string, wcv: WaveTabView): void { const cacheKey = waveWindowId + "|" + waveTabId; wcvCache.set(cacheKey, wcv); checkAndEvictCache(); } function removeWaveTabView(waveWindowId: string, waveTabId: string): void { const cacheKey = waveWindowId + "|" + waveTabId; wcvCache.delete(cacheKey); } function forceRemoveAllTabsForWindow(waveWindowId: string): void { const keys = Array.from(wcvCache.keys()); for (const key of keys) { if (key.startsWith(waveWindowId)) { wcvCache.delete(key); } } } function checkAndEvictCache(): void { if (wcvCache.size <= MaxCacheSize) { return; } const sorted = Array.from(wcvCache.values()).sort((a, b) => { // Prioritize entries which are active if (a.isActiveTab && !b.isActiveTab) { return -1; } // Otherwise, sort by lastUsedTs return a.lastUsedTs - b.lastUsedTs; }); for (let i = 0; i < sorted.length - MaxCacheSize; i++) { if (sorted[i].isActiveTab) { // don't evict WaveTabViews that are currently showing in a window continue; } const tabView = sorted[i]; destroyTab(tabView); } } export function clearTabCache() { const wcVals = Array.from(wcvCache.values()); for (let i = 0; i < wcVals.length; i++) { const tabView = wcVals[i]; if (tabView.isActiveTab) { continue; } destroyTab(tabView); } } // returns [tabview, initialized] function getOrCreateWebViewForTab(fullConfig: FullConfigType, windowId: string, tabId: string): [WaveTabView, boolean] { let tabView = getWaveTabView(windowId, tabId); if (tabView) { return [tabView, true]; } tabView = getSpareTab(fullConfig); tabView.lastUsedTs = Date.now(); tabView.waveTabId = tabId; tabView.waveWindowId = windowId; setWaveTabView(windowId, tabId, tabView); tabView.webContents.on("will-navigate", shNavHandler); tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("did-attach-webview", (event, wc) => { wc.setWindowOpenHandler((details) => { tabView.webContents.send("webview-new-window", wc.id, details); return { action: "deny" }; }); }); tabView.webContents.on("before-input-event", (e, input) => { const waveEvent = keyutil.adaptFromElectronKeyEvent(input); // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); handleCtrlShiftState(tabView.webContents, waveEvent); setWasActive(true); }); tabView.webContents.on("zoom-changed", (e) => { tabView.webContents.send("zoom-changed"); }); tabView.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" }; }); tabView.webContents.on("blur", () => { handleCtrlShiftFocus(tabView.webContents, false); }); configureAuthKeyRequestInjection(tabView.webContents.session); return [tabView, false]; } 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 sending new window bounds to backend", e); } } type WindowOpts = { unamePlatform: string; }; function createBaseWaveBrowserWindow( waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts ): WaveBrowserWindow { console.log("create win", waveWindow.oid); 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.BaseWindowConstructorOptions = { titleBarStyle: opts.unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden", titleBarOverlay: opts.unamePlatform !== "darwin" ? { symbolColor: "white", color: "#00000000", } : false, x: winBounds.x, y: winBounds.y, width: winBounds.width, height: winBounds.height, minWidth: 400, minHeight: 300, icon: opts.unamePlatform == "linux" ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") : undefined, show: false, autoHideMenuBar: !settings?.["window:showmenubar"], }; const isTransparent = settings?.["window:transparent"] ?? false; const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); if (isTransparent) { winOpts.transparent = true; } else if (isBlur) { switch (opts.unamePlatform) { case "win32": { winOpts.backgroundMaterial = "acrylic"; break; } case "darwin": { winOpts.vibrancy = "fullscreen-ui"; break; } } } else { winOpts.backgroundColor = "#222222"; } const bwin = new electron.BaseWindow(winOpts); const win: WaveBrowserWindow = bwin as WaveBrowserWindow; win.waveWindowId = waveWindow.oid; win.alreadyClosed = false; win.allTabViews = new Map(); const winBoundsPoller = setInterval(() => { if (win.isDestroyed()) { clearInterval(winBoundsPoller); return; } if (tabSwitchQueue.length > 0) { return; } finalizePositioning(win); }, 1000); win.on( // @ts-expect-error "resize", debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) ); win.on("resize", () => { if (win.isDestroyed()) { return; } positionTabOnScreen(win.activeTabView, win.getContentBounds()); }); win.on( // @ts-expect-error "move", debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) ); win.on("enter-full-screen", async () => { console.log("enter-full-screen event", win.getContentBounds()); const tabView = win.activeTabView; if (tabView) { tabView.webContents.send("fullscreen-change", true); } positionTabOnScreen(win.activeTabView, win.getContentBounds()); }); win.on("leave-full-screen", async () => { const tabView = win.activeTabView; if (tabView) { tabView.webContents.send("fullscreen-change", false); } positionTabOnScreen(win.activeTabView, win.getContentBounds()); }); win.on("focus", () => { if (getGlobalIsRelaunching()) { return; } focusedWaveWindow = win; console.log("focus win", win.waveWindowId); ClientService.FocusWindow(win.waveWindowId); setWasInFg(true); setWasActive(true); }); win.on("blur", () => { if (focusedWaveWindow == win) { focusedWaveWindow = null; } }); win.on("close", (e) => { console.log("win 'close' handler fired", win.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { return; } const numWindows = waveWindowMap.size; 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(); } else { win.deleteAllowed = true; } }); win.on("closed", () => { console.log("win 'closed' handler fired", win.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing") { return; } if (getGlobalIsRelaunching()) { destroyWindow(win); return; } const numWindows = waveWindowMap.size; if (numWindows == 0) { return; } if (!win.alreadyClosed && win.deleteAllowed) { console.log("win removing window from backend DB", win.waveWindowId); WindowService.CloseWindow(waveWindow.oid, true); } destroyWindow(win); }); waveWindowMap.set(waveWindow.oid, win); return win; } export function getLastFocusedWaveWindow(): WaveBrowserWindow { return focusedWaveWindow; } // 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, opts: WindowOpts ): WaveBrowserWindow { const bwin = createBaseWaveBrowserWindow(waveWindow, fullConfig, opts); // TODO fix null activetabid if it exists if (waveWindow.activetabid != null) { setActiveTab(bwin, waveWindow.activetabid); } return bwin; } export async function setActiveTab(waveWindow: WaveBrowserWindow, tabId: string) { const windowId = waveWindow.waveWindowId; await ObjectService.SetActiveTab(waveWindow.waveWindowId, tabId); const fullConfig = await FileService.GetFullConfig(); const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, windowId, tabId); queueTabSwitch(waveWindow, tabView, tabInitialized); } export function queueTabSwitch(bwin: WaveBrowserWindow, tabView: WaveTabView, tabInitialized: boolean) { if (tabSwitchQueue.length == 2) { tabSwitchQueue[1] = { bwin, tabView, tabInitialized }; return; } tabSwitchQueue.push({ bwin, tabView, tabInitialized }); if (tabSwitchQueue.length == 1) { processTabSwitchQueue(); } } async function processTabSwitchQueue() { if (tabSwitchQueue.length == 0) { tabSwitchQueue = []; return; } try { const { bwin, tabView, tabInitialized } = tabSwitchQueue[0]; await setTabViewIntoWindow(bwin, tabView, tabInitialized); } finally { tabSwitchQueue.shift(); processTabSwitchQueue(); } } async function setTabViewIntoWindow(bwin: WaveBrowserWindow, tabView: WaveTabView, tabInitialized: boolean) { const clientData = await ClientService.GetClientData(); if (bwin.activeTabView == tabView) { return; } const oldActiveView = bwin.activeTabView; tabView.isActiveTab = true; if (oldActiveView != null) { oldActiveView.isActiveTab = false; } bwin.activeTabView = tabView; bwin.allTabViews.set(tabView.waveTabId, tabView); if (!tabInitialized) { console.log("initializing a new tab"); await tabView.initPromise; bwin.contentView.addChildView(tabView); const initOpts = { tabId: tabView.waveTabId, clientId: clientData.oid, windowId: bwin.waveWindowId, activate: true, }; tabView.savedInitOpts = { ...initOpts }; tabView.savedInitOpts.activate = false; let startTime = Date.now(); tabView.webContents.send("wave-init", initOpts); console.log("before wave ready"); await tabView.waveReadyPromise; // positionTabOnScreen(tabView, bwin.getContentBounds()); console.log("wave-ready init time", Date.now() - startTime + "ms"); // positionTabOffScreen(oldActiveView, bwin.getContentBounds()); await repositionTabsSlowly(bwin, 100); } else { console.log("reusing an existing tab"); const p1 = repositionTabsSlowly(bwin, 35); const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit await Promise.all([p1, p2]); } // something is causing the new tab to lose focus so it requires manual refocusing tabView.webContents.focus(); setTimeout(() => { if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 10); setTimeout(() => { if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 30); }