// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { FileService } from "@/app/store/services"; import { adaptFromElectronKeyEvent } from "@/util/keyutil"; import { Rectangle, shell, WebContentsView } from "electron"; import { getWaveWindowById } from "emain/emain-window"; import path from "path"; import { configureAuthKeyRequestInjection } from "./authkey"; import { setWasActive } from "./emain-activity"; import { handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler } from "./emain-util"; import { getElectronAppBasePath, isDevVite } from "./platform"; 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"; } } const wcIdToWaveTabMap = new Map(); export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { return wcIdToWaveTabMap.get(webContentsId); } export class WaveTabView extends WebContentsView { waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) isActiveTab: boolean; private _waveTabId: string; // always set, WaveTabViews are unique per tab lastUsedTs: number; // ts milliseconds createdTs: number; // ts milliseconds initPromise: Promise; initResolve: () => void; savedInitOpts: WaveInitOpts; waveReadyPromise: Promise; waveReadyResolve: () => void; isInitialized: boolean = false; isWaveReady: boolean = false; isDestroyed: boolean = false; constructor(fullConfig: FullConfigType) { console.log("createBareTabView"); super({ webPreferences: { preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), webviewTag: true, }, }); this.createdTs = Date.now(); this.savedInitOpts = null; this.initPromise = new Promise((resolve, _) => { this.initResolve = resolve; }); this.initPromise.then(() => { this.isInitialized = true; console.log("tabview init", Date.now() - this.createdTs + "ms"); }); this.waveReadyPromise = new Promise((resolve, _) => { this.waveReadyResolve = resolve; }); this.waveReadyPromise.then(() => { this.isWaveReady = true; }); wcIdToWaveTabMap.set(this.webContents.id, this); if (isDevVite) { this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); } else { this.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); } this.webContents.on("destroyed", () => { wcIdToWaveTabMap.delete(this.webContents.id); removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); this.setBackgroundColor(computeBgColor(fullConfig)); } get waveTabId(): string { return this._waveTabId; } set waveTabId(waveTabId: string) { this._waveTabId = waveTabId; } positionTabOnScreen(winBounds: Rectangle) { const curBounds = this.getBounds(); if ( curBounds.width == winBounds.width && curBounds.height == winBounds.height && curBounds.x == 0 && curBounds.y == 0 ) { return; } this.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); } positionTabOffScreen(winBounds: Rectangle) { this.setBounds({ x: -15000, y: -15000, width: winBounds.width, height: winBounds.height, }); } isOnScreen() { const bounds = this.getBounds(); return bounds.x == 0 && bounds.y == 0; } destroy() { console.log("destroy tab", this.waveTabId); removeWaveTabView(this.waveTabId); if (!this.isDestroyed) { this.webContents?.close(); } this.isDestroyed = true; } } let MaxCacheSize = 10; const wcvCache = new Map(); export function setMaxTabCacheSize(size: number) { console.log("setMaxTabCacheSize", size); MaxCacheSize = size; } export function getWaveTabView(waveTabId: string): WaveTabView | undefined { const rtn = wcvCache.get(waveTabId); if (rtn) { rtn.lastUsedTs = Date.now(); } return rtn; } function tryEvictEntry(waveTabId: string): boolean { const tabView = wcvCache.get(waveTabId); if (!tabView) { return false; } if (tabView.isActiveTab) { return false; } const lastUsedDiff = Date.now() - tabView.lastUsedTs; if (lastUsedDiff < 1000) { return false; } const ww = getWaveWindowById(tabView.waveWindowId); if (!ww) { // this shouldn't happen, but if it does, just destroy the tabview console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId); tabView.destroy(); return true; } else { // will trigger a destroy on the tabview ww.removeTabView(tabView.waveTabId, false); return true; } } 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; }); const now = Date.now(); for (let i = 0; i < sorted.length - MaxCacheSize; i++) { tryEvictEntry(sorted[i].waveTabId); } } export function clearTabCache() { const wcVals = Array.from(wcvCache.values()); for (let i = 0; i < wcVals.length; i++) { const tabView = wcVals[i]; tryEvictEntry(tabView.waveTabId); } } // returns [tabview, initialized] export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { let tabView = getWaveTabView(tabId); if (tabView) { return [tabView, true]; } const fullConfig = await FileService.GetFullConfig(); tabView = getSpareTab(fullConfig); tabView.waveWindowId = waveWindowId; tabView.lastUsedTs = Date.now(); setWaveTabView(tabId, tabView); tabView.waveTabId = tabId; 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 = 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); 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]; } export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void { if (waveTabId == null) { return; } wcvCache.set(waveTabId, wcv); checkAndEvictCache(); } function removeWaveTabView(waveTabId: string): void { if (waveTabId == null) { return; } wcvCache.delete(waveTabId); } let HotSpareTab: WaveTabView = null; export function ensureHotSpareTab(fullConfig: FullConfigType) { console.log("ensureHotSpareTab"); if (HotSpareTab == null) { HotSpareTab = new WaveTabView(fullConfig); } } export 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 new WaveTabView(fullConfig); } }