import { BrowserWindow, WebContentsView, View, Menu, clipboard, ipcMain, shell } from 'electron'; import { TAB_BAR_DATA_URL } from './tab-bar-html'; import { ALL_CLIENT_CSS } from './client-ui'; import { electronLog } from './logger'; const KRUNKER_SOCIAL = 'https://krunker.io/social.html'; const TAB_BAR_HEIGHT = 40; const MAX_TABS = 20; interface TabInfo { id: number; view: WebContentsView; title: string; url: string; loading: boolean; } interface TabWindowState { width: number; height: number; x: number | undefined; y: number | undefined; maximized: boolean; } type TabMode = 'same' | 'new'; export class TabManager { private tabs: TabInfo[] = []; private activeTabId: number | null = null; private tabBarView: WebContentsView; private containerView: View; private tabWindow: BrowserWindow | null = null; private visible = false; private nextId = 1; private mode: TabMode; private mainWin: BrowserWindow; private ses: Electron.Session; private preloadPath: string; private isGameURL: (url: string) => boolean; private titlePolls = new Map>(); private recentlyClosed: { url: string; title: string }[] = []; private getTabWindowState: () => TabWindowState; private saveTabWindowState: (state: TabWindowState) => void; private tabSaveTimer: ReturnType | null = null; constructor( win: BrowserWindow, ses: Electron.Session, preloadPath: string, mode: TabMode, isGameURL: (url: string) => boolean, getTabWindowState: () => TabWindowState, saveTabWindowState: (state: TabWindowState) => void, ) { this.mainWin = win; this.ses = ses; this.preloadPath = preloadPath; this.mode = mode; this.isGameURL = isGameURL; this.getTabWindowState = getTabWindowState; this.saveTabWindowState = saveTabWindowState; // ── Tab bar view (shared between both modes) ── this.tabBarView = new WebContentsView({ webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, }, }); this.tabBarView.webContents.loadURL(TAB_BAR_DATA_URL); // ── Container view (holds tab bar + active tab content) ── this.containerView = new View(); this.containerView.addChildView(this.tabBarView); // Tab bar keybinds (when tab bar itself is focused) this.tabBarView.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; if (this.handleTabShortcut(event, input)) return; }); if (mode === 'same') { this.initSameWindowMode(); } // 'new' mode: tabWindow created lazily on first openTab() this.registerIPC(); } // ── Same Window Mode Setup ── private initSameWindowMode(): void { this.mainWin.contentView.addChildView(this.containerView); this.containerView.setVisible(false); this.visible = false; this.mainWin.on('resize', () => this.updateLayout()); } // ── New Window Mode: create/show the tab window ── private ensureTabWindow(): void { if (this.tabWindow && !this.tabWindow.isDestroyed()) return; const saved = this.getTabWindowState(); this.tabWindow = new BrowserWindow({ width: saved.width, height: saved.height, x: saved.x, y: saved.y, frame: true, backgroundColor: '#000000', autoHideMenuBar: true, title: 'KCC - Tabs', show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }, }); this.tabWindow.removeMenu(); if (saved.maximized) this.tabWindow.maximize(); this.tabWindow.contentView.addChildView(this.containerView); this.containerView.setVisible(true); this.tabWindow.on('resize', () => { this.updateLayout(); this.debounceSaveTabWindow(); }); this.tabWindow.on('move', () => this.debounceSaveTabWindow()); this.tabWindow.on('close', () => { // Flush pending save before the window is destroyed if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer); if (this.tabWindow && !this.tabWindow.isDestroyed()) { const bounds = this.tabWindow.getBounds(); this.saveTabWindowState({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, maximized: this.tabWindow.isMaximized(), }); } }); this.tabWindow.on('closed', () => { this.destroyAllTabs(); this.tabWindow = null; }); this.tabWindow.show(); } private debounceSaveTabWindow(): void { if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer); this.tabSaveTimer = setTimeout(() => { if (!this.tabWindow || this.tabWindow.isDestroyed()) return; const bounds = this.tabWindow.getBounds(); this.saveTabWindowState({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, maximized: this.tabWindow.isMaximized(), }); }, 1000); } // ── IPC from tab bar ── private registerIPC(): void { ipcMain.on('tab-switch', (_e, id: number) => this.switchToTab(id)); ipcMain.on('tab-close', (_e, id: number) => this.closeTab(id)); ipcMain.on('tab-new', () => this.openTab(KRUNKER_SOCIAL)); ipcMain.on('tab-reorder', (_e, fromId: number, toId: number, side: string) => { this.reorderTab(fromId, toId, side as 'before' | 'after'); }); ipcMain.on('tab-back-to-game', () => { if (this.mode === 'same') { this.hideTabs(); } else { this.mainWin.focus(); } }); } // ── Open a new tab ── openTab(url: string): number { if (this.tabs.length >= MAX_TABS) { const existing = this.tabs.find(t => t.url === url); if (existing) { this.switchToTab(existing.id); return existing.id; } electronLog.warn('[KCC-Tabs] Tab limit reached, ignoring openTab'); return -1; } const id = this.nextId++; const view = this.createTabView(id); const tab: TabInfo = { id, view, title: this.titleFromUrl(url), url, loading: true }; this.tabs.push(tab); if (this.mode === 'new') { this.ensureTabWindow(); } this.switchToTab(id); this.showTabs(); view.webContents.loadURL(url); return id; } // ── Create a WebContentsView for a tab ── private createTabView(tabId: number): WebContentsView { const view = new WebContentsView({ webPreferences: { preload: this.preloadPath, session: this.ses, contextIsolation: false, nodeIntegration: false, sandbox: false, spellcheck: false, }, }); const wc = view.webContents; wc.on('did-finish-load', () => { wc.insertCSS(ALL_CLIENT_CSS).catch(() => {}); wc.send('main_did-finish-load-tab'); ipcMain.emit('throttle-state', { sender: wc } as any, 'menu'); this.updateTabInfo(tabId, { loading: false }); this.startTitleWatcher(tabId, wc); }); wc.on('did-start-loading', () => { this.updateTabInfo(tabId, { loading: true }); }); wc.on('did-stop-loading', () => { this.updateTabInfo(tabId, { loading: false }); }); wc.on('page-title-updated', (_e, title) => { if (this.isGenericTitle(title)) return; this.updateTabInfo(tabId, { title }); }); wc.on('did-navigate', (_e, url) => { this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) }); }); wc.setWindowOpenHandler(({ url: linkUrl }) => { if (linkUrl.includes('krunker.io')) { if (this.isGameURL(linkUrl)) { this.mainWin.loadURL(linkUrl); if (this.mode === 'same') this.hideTabs(); else this.mainWin.focus(); } else { setImmediate(() => this.openTab(linkUrl)); } } else { setImmediate(() => shell.openExternal(linkUrl)); } return { action: 'deny' as const }; }); wc.on('will-navigate', (event, navUrl) => { if (navUrl.includes('krunker.io') && this.isGameURL(navUrl)) { event.preventDefault(); this.mainWin.loadURL(navUrl); if (this.mode === 'same') this.hideTabs(); else this.mainWin.focus(); } }); wc.on('context-menu', (_e, params) => { if (!params.linkURL) return; const items: Electron.MenuItemConstructorOptions[] = []; if (params.linkURL.includes('krunker.io') && !this.isGameURL(params.linkURL)) { items.push({ label: 'Open in New Tab', click: () => this.openTab(params.linkURL) }); } items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) }); if (!params.linkURL.includes('krunker.io')) { items.push({ label: 'Open in Browser', click: () => shell.openExternal(params.linkURL) }); } if (items.length) Menu.buildFromTemplate(items).popup(); }); wc.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; if (this.handleTabShortcut(event, input)) return; if (input.key === 'F12' && !input.control && !input.shift && !input.alt) { wc.toggleDevTools(); event.preventDefault(); } }); return view; } // ── Switch active tab ── switchToTab(id: number): void { const tab = this.tabs.find(t => t.id === id); if (!tab) return; if (this.activeTabId !== null) { const prev = this.tabs.find(t => t.id === this.activeTabId); if (prev) { this.containerView.removeChildView(prev.view); } } this.activeTabId = id; this.containerView.addChildView(tab.view); this.updateLayout(); this.broadcastTabState(); } // ── Close a tab ── closeTab(id: number): void { const idx = this.tabs.findIndex(t => t.id === id); if (idx === -1) return; const tab = this.tabs[idx]; if (this.activeTabId === id) { this.containerView.removeChildView(tab.view); this.activeTabId = null; } this.recentlyClosed.push({ url: tab.url, title: tab.title }); if (this.recentlyClosed.length > 10) this.recentlyClosed.shift(); this.stopTitleWatcher(id); tab.view.webContents.close(); this.tabs.splice(idx, 1); if (this.tabs.length > 0) { const nextIdx = Math.min(idx, this.tabs.length - 1); this.switchToTab(this.tabs[nextIdx].id); } else { if (this.mode === 'same') { this.hideTabs(); } else { if (this.tabWindow && !this.tabWindow.isDestroyed()) { this.tabWindow.contentView.removeChildView(this.containerView); this.tabWindow.close(); } } } this.broadcastTabState(); } // ── Show / hide tabs ── showTabs(): void { if (this.mode === 'same') { this.containerView.setVisible(true); this.visible = true; this.updateLayout(); } else { this.ensureTabWindow(); if (this.tabWindow && !this.tabWindow.isDestroyed()) { this.tabWindow.show(); this.tabWindow.focus(); } this.visible = true; } } hideTabs(): void { if (this.mode === 'same') { this.containerView.setVisible(false); this.visible = false; this.mainWin.focus(); } else { this.mainWin.focus(); this.visible = false; } } // ── Tab navigation ── nextTab(): void { if (this.tabs.length < 2 || this.activeTabId === null) return; const idx = this.tabs.findIndex(t => t.id === this.activeTabId); const next = (idx + 1) % this.tabs.length; this.switchToTab(this.tabs[next].id); } prevTab(): void { if (this.tabs.length < 2 || this.activeTabId === null) return; const idx = this.tabs.findIndex(t => t.id === this.activeTabId); const prev = (idx - 1 + this.tabs.length) % this.tabs.length; this.switchToTab(this.tabs[prev].id); } closeCurrentTab(): void { if (this.activeTabId !== null) this.closeTab(this.activeTabId); } // ── Reorder tabs via drag ── reorderTab(fromId: number, toId: number, side: 'before' | 'after'): void { const fromIdx = this.tabs.findIndex(t => t.id === fromId); const toIdx = this.tabs.findIndex(t => t.id === toId); if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return; const [tab] = this.tabs.splice(fromIdx, 1); let insertIdx = this.tabs.findIndex(t => t.id === toId); if (side === 'after') insertIdx++; this.tabs.splice(insertIdx, 0, tab); this.broadcastTabState(); } // ── Jump to tab by position (0-based, -1 = last) ── switchToTabByIndex(index: number): void { if (this.tabs.length === 0) return; if (index < 0 || index >= this.tabs.length) index = this.tabs.length - 1; this.switchToTab(this.tabs[index].id); } // ── Reopen last closed tab ── reopenTab(): void { const entry = this.recentlyClosed.pop(); if (entry) this.openTab(entry.url); } // ── Shared shortcut handler (returns true if handled) ── private handleTabShortcut(event: Electron.Event, input: Electron.Input): boolean { if (input.key === 'Escape' && !input.control && !input.shift && !input.alt) { if (this.mode === 'same') this.hideTabs(); else this.mainWin.focus(); event.preventDefault(); return true; } else if (input.key === 'w' && input.control && !input.shift && !input.alt) { this.closeCurrentTab(); event.preventDefault(); return true; } else if (input.key === 'Tab' && input.control && !input.shift && !input.alt) { this.nextTab(); event.preventDefault(); return true; } else if (input.key === 'Tab' && input.control && input.shift && !input.alt) { this.prevTab(); event.preventDefault(); return true; } else if (input.key === 't' && input.control && !input.shift && !input.alt) { this.openTab(KRUNKER_SOCIAL); event.preventDefault(); return true; } else if (input.key === 'T' && input.control && input.shift && !input.alt) { this.reopenTab(); event.preventDefault(); return true; } else if (input.key >= '1' && input.key <= '8' && input.control && !input.shift && !input.alt) { this.switchToTabByIndex(parseInt(input.key) - 1); event.preventDefault(); return true; } else if (input.key === '9' && input.control && !input.shift && !input.alt) { this.switchToTabByIndex(-1); event.preventDefault(); return true; } return false; } // ── Cleanup ── destroyAll(): void { this.destroyAllTabs(); ipcMain.removeAllListeners('tab-switch'); ipcMain.removeAllListeners('tab-close'); ipcMain.removeAllListeners('tab-new'); ipcMain.removeAllListeners('tab-reorder'); ipcMain.removeAllListeners('tab-back-to-game'); if (this.tabWindow && !this.tabWindow.isDestroyed()) { this.tabWindow.contentView.removeChildView(this.containerView); this.tabWindow.close(); this.tabWindow = null; } if (this.mode === 'same') { try { this.mainWin.contentView.removeChildView(this.containerView); } catch { /* may already be removed */ } } } private destroyAllTabs(): void { for (const tab of this.tabs) { this.stopTitleWatcher(tab.id); if (this.activeTabId === tab.id) { this.containerView.removeChildView(tab.view); } if (!tab.view.webContents.isDestroyed()) { tab.view.webContents.close(); } } this.tabs = []; this.activeTabId = null; this.broadcastTabState(); } // ── Layout ── private updateLayout(): void { let bounds: { width: number; height: number }; if (this.mode === 'same') { const [w, h] = this.mainWin.getContentSize(); bounds = { width: w, height: h }; this.containerView.setBounds({ x: 0, y: 0, width: w, height: h }); } else if (this.tabWindow && !this.tabWindow.isDestroyed()) { const [w, h] = this.tabWindow.getContentSize(); bounds = { width: w, height: h }; this.containerView.setBounds({ x: 0, y: 0, width: w, height: h }); } else { return; } this.tabBarView.setBounds({ x: 0, y: 0, width: bounds.width, height: TAB_BAR_HEIGHT, }); if (this.activeTabId !== null) { const tab = this.tabs.find(t => t.id === this.activeTabId); if (tab) { tab.view.setBounds({ x: 0, y: TAB_BAR_HEIGHT, width: bounds.width, height: bounds.height - TAB_BAR_HEIGHT, }); } } } // ── Update tab metadata and broadcast ── private updateTabInfo(id: number, updates: Partial>): void { const tab = this.tabs.find(t => t.id === id); if (!tab) return; if (updates.title !== undefined) tab.title = updates.title; if (updates.url !== undefined) tab.url = updates.url; if (updates.loading !== undefined) tab.loading = updates.loading; this.broadcastTabState(); } private broadcastTabState(): void { if (this.tabBarView.webContents.isDestroyed()) return; const data = this.tabs.map(t => ({ id: t.id, title: t.title, active: t.id === this.activeTabId, loading: t.loading, })); this.tabBarView.webContents.send('tabs-update', data); } private static readonly GENERIC_TITLES = new Set([ 'krunker hub', 'krunker', 'krunker.io', '', 'hub', 'social', 'profile', 'new tab', 'loading...', ]); private isGenericTitle(title: string): boolean { return TabManager.GENERIC_TITLES.has(title.toLowerCase().trim()); } // ── Persistent URL watcher + DOM title extraction ── private startTitleWatcher(tabId: number, wc: Electron.WebContents): void { const existing = this.titlePolls.get(tabId); if (existing) clearInterval(existing); let lastUrl = ''; let lastDom = ''; const poll = setInterval(() => { if (wc.isDestroyed()) { clearInterval(poll); this.titlePolls.delete(tabId); return; } wc.executeJavaScript( `(function() { var url = window.location.href; var title = ''; var ph = document.getElementById('profileHolder'); if (ph && ph.style.display === 'block') { var ns = document.getElementById('nameSwitch'); if (ns && ns.innerText) title = ns.innerText; } return JSON.stringify({ url: url, dom: title }); })()` ).then((json: string) => { const { url, dom } = JSON.parse(json); if (url === lastUrl && dom === lastDom) return; lastUrl = url; lastDom = dom; const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; if (dom) { if (tab.title !== dom) { this.updateTabInfo(tabId, { url, title: dom }); } else if (tab.url !== url) { this.updateTabInfo(tabId, { url }); } return; } if (tab.url !== url) { this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) }); } }).catch(() => {}); }, 1000); this.titlePolls.set(tabId, poll); } private stopTitleWatcher(tabId: number): void { const poll = this.titlePolls.get(tabId); if (poll) { clearInterval(poll); this.titlePolls.delete(tabId); } } // ── Extract a display title from URL ── private titleFromUrl(url: string): string { try { const parsed = new URL(url); const p = parsed.searchParams.get('p'); const q = parsed.searchParams.get('q'); if (q) return q; if (p) { const pageMap: Record = { profile: 'Profile', leaders: 'Leaderboard', games: 'Games', clans: 'Clans', skins: 'Skins', mods: 'Mods', maps: 'Maps', editor: 'Editor', market: 'Market', itemsales: 'Market Item', inventory: 'Inventory', settings: 'Settings', feed: 'Hub', }; return pageMap[p] || p.charAt(0).toUpperCase() + p.slice(1); } const path = parsed.pathname.replace(/\.html$/, '').replace(/^\//, ''); if (path === 'social') return 'Hub'; if (path) return path.charAt(0).toUpperCase() + path.slice(1); return 'New Tab'; } catch { return 'New Tab'; } } }