// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ClientService, FileService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; import { fireAndForget } from "@/util/util"; import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron"; import path from "path"; import { debounce } from "throttle-debounce"; import { getGlobalIsQuitting, getGlobalIsRelaunching, setGlobalIsRelaunching, setWasActive, setWasInFg, } from "./emain-activity"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; import { delay, ensureBoundsAreVisible } from "./emain-util"; import { log } from "./log"; import { getElectronAppBasePath, unamePlatform } from "./platform"; import { updater } from "./updater"; export type WindowOpts = { unamePlatform: string; }; export const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow // on blur we do not set this to null (but on destroy we do), so this tracks the *last* focused window // e.g. it persists when the app itself is not focused export let focusedWaveWindow: WaveBrowserWindow = null; let cachedClientId: string = null; async function getClientId() { if (cachedClientId != null) { return cachedClientId; } const clientData = await ClientService.GetClientData(); cachedClientId = clientData?.oid; return cachedClientId; } type WindowActionQueueEntry = | { op: "switchtab"; tabId: string; setInBackend: boolean; } | { op: "createtab"; pinned: boolean; } | { op: "closetab"; tabId: string; } | { op: "switchworkspace"; workspaceId: string; }; function showCloseConfirmDialog(workspace: Workspace): boolean { return !workspace.name && !workspace.icon && (workspace.tabids?.length > 1 || workspace.pinnedtabids?.length > 1); } export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; allLoadedTabViews: Map; activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; private actionQueue: WindowActionQueueEntry[]; constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { 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 = 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: 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"; } super(winOpts); this.actionQueue = []; this.waveWindowId = waveWindow.oid; this.workspaceId = waveWindow.workspaceid; this.allLoadedTabViews = new Map(); const winBoundsPoller = setInterval(() => { if (this.isDestroyed()) { clearInterval(winBoundsPoller); return; } if (this.actionQueue.length > 0) { return; } this.finalizePositioning(); }, 1000); this.on( // @ts-expect-error "resize", debounce(400, (e) => this.mainResizeHandler(e)) ); this.on("resize", () => { if (this.isDestroyed()) { return; } this.activeTabView?.positionTabOnScreen(this.getContentBounds()); }); this.on( // @ts-expect-error "move", debounce(400, (e) => this.mainResizeHandler(e)) ); this.on("enter-full-screen", async () => { if (this.isDestroyed()) { return; } console.log("enter-full-screen event", this.getContentBounds()); const tabView = this.activeTabView; if (tabView) { tabView.webContents.send("fullscreen-change", true); } this.activeTabView?.positionTabOnScreen(this.getContentBounds()); }); this.on("leave-full-screen", async () => { if (this.isDestroyed()) { return; } const tabView = this.activeTabView; if (tabView) { tabView.webContents.send("fullscreen-change", false); } this.activeTabView?.positionTabOnScreen(this.getContentBounds()); }); this.on("focus", () => { if (this.isDestroyed()) { return; } if (getGlobalIsRelaunching()) { return; } focusedWaveWindow = this; console.log("focus win", this.waveWindowId); fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); setWasInFg(true); setWasActive(true); }); this.on("blur", () => { // nothing for now }); this.on("close", (e) => { if (this.canClose) { return; } if (this.isDestroyed()) { return; } console.log("win 'close' handler fired", this.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { return; } e.preventDefault(); fireAndForget(async () => { const numWindows = waveWindowMap.size; if (numWindows > 1) { console.log("numWindows > 1", numWindows); const workspace = await WorkspaceService.GetWorkspace(this.workspaceId); console.log("workspace", workspace); if (showCloseConfirmDialog(workspace)) { console.log("workspace has no name, icon, and multiple tabs", workspace); const choice = dialog.showMessageBoxSync(this, { type: "question", buttons: ["Cancel", "Close Window"], title: "Confirm", message: "Window has unsaved tabs, closing window will delete existing tabs.\n\nContinue?", }); if (choice === 0) { console.log("user cancelled close window", this.waveWindowId); return; } } console.log("deleteAllowed = true", this.waveWindowId); this.deleteAllowed = true; } console.log("canClose = true", this.waveWindowId); this.canClose = true; this.close(); }); }); this.on("closed", () => { console.log("win 'closed' handler fired", this.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing") { console.log("win quitting or updating", this.waveWindowId); return; } waveWindowMap.delete(this.waveWindowId); if (focusedWaveWindow == this) { focusedWaveWindow = null; } this.removeAllChildViews(); if (getGlobalIsRelaunching()) { console.log("win relaunching", this.waveWindowId); this.destroy(); return; } const numWindows = waveWindowMap.size; if (numWindows == 0) { console.log("win no windows left", this.waveWindowId); return; } if (this.deleteAllowed) { console.log("win removing window from backend DB", this.waveWindowId); fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); } }); waveWindowMap.set(waveWindow.oid, this); } private removeAllChildViews() { for (const tabView of this.allLoadedTabViews.values()) { if (!this.isDestroyed()) { this.contentView.removeChildView(tabView); } tabView?.destroy(); } } async switchWorkspace(workspaceId: string) { console.log("switchWorkspace", workspaceId, this.waveWindowId); if (workspaceId == this.workspaceId) { console.log("switchWorkspace already on this workspace", this.waveWindowId); return; } // If the workspace is already owned by a window, then we can just call SwitchWorkspace without first prompting the user, since it'll just focus to the other window. const workspaceList = await WorkspaceService.ListWorkspaces(); if (!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid) { const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); if (showCloseConfirmDialog(curWorkspace)) { const choice = dialog.showMessageBoxSync(this, { type: "question", buttons: ["Cancel", "Open in New Window", "Switch Workspace"], title: "Confirm", message: "Window has unsaved tabs, switching workspaces will delete existing tabs.\n\nContinue?", }); if (choice === 0) { console.log("user cancelled switch workspace", this.waveWindowId); return; } else if (choice === 1) { console.log("user chose open in new window", this.waveWindowId); const newWin = await WindowService.CreateWindow(null, workspaceId); if (!newWin) { console.log("error creating new window", this.waveWindowId); } const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { unamePlatform, }); newBwin.show(); return; } } } await this._queueActionInternal({ op: "switchworkspace", workspaceId }); } async setActiveTab(tabId: string, setInBackend: boolean) { console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend); await this._queueActionInternal({ op: "switchtab", tabId, setInBackend }); } private async initializeTab(tabView: WaveTabView) { const clientId = await getClientId(); await tabView.initPromise; this.contentView.addChildView(tabView); const initOpts = { tabId: tabView.waveTabId, clientId: clientId, windowId: this.waveWindowId, activate: true, }; tabView.savedInitOpts = { ...initOpts }; tabView.savedInitOpts.activate = false; let startTime = Date.now(); console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId); tabView.webContents.send("wave-init", initOpts); await tabView.waveReadyPromise; console.log("wave-ready init time", Date.now() - startTime + "ms"); } private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { if (this.activeTabView == tabView) { return; } const oldActiveView = this.activeTabView; tabView.isActiveTab = true; if (oldActiveView != null) { oldActiveView.isActiveTab = false; } this.activeTabView = tabView; this.allLoadedTabViews.set(tabView.waveTabId, tabView); if (!tabInitialized) { console.log("initializing a new tab"); const p1 = this.initializeTab(tabView); const p2 = this.repositionTabsSlowly(100); await Promise.all([p1, p2]); } else { console.log("reusing an existing tab, calling wave-init", tabView.waveTabId); const p1 = this.repositionTabsSlowly(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 (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 10); setTimeout(() => { if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 30); } private async repositionTabsSlowly(delayMs: number) { const activeTabView = this.activeTabView; const winBounds = this.getContentBounds(); if (activeTabView == null) { return; } if (activeTabView.isOnScreen()) { 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 (this.activeTabView != activeTabView) { // another tab view has been set, do not finalize this layout return; } this.finalizePositioning(); } private finalizePositioning() { if (this.isDestroyed()) { return; } const curBounds = this.getContentBounds(); this.activeTabView?.positionTabOnScreen(curBounds); for (const tabView of this.allLoadedTabViews.values()) { if (tabView == this.activeTabView) { continue; } tabView?.positionTabOffScreen(curBounds); } } async queueCreateTab(pinned = false) { await this._queueActionInternal({ op: "createtab", pinned }); } async queueCloseTab(tabId: string) { await this._queueActionInternal({ op: "closetab", tabId }); } private async _queueActionInternal(entry: WindowActionQueueEntry) { if (this.actionQueue.length >= 2) { this.actionQueue[1] = entry; return; } const wasEmpty = this.actionQueue.length === 0; this.actionQueue.push(entry); if (wasEmpty) { await this.processActionQueue(); } } private removeTabViewLater(tabId: string, delayMs: number) { setTimeout(() => { this.removeTabView(tabId, false); }, 1000); } // the queue and this function are used to serialize operations that update the window contents view // processActionQueue will replace [1] if it is already set // we don't mess with [0] because it is "in process" // we replace [1] because there is no point to run an action that is going to be overwritten private async processActionQueue() { while (this.actionQueue.length > 0) { try { if (this.isDestroyed()) { break; } const entry = this.actionQueue[0]; let tabId: string = null; // have to use "===" here to get the typechecker to work :/ switch (entry.op) { case "createtab": tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, entry.pinned); break; case "switchtab": tabId = entry.tabId; if (this.activeTabView?.waveTabId == tabId) { continue; } if (entry.setInBackend) { await WorkspaceService.SetActiveTab(this.workspaceId, tabId); } break; case "closetab": tabId = entry.tabId; const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); if (rtn == null) { console.log( "[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId ); return; } this.removeTabViewLater(tabId, 1000); if (rtn.closewindow) { this.close(); return; } if (!rtn.newactivetabid) { return; } tabId = rtn.newactivetabid; break; case "switchworkspace": const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId); if (!newWs) { return; } console.log("processActionQueue switchworkspace newWs", newWs); this.removeAllChildViews(); console.log("destroyed all tabs", this.waveWindowId); this.workspaceId = entry.workspaceId; this.allLoadedTabViews = new Map(); tabId = newWs.activetabid; break; } if (tabId == null) { return; } const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); await this.setTabViewIntoWindow(tabView, tabInitialized); } catch (e) { console.log("error caught in processActionQueue", e); } finally { this.actionQueue.shift(); } } } private async mainResizeHandler(_: any) { if (this == null || this.isDestroyed() || this.fullScreen) { return; } const bounds = this.getBounds(); try { await WindowService.SetWindowPosAndSize( this.waveWindowId, { x: bounds.x, y: bounds.y }, { width: bounds.width, height: bounds.height } ); } catch (e) { console.log("error sending new window bounds to backend", e); } } removeTabView(tabId: string, force: boolean) { if (!force && this.activeTabView?.waveTabId == tabId) { console.log("cannot remove active tab", tabId, this.waveWindowId); return; } const tabView = this.allLoadedTabViews.get(tabId); if (tabView == null) { console.log("removeTabView -- tabView not found", tabId, this.waveWindowId); // the tab was never loaded, so just return return; } this.contentView.removeChildView(tabView); this.allLoadedTabViews.delete(tabId); tabView.destroy(); } destroy() { console.log("destroy win", this.waveWindowId); this.deleteAllowed = true; super.destroy(); } } export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { for (const ww of waveWindowMap.values()) { if (ww.allLoadedTabViews.has(tabId)) { return ww; } } } export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { const tabView = getWaveTabViewByWebContentsId(webContentsId); if (tabView == null) { return null; } return getWaveWindowByTabId(tabView.waveTabId); } export function getWaveWindowById(windowId: string): WaveBrowserWindow { return waveWindowMap.get(windowId); } export function getWaveWindowByWorkspaceId(workspaceId: string): WaveBrowserWindow { for (const waveWindow of waveWindowMap.values()) { if (waveWindow.workspaceId === workspaceId) { return waveWindow; } } } export function getAllWaveWindows(): WaveBrowserWindow[] { return Array.from(waveWindowMap.values()); } // note, this does not *show* the window. // to show, await win.readyPromise and then win.show() export async function createBrowserWindow( waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts ): Promise { if (!waveWindow) { console.log("createBrowserWindow: no waveWindow"); waveWindow = await WindowService.CreateWindow(null, ""); } let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); if (!workspace) { console.log("createBrowserWindow: no workspace, creating new window"); await WindowService.CloseWindow(waveWindow.oid, true); waveWindow = await WindowService.CreateWindow(null, ""); workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); } console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace); const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts); if (workspace.activetabid) { await bwin.setActiveTab(workspace.activetabid, false); } return bwin; } ipcMain.on("set-active-tab", async (event, tabId) => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("set-active-tab", tabId, ww?.waveWindowId); await ww?.setActiveTab(tabId, true); }); ipcMain.on("create-tab", async (event, opts) => { const senderWc = event.sender; const ww = getWaveWindowByWebContentsId(senderWc.id); if (ww != null) { await ww.queueCreateTab(); } event.returnValue = true; return null; }); ipcMain.on("close-tab", async (event, workspaceId, tabId) => { const ww = getWaveWindowByWorkspaceId(workspaceId); if (ww == null) { console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`); return; } await ww.queueCloseTab(tabId); event.returnValue = true; return null; }); ipcMain.on("switch-workspace", (event, workspaceId) => { fireAndForget(async () => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("switch-workspace", workspaceId, ww?.waveWindowId); await ww?.switchWorkspace(workspaceId); }); }); export async function createWorkspace(window: WaveBrowserWindow) { if (!window) { return; } const newWsId = await WorkspaceService.CreateWorkspace(); if (newWsId) { await window.switchWorkspace(newWsId); } } ipcMain.on("create-workspace", (event) => { fireAndForget(async () => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("create-workspace", ww?.waveWindowId); await createWorkspace(ww); }); }); ipcMain.on("delete-workspace", (event, workspaceId) => { fireAndForget(async () => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("delete-workspace", workspaceId, ww?.waveWindowId); const workspaceList = await WorkspaceService.ListWorkspaces(); const workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid; const choice = dialog.showMessageBoxSync(this, { type: "question", buttons: ["Cancel", "Delete Workspace"], title: "Confirm", message: `Deleting workspace will also delete its contents.${workspaceHasWindow ? "\nWorkspace is open in a window, which will be closed." : ""}\n\nContinue?`, }); if (choice === 0) { console.log("user cancelled workspace delete", workspaceId, ww?.waveWindowId); return; } await WorkspaceService.DeleteWorkspace(workspaceId); console.log("delete-workspace done", workspaceId, ww?.waveWindowId); if (ww?.workspaceId == workspaceId) { console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId); ww.destroy(); } }); }); export async function createNewWaveWindow() { log("createNewWaveWindow"); const clientData = await ClientService.GetClientData(); const fullConfig = await FileService.GetFullConfig(); let recreatedWindow = false; const allWindows = getAllWaveWindows(); if (allWindows.length === 0 && clientData?.windowids?.length >= 1) { console.log("no windows, but clientData has windowids, recreating first window"); // reopen the first window const existingWindowId = clientData.windowids[0]; const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; if (existingWindowData != null) { const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform }); win.show(); recreatedWindow = true; } } if (recreatedWindow) { console.log("recreated window, returning"); return; } console.log("creating new window"); const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform }); newBrowserWindow.show(); } export async function relaunchBrowserWindows() { console.log("relaunchBrowserWindows"); setGlobalIsRelaunching(true); const windows = getAllWaveWindows(); if (windows.length > 0) { for (const window of windows) { console.log("relaunch -- closing window", window.waveWindowId); window.close(); } await delay(1200); } setGlobalIsRelaunching(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 WindowService.GetWindow(windowId); if (windowData == null) { console.log("relaunch -- window data not found, closing window", windowId); await WindowService.CloseWindow(windowId, true); continue; } console.log("relaunch -- creating window", windowId, windowData); const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform }); wins.push(win); } for (const win of wins) { console.log("show window", win.waveWindowId); win.show(); } }