const ipcRenderer = window.electronAPI; // Lightweight debug logger (toggleable) const DEBUG = false; const debug = (...args) => { if (DEBUG) console.log(...args); }; // Scroll normalization CSS and JS to ensure consistent scroll speed across all sites const SCROLL_NORMALIZATION_CSS = ` /* Disable smooth scrolling behavior that some sites force */ *, *::before, *::after { scroll-behavior: auto !important; } html, body { scroll-behavior: auto !important; } `; const SCROLL_NORMALIZATION_JS = ` (function() { if (window.__nebulaScrollNormalized) return; window.__nebulaScrollNormalized = true; // Consistent scroll amount in pixels per wheel delta unit const SCROLL_SPEED = 100; // Intercept wheel events to normalize scroll speed document.addEventListener('wheel', function(e) { // Don't interfere if modifier keys are pressed (zoom, horizontal scroll, etc.) if (e.ctrlKey || e.metaKey || e.altKey) return; // Get the scroll target let target = e.target; let scrollable = null; // Find the nearest scrollable element while (target && target !== document.body && target !== document.documentElement) { const style = window.getComputedStyle(target); const overflowY = style.overflowY; const overflowX = style.overflowX; if ((overflowY === 'auto' || overflowY === 'scroll') && target.scrollHeight > target.clientHeight) { scrollable = target; break; } if ((overflowX === 'auto' || overflowX === 'scroll') && target.scrollWidth > target.clientWidth && e.shiftKey) { scrollable = target; break; } target = target.parentElement; } // If no scrollable container found, use the document if (!scrollable) { scrollable = document.scrollingElement || document.documentElement || document.body; } // Calculate normalized scroll delta // deltaMode: 0 = pixels, 1 = lines, 2 = pages let deltaY = e.deltaY; let deltaX = e.deltaX; if (e.deltaMode === 1) { // Line mode - multiply by line height approximation deltaY *= SCROLL_SPEED; deltaX *= SCROLL_SPEED; } else if (e.deltaMode === 2) { // Page mode - multiply by viewport height deltaY *= window.innerHeight; deltaX *= window.innerWidth; } else { // Pixel mode - normalize to consistent speed // Clamp the delta to prevent extremely fast scrolling from some sites const sign = deltaY > 0 ? 1 : -1; deltaY = sign * Math.min(Math.abs(deltaY), SCROLL_SPEED * 3); const signX = deltaX > 0 ? 1 : -1; deltaX = signX * Math.min(Math.abs(deltaX), SCROLL_SPEED * 3); } // Apply scroll e.preventDefault(); scrollable.scrollBy({ top: deltaY, left: e.shiftKey ? deltaX : 0, behavior: 'auto' }); }, { passive: false, capture: true }); })(); `; // Function to apply scroll normalization to a webview function applyScrollNormalization(webview) { try { // Inject CSS to disable smooth scrolling webview.insertCSS(SCROLL_NORMALIZATION_CSS); // Inject JS to normalize wheel scroll speed webview.executeJavaScript(SCROLL_NORMALIZATION_JS); debug('[Scroll] Applied scroll normalization to webview'); } catch (err) { console.warn('[Scroll] Failed to apply scroll normalization:', err); } } // Site history management using localStorage function getSiteHistory() { try { const history = localStorage.getItem('siteHistory'); return history ? JSON.parse(history) : []; } catch (err) { console.error('Error reading site history from localStorage:', err); return []; } } function addToSiteHistory(url) { try { let history = getSiteHistory(); // Remove if already exists to avoid duplicates history = history.filter(item => item !== url); // Add to beginning history.unshift(url); // Keep only last 100 entries if (history.length > 100) { history = history.slice(0, 100); } localStorage.setItem('siteHistory', JSON.stringify(history)); } catch (err) { console.error('Error saving site history to localStorage:', err); } } // Search history management using localStorage function getSearchHistory() { try { const history = localStorage.getItem('searchHistory'); return history ? JSON.parse(history) : []; } catch (err) { console.error('Error reading search history from localStorage:', err); return []; } } function addToSearchHistory(searchQuery) { try { let history = getSearchHistory(); // Remove if already exists to avoid duplicates history = history.filter(item => item !== searchQuery); // Add to beginning history.unshift(searchQuery); // Keep only last 100 entries if (history.length > 100) { history = history.slice(0, 100); } localStorage.setItem('searchHistory', JSON.stringify(history)); // Also save to file via IPC for persistence if (window.electronAPI && window.electronAPI.invoke) { window.electronAPI.invoke('save-search-history', history); } } catch (err) { console.error('Error saving search history to localStorage:', err); } } // Store current theme colors globally for use by renderTabs let currentThemeColors = null; // Apply theme colors to the main UI (URL bar and tabs) function applyThemeToMainUI(theme) { if (!theme || !theme.colors) return; const root = document.documentElement; const colors = theme.colors; // Store colors globally for renderTabs to use currentThemeColors = colors; // Set CSS variables on root for elements using var() const setVar = (cssVar, value, fallback) => { const val = value || fallback; if (val) root.style.setProperty(cssVar, val); }; // Core palette so popups/menus and the address bar stay in sync setVar('--bg', colors.bg, '#0b0d10'); setVar('--dark-blue', colors.darkBlue, '#0b1c2b'); setVar('--dark-purple', colors.darkPurple, '#1b1035'); setVar('--primary', colors.primary, '#7b2eff'); setVar('--accent', colors.accent, '#00c6ff'); setVar('--text', colors.text, '#e0e0e0'); // URL bar + tab strip styling setVar('--url-bar-bg', colors.urlBarBg, '#1c2030'); setVar('--url-bar-text', colors.urlBarText, '#e0e0e0'); setVar('--url-bar-border', colors.urlBarBorder, '#3e4652'); setVar('--tab-bg', colors.tabBg, '#161925'); setVar('--tab-text', colors.tabText, '#a4a7b3'); setVar('--tab-active', colors.tabActive, '#1c2030'); setVar('--tab-active-text', colors.tabActiveText, '#e0e0e0'); setVar('--tab-border', colors.tabBorder, '#2b3040'); // Also directly apply to key elements to ensure styles take effect const nav = document.getElementById('nav'); const titlebarContainer = document.getElementById('titlebar-container'); const tabBar = document.getElementById('tab-bar'); const urlBox = document.getElementById('url'); const navCenter = document.querySelector('.nav-center'); if (nav) { nav.style.setProperty('background', colors.urlBarBg || '#1c2030', 'important'); nav.style.setProperty('border-bottom-color', colors.urlBarBorder || '#3e4652', 'important'); } if (navCenter) { navCenter.style.setProperty('background', colors.urlBarBg || '#1c2030', 'important'); navCenter.style.setProperty('border-color', colors.urlBarBorder || '#3e4652', 'important'); } if (titlebarContainer) { titlebarContainer.style.setProperty('background', colors.tabBg || '#161925', 'important'); } if (tabBar) { tabBar.style.setProperty('background', colors.tabBg || '#161925', 'important'); tabBar.style.setProperty('border-bottom-color', colors.tabBorder || '#2b3040', 'important'); } if (urlBox) { urlBox.style.setProperty('color', colors.urlBarText || '#e0e0e0', 'important'); } // Update existing tab elements to reflect new theme colors document.querySelectorAll('.tab').forEach(tab => { const isActive = tab.classList.contains('active'); tab.style.setProperty('background', isActive ? (colors.tabActive || '#1c2030') : (colors.tabBg || '#161925'), 'important'); tab.style.setProperty('color', isActive ? (colors.tabActiveText || '#e0e0e0') : (colors.tabText || '#a4a7b3'), 'important'); tab.style.setProperty('border-color', colors.tabBorder || '#2b3040', 'important'); }); // Align the chrome background with the theme gradient or fallback if (theme.gradient) { document.body.style.background = theme.gradient; } else if (colors.bg) { document.body.style.background = colors.bg; } // Persist so other pages (home/settings) can pull the latest palette try { localStorage.setItem('currentTheme', JSON.stringify(theme)); } catch {} console.log('[THEME] Applied theme to main UI:', { urlBarBg: colors.urlBarBg, tabBg: colors.tabBg, navFound: !!nav, titlebarFound: !!titlebarContainer, tabBarFound: !!tabBar }); } // Detect platform and add class to body for CSS platform-specific styling (function detectPlatform() { const platform = navigator.platform.toLowerCase(); if (platform.includes('mac')) { document.body.classList.add('platform-darwin'); } else if (platform.includes('win')) { document.body.classList.add('platform-win32'); } else { document.body.classList.add('platform-linux'); } })(); // 1) cache hot DOM references const urlBox = document.getElementById('url'); const tabBarEl = document.getElementById('tab-bar'); const viewHostEl = document.getElementById('view-host'); const menuPopup = document.getElementById('menu-popup'); // (Removed old custom HTML context menu in favor of native Electron menu) function updateBrowserViewBounds() { if (!viewHostEl) return; const rect = viewHostEl.getBoundingClientRect(); ipcRenderer.invoke('browserview-set-bounds', { x: rect.left, y: rect.top, width: rect.width, height: rect.height }).catch(() => {}); } window.addEventListener('resize', () => { updateBrowserViewBounds(); }); // Select all text on focus and prevent mouseup from deselecting urlBox.addEventListener('focus', () => { urlBox.select(); }); urlBox.addEventListener('mouseup', e => e.preventDefault()); // Add Enter key navigation urlBox.addEventListener('keydown', (e) => { if (e.key === 'Enter') { navigate(); } }); let tabs = []; let activeTabId = null; let isHistoryNavigation = false; // Flag to prevent duplicate history entries during back/forward const allowedInternalPages = ['settings', 'home', 'downloads', 'nebot', 'insecure']; // Session-scoped allowlist of HTTP hosts the user explicitly chose to proceed with. const insecureBypassedHosts = new Set(); let pluginPages = []; // { id, file, fileUrl, pluginId } let pluginPagesReady = false; const pendingInternalNavigations = []; // Allow isolated worlds / plugin preloads (contextIsolation) to request opening an internal page window.addEventListener('message', (e) => { try { const data = e.data; if (!data || typeof data !== 'object') return; if (data.type === 'open-internal-page' && typeof data.url === 'string') { console.log('[DEBUG] Message request to open internal page:', data.url); createTab(data.url); } else if (data.type === 'navigate' && typeof data.url === 'string') { // Fallback navigation from pages (like insecure.html) when electronAPI.sendToHost is unavailable try { if (data.opts && data.opts.insecureBypass && /^http:\/\//i.test(data.url)) { const h = new URL(data.url).hostname; insecureBypassedHosts.add(h); } } catch {} urlBox.value = data.url; navigate(); } } catch (err) { console.warn('[DEBUG] open-internal-page handler error', err); } }); // Fetch plugin-provided pages (nebula://) once on startup (async () => { try { console.log('[DEBUG] About to request plugin pages from main process...'); pluginPages = await ipcRenderer.invoke('plugins-get-pages'); console.log('[DEBUG] Loaded pluginPages:', pluginPages); console.log('[DEBUG] allowedInternalPages before:', allowedInternalPages); for (const p of pluginPages) { if (p && p.id && !allowedInternalPages.includes(p.id)) { console.log('[DEBUG] Adding plugin page to allowed list:', p.id); allowedInternalPages.push(p.id); } } console.log('[DEBUG] allowedInternalPages after:', allowedInternalPages); } catch (e) { console.warn('Failed to load plugin pages', e); } finally { pluginPagesReady = true; console.log('[DEBUG] Plugin pages ready, flushing', pendingInternalNavigations.length, 'pending navigations'); // Flush any queued internal navigations that occurred before readiness while (pendingInternalNavigations.length) { const fn = pendingInternalNavigations.shift(); try { fn(); } catch {} } } })(); let bookmarks = []; // Efficient render scheduling to avoid redundant DOM work let tabsRenderPending = false; // Track previous order and positions for FLIP animations let lastTabOrder = []; let closingTabs = new Set(); function scheduleRenderTabs() { if (tabsRenderPending) return; tabsRenderPending = true; requestAnimationFrame(() => { tabsRenderPending = false; renderTabs(); }); } // Debounce nav button updates to reduce layout work let navButtonsPending = false; let backBtnCached = null; let fwdBtnCached = null; function scheduleUpdateNavButtons() { if (navButtonsPending) return; navButtonsPending = true; requestAnimationFrame(() => { navButtonsPending = false; try { updateNavButtons(); } catch {} }); } // Derive a stable, safe label for a tab without throwing on non-URLs function getTabLabel(tab) { if (tab.title && tab.title !== 'New Tab') return tab.title; const u = tab.url || ''; try { if (u.startsWith('data:image')) return 'Image'; if (u.startsWith('data:')) return 'Data'; if (u.startsWith('blob:')) return 'Resource'; if (u.startsWith('http')) return new URL(u).hostname; if (u.startsWith('nebula://')) return u.replace('nebula://', ''); return u || 'New Tab'; } catch { return u || 'New Tab'; } } // Load bookmarks on startup async function loadBookmarks() { try { bookmarks = await ipcRenderer.invoke('load-bookmarks'); } catch (error) { console.error('Error loading bookmarks in main context:', error); bookmarks = []; } } // Function to save bookmarks async function saveBookmarks(newBookmarks) { try { bookmarks = newBookmarks; await ipcRenderer.invoke('save-bookmarks', bookmarks); } catch (error) { console.error('Error saving bookmarks in main context:', error); } } // Load bookmarks when the script starts loadBookmarks(); // Initial home tab will be created on DOMContentLoaded // Remove iframe-based navigation listener (using webview IPC now) // Listen for site history updates from main process // NOTE: electronAPI.on wrapper strips the original event object and only forwards args. // Handlers therefore must NOT expect the event parameter. ipcRenderer.on('record-site-history', (url) => { debug('[DEBUG] Received site history update:', url); if (typeof url === 'string' && url) addToSiteHistory(url); }); // Main process requests opening a URL in a new tab (window.open interception) ipcRenderer.on('open-url-new-tab', (url) => { console.log('[DEBUG] IPC open-url-new-tab received:', url); if (typeof url === 'string' && url) createTab(url); }); // Messages from BrowserView pages (sendToHost fallback) ipcRenderer.on('browserview-host-message', (payload) => { console.log('[Renderer] browserview-host-message received:', payload); const data = payload || {}; const channel = data.channel; const args = data.args || []; if (!channel) return; if (channel === 'navigate' && args[0]) { console.log('[Renderer] Navigating to:', args[0]); const targetUrl = args[0]; const opts = args[1] || {}; try { if (opts.insecureBypass && /^http:\/\//i.test(targetUrl)) { const h = new URL(targetUrl).hostname; insecureBypassedHosts.add(h); } } catch {} if (opts.newTab) { createTab(targetUrl); } else { urlBox.value = targetUrl; navigate(); } } else if (channel === 'theme-update' && args[0]) { const theme = args[0]; applyThemeToMainUI(theme); ipcRenderer.send('browserview-broadcast', { channel: 'theme-update', args: [theme] }); } }); // Commands from the overlay menu window function runMenuCommand(cmd) { if (!cmd) return; switch (cmd) { case 'open-settings': openSettings(); break; case 'open-downloads': openDownloads(); break; case 'toggle-devtools': window.electronAPI?.toggleDevTools?.(); break; case 'big-picture': window.bigPictureAPI?.launch?.(); break; case 'zoom-in': zoomIn(); break; case 'zoom-out': zoomOut(); break; case 'hard-reload': hardReload(); break; case 'fresh-reload': freshReload(); break; default: break; } } const isLinux = document.body.classList.contains('platform-linux'); function hideMainMenuPopup() { if (!isLinux) { // Windows/macOS: hide the popup window ipcRenderer.send('menu-popup-hide'); } } function showNativeAppMenu() { if (!menuBtn) return; const rect = menuBtn.getBoundingClientRect(); if (isLinux) { // Linux: use native OS menu (renders above BrowserView reliably) ipcRenderer.invoke('show-app-menu', { x: Math.round(rect.right - 200), y: Math.round(rect.bottom + 4) }).catch(() => {}); } else { // Windows/macOS: use the custom popup window const theme = currentThemeColors ? { colors: currentThemeColors } : null; ipcRenderer.send('menu-popup-toggle', { anchorRect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, anchorScreenPoint: { x: Math.round(window.screenX + rect.right), y: Math.round(window.screenY + rect.bottom) }, theme }); } } ipcRenderer.on('menu-command', (payload) => { const cmd = payload?.cmd; runMenuCommand(cmd); hideMainMenuPopup(); }); // Auto-open on download start is disabled by design now. function createTab(inputUrl) { inputUrl = inputUrl || 'nebula://home'; console.log('[DEBUG] createTab() inputUrl =', inputUrl); const id = crypto.randomUUID(); if (inputUrl.startsWith('nebula://') && !pluginPagesReady) { // Defer creation until plugin pages known to avoid 404 race console.log('[DEBUG] Deferring createTab until pluginPagesReady'); pendingInternalNavigations.push(() => createTab(inputUrl)); return id; } let resolvedUrl = resolveInternalUrl(inputUrl); console.log('[DEBUG] createTab resolvedUrl:', resolvedUrl, 'from inputUrl:', inputUrl); // Keep data: URLs intact; BrowserView cannot consume blob URLs created in the UI process. tabs.push({ id, url: inputUrl, title: 'New Tab', favicon: null, history: [inputUrl], historyIndex: 0 }); ipcRenderer.invoke('browserview-create', { tabId: id, url: resolvedUrl }) .then(() => { setActiveTab(id); updateBrowserViewBounds(); }) .catch(() => {}); scheduleRenderTabs(); return id; } // Expose for plugin usage (e.g., Nebot panel "Open Page") try { window.createTab = createTab; } catch {} function resolveInternalUrl(url) { console.log('[DEBUG] resolveInternalUrl called with:', url); if (url.startsWith('nebula://')) { // Support query / hash on internal pages (e.g., nebula://insecure?target=...) const tail = url.replace('nebula://', ''); const page = tail.split(/[?#]/)[0]; const suffix = tail.slice(page.length); // includes ? and/or # if present console.log('[DEBUG] Extracted page:', page); // Fast path: if user typed nebula://nebot and plugin page exists, return immediately if (page === 'nebot') { const nebotPage = pluginPages.find(p => p.id === 'nebot'); console.log('[DEBUG] Fast path for nebot, pluginPages:', pluginPages, 'nebotPage:', nebotPage); if (nebotPage && (nebotPage.fileUrl || nebotPage.file)) { const resolvedFast = nebotPage.fileUrl || (nebotPage.file.startsWith('file://') ? nebotPage.file : 'file://' + nebotPage.file.replace(/\\/g,'/')); console.log('[DEBUG] Fast path nebot resolve ->', resolvedFast); return resolvedFast; } console.log('[DEBUG] No plugin page found for nebot, falling back to nebot.html'); } console.log('[DEBUG] Checking if page in allowedInternalPages:', page, 'list:', allowedInternalPages); if (allowedInternalPages.includes(page)) { // Check if this page is provided by a plugin (absolute file path) const plug = pluginPages.find(p => p.id === page); console.log('[DEBUG] Resolving nebula://' + page, 'plug:', plug); if (plug && (plug.fileUrl || plug.file)) { // Prefer pre-built fileUrl for correctness across platforms const resolved = plug.fileUrl ? plug.fileUrl : (plug.file.startsWith('file://') ? plug.file : 'file://' + plug.file.replace(/\\/g,'/')); console.log('[DEBUG] Resolved plugin page', page, '->', resolved); return resolved + suffix; } // Fallback: built-in renderer copy (resolve to absolute file URL) console.log('[DEBUG] Using fallback for page:', page); const rel = `${page}.html${suffix}`; try { return new URL(rel, window.location.href).toString(); } catch { return rel; } } console.log('[DEBUG] Page not in allowedInternalPages, returning 404'); try { return new URL('404.html', window.location.href).toString(); } catch { return '404.html'; } } // Allow direct loading of common schemes without forcing https:// if (/^(https?:|file:|data:|blob:)/i.test(url)) return url; return `https://${url}`; } function handleLoadFail(tabId) { return (event) => { if (!event.validatedURL.includes('nebula://') && event.errorCode !== -3) { const badUrl = tabs.find(t => t.id === tabId)?.url || ''; ipcRenderer.invoke('browserview-load-url', { tabId, url: `404.html?url=${encodeURIComponent(badUrl)}` }).catch(() => {}); } }; } function updateTabMetadata(id, key, value) { const tab = tabs.find(t => t.id === id); if (tab) { tab[key] = value; scheduleRenderTabs(); } } function performNavigation(input, originalInputForHistory) { const tab = tabs.find(t => t.id === activeTabId); if (!tab) return; const hasProtocol = /^https?:\/\//i.test(input); const isFileProtocol = /^file:\/\//i.test(input); const looksLikeLocalPath = /^(?:[A-Za-z]:\\|\\\\|\/?)[^?]*\.(?:x?html?)$/i.test(input); const isInternal = input.startsWith('nebula://'); const isLikelyUrl = hasProtocol || input.includes('.'); let resolved; let isSearch = false; if (isFileProtocol) { resolved = input; } else if (looksLikeLocalPath) { let p = input.replace(/\\/g,'/'); if (/^[A-Za-z]:\//.test(p)) resolved = 'file:///' + encodeURI(p); else if (p.startsWith('/')) resolved = 'file://' + encodeURI(p); else resolved = 'file://' + encodeURI(p); } else if (!isInternal && !isLikelyUrl) { resolved = `https://www.google.com/search?q=${encodeURIComponent(input)}`; isSearch = true; // Save to search history addToSearchHistory(input); } else { resolved = resolveInternalUrl(input); } console.log('[DEBUG] performNavigation input:', input, 'resolved:', resolved, 'isInternal:', isInternal); // Intercept plain HTTP (not HTTPS) navigations (excluding localhost / 127.* / internal pages) try { if (!isInternal && /^http:\/\//i.test(resolved)) { const u = new URL(resolved); const host = u.hostname; const isLoopback = /^(localhost|127\.0\.0\.1|::1)$/.test(host); if (!isLoopback && !insecureBypassedHosts.has(host)) { const encoded = encodeURIComponent(resolved); // Directly load insecure.html (avoid custom scheme so OS doesn't try to resolve an external handler) resolved = `insecure.html?target=${encoded}`; } } } catch (e) { debug('[DEBUG] HTTP interception error', e); } if (!activeTabId) { createTab(input); return; } tab.history = tab.history.slice(0, tab.historyIndex + 1); tab.history.push(originalInputForHistory); tab.historyIndex++; tab.url = originalInputForHistory; ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: resolved }).catch(() => {}); scheduleRenderTabs(); scheduleUpdateNavButtons(); } function navigate() { const rawInput = urlBox.value.trim(); let input = rawInput; if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) input = input.slice(1, -1); if (input !== rawInput) urlBox.value = input; const isInternal = input.startsWith('nebula://'); if (isInternal && !pluginPagesReady) { const captured = input; // preserve original pendingInternalNavigations.push(() => performNavigation(captured, captured)); return; } performNavigation(input, input); } // Keyboard shortcut: Ctrl+O (Cmd+O on mac) to open a local file document.addEventListener('keydown', async (e) => { const isAccel = (navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey); if (isAccel && e.key.toLowerCase() === 'o') { e.preventDefault(); if (window.electronAPI && window.electronAPI.openLocalFile) { const fileUrl = await window.electronAPI.openLocalFile(); if (fileUrl) { urlBox.value = fileUrl; navigate(); } } } }); function handleNavigation(tabId, newUrl) { const tab = tabs.find(t => t.id === tabId); if (!tab) return; debug('[DEBUG] handleNavigation called with:', newUrl); // --- record every real navigation into history --- // Skip adding to history if this is a programmatic back/forward navigation if (!isHistoryNavigation) { // Check both current position AND last recorded URL to prevent duplicates from // multiple event firings (did-navigate + did-navigate-in-page) const lastRecordedUrl = tab.history[tab.history.length - 1]; if (tab.history[tab.historyIndex] !== newUrl && lastRecordedUrl !== newUrl) { tab.history = tab.history.slice(0, tab.historyIndex + 1); tab.history.push(newUrl); tab.historyIndex++; } } else { // Reset flag after handling the navigation isHistoryNavigation = false; } // Record site history in localStorage (skip internal pages and file:// URLs) if (!newUrl.endsWith('home.html') && !newUrl.endsWith('settings.html') && !newUrl.startsWith('file://') && !newUrl.includes('nebula://') && newUrl.startsWith('http')) { debug('[DEBUG] Adding to site history:', newUrl); addToSiteHistory(newUrl); // Also send to main process for file storage ipcRenderer.invoke('save-site-history-entry', newUrl); } // translate local files back to our nebula:// scheme const isHome = newUrl.endsWith('home.html'); const isSettings = newUrl.endsWith('settings.html'); const isDownloads = newUrl.endsWith('downloads.html'); const isNebot = newUrl.endsWith('nebot.html'); const isInsecure = newUrl.includes('insecure.html'); const is404 = newUrl.includes('404.html'); const displayUrl = isHome ? 'nebula://home' : isSettings ? 'nebula://settings' : isDownloads ? 'nebula://downloads' : isNebot ? 'nebula://nebot' : isInsecure ? 'nebula://insecure' : is404 ? 'nebula://404' : newUrl; tab.url = displayUrl; // Clear favicon and reset title for internal nebula:// pages if (displayUrl.startsWith('nebula://')) { tab.favicon = null; // Set appropriate title for each internal page if (isHome) { tab.title = 'New Tab'; } else if (isSettings) { tab.title = 'Settings'; } else if (isDownloads) { tab.title = 'Downloads'; } else if (isNebot) { tab.title = 'Nebot'; } else if (isInsecure) { tab.title = 'Insecure Connection'; } else if (is404) { tab.title = 'Page Not Found'; } } if (tabId === activeTabId) { urlBox.value = displayUrl === 'nebula://home' ? '' : displayUrl; } scheduleRenderTabs(); scheduleUpdateNavButtons(); } function setActiveTab(id) { activeTabId = id; ipcRenderer.invoke('browserview-set-active', { tabId: id }).catch(() => {}); updateBrowserViewBounds(); const tab = tabs.find(t => t.id === id); if (tab) { urlBox.value = tab.url === 'nebula://home' ? '' : tab.url; scheduleRenderTabs(); updateNavButtons(); updateZoomUI(); } } function closeTab(id) { // Play closing animation on tab button, then remove const btn = tabBarEl.querySelector(`[data-tab-id="${id}"]`); if (btn && !closingTabs.has(id)) { closingTabs.add(id); btn.classList.add('tab--closing'); // Pre-calc which tab should become active if we're closing the active tab const idx = tabs.findIndex(t => t.id === id); const nextActiveId = (id === activeTabId) ? (tabs[idx - 1]?.id ?? tabs[idx + 1]?.id ?? tabs[0]?.id) : activeTabId; btn.addEventListener('animationend', () => { ipcRenderer.invoke('browserview-destroy', { tabId: id }).catch(() => {}); // Remove from model tabs = tabs.filter(t => t.id !== id); // Choose a new active tab if needed if (tabs.length > 0 && nextActiveId) setActiveTab(nextActiveId); closingTabs.delete(id); scheduleRenderTabs(); updateNavButtons(); }, { once: true }); return; } // Fallback (no button rendered yet) ipcRenderer.invoke('browserview-destroy', { tabId: id }).catch(() => {}); tabs = tabs.filter(t => t.id !== id); if (id === activeTabId && tabs.length > 0) setActiveTab(tabs[0].id); scheduleRenderTabs(); updateNavButtons(); } // 2) streamline renderTabs with a fragment function renderTabs() { // Measure initial positions (First) for existing elements const firstRects = new Map(); const existing = Array.from(tabBarEl.querySelectorAll('.tab')); existing.forEach(el => { firstRects.set(el.dataset.tabId, el.getBoundingClientRect()); }); const frag = document.createDocumentFragment(); if (tabBarEl && tabBarEl.getAttribute('role') !== 'tablist') { tabBarEl.setAttribute('role', 'tablist'); } // Create tab elements const currentOrder = []; tabs.forEach(tab => { const el = document.createElement('div'); el.className = 'tab' + (tab.id === activeTabId ? ' active' : ''); el.classList.add('tab--flip'); el.setAttribute('role', 'tab'); el.setAttribute('aria-selected', String(tab.id === activeTabId)); el.setAttribute('tabindex', tab.id === activeTabId ? '0' : '-1'); el.dataset.tabId = tab.id; currentOrder.push(tab.id); // Apply theme colors to new tab element if (currentThemeColors) { const isActive = tab.id === activeTabId; el.style.setProperty('background', isActive ? (currentThemeColors.tabActive || '#1c2030') : (currentThemeColors.tabBg || '#161925'), 'important'); el.style.setProperty('color', isActive ? (currentThemeColors.tabActiveText || '#e0e0e0') : (currentThemeColors.tabText || '#a4a7b3'), 'important'); el.style.setProperty('border-color', currentThemeColors.tabBorder || '#2b3040', 'important'); } if (!lastTabOrder.includes(tab.id)) { // New tab enters with animation el.classList.add('tab--enter'); } if (tab.favicon) { const icon = document.createElement('img'); icon.src = tab.favicon; icon.className = 'tab-favicon'; icon.onerror = function() { this.style.display = 'none'; }; el.appendChild(icon); } const title = document.createElement('span'); title.className = 'tab-title'; title.textContent = getTabLabel(tab); el.appendChild(title); const closeBtn = document.createElement('button'); closeBtn.className = 'tab-close'; closeBtn.title = 'Close tab'; closeBtn.textContent = '×'; closeBtn.addEventListener('click', (e) => { e.stopPropagation(); closeTab(tab.id); }); el.appendChild(closeBtn); el.addEventListener('mousedown', (e) => { if (e.button === 1) { e.preventDefault(); closeTab(tab.id); } }); el.draggable = true; el.addEventListener('dragstart', e => { e.dataTransfer.setData('tabId', tab.id); e.dataTransfer.setData('text/plain', tab.id); // Hide default ghost image; use an empty drag image const ghost = document.createElement('canvas'); ghost.width = 1; ghost.height = 1; // 1x1 transparent pixel const ctx = ghost.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, 1, 1); } e.dataTransfer.setDragImage(ghost, 0, 0); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.dropEffect = 'move'; } // visual lift on drag start el.classList.add('tab--dragging'); // Store initial pointer offset to keep tab under cursor const rect = el.getBoundingClientRect(); el._dragOffsetX = e.clientX - rect.left; el._dragStartLeft = rect.left; el._dragStartTop = rect.top; }); el.addEventListener('dragenter', e => { // If another tab is being dragged over this one, hint before/after const draggedId = (e.dataTransfer && (e.dataTransfer.getData('tabId') || e.dataTransfer.getData('text/plain'))) || null; if (!draggedId || draggedId === tab.id) return; const rect = el.getBoundingClientRect(); const x = e.clientX; const before = (x - rect.left) < rect.width / 2; el.classList.toggle('tab--drop-before', before); el.classList.toggle('tab--drop-after', !before); }); el.addEventListener('dragover', e => { e.preventDefault(); // Continuously update hint side while hovering const rect = el.getBoundingClientRect(); const before = (e.clientX - rect.left) < rect.width / 2; el.classList.toggle('tab--drop-before', before); el.classList.toggle('tab--drop-after', !before); }); // While dragging, move the actual element to follow cursor horizontally (attach once). if (!tabBarEl._dragoverAttached) { tabBarEl.addEventListener('dragover', (evt) => { const draggingEl = tabBarEl.querySelector('.tab.tab--dragging'); if (!draggingEl) return; evt.preventDefault(); if (evt.dataTransfer) evt.dataTransfer.dropEffect = 'move'; const barRect = tabBarEl.getBoundingClientRect(); const targetX = evt.clientX - barRect.left - (draggingEl._dragOffsetX || 0); // Translate relative to its current position const elRect = draggingEl.getBoundingClientRect(); const dx = targetX - (elRect.left - barRect.left); draggingEl.style.transform = `translateX(${dx}px)`; }); tabBarEl._dragoverAttached = true; } el.addEventListener('dragleave', () => { el.classList.remove('tab--drop-before', 'tab--drop-after'); }); el.addEventListener('drop', e => { e.preventDefault(); const draggedId = e.dataTransfer.getData('tabId') || e.dataTransfer.getData('text/plain'); if (!draggedId || draggedId === tab.id) return; const fromIndex = tabs.findIndex(t => t.id === draggedId); const toIndex = tabs.findIndex(t => t.id === tab.id); if (fromIndex === -1 || toIndex === -1) return; const rect = el.getBoundingClientRect(); const after = (e.clientX - rect.left) > rect.width / 2; const newIndex = toIndex + (after ? 1 : 0); const [moved] = tabs.splice(fromIndex, 1); const adjIndex = fromIndex < newIndex ? newIndex - 1 : newIndex; tabs.splice(adjIndex, 0, moved); el.classList.remove('tab--drop-before', 'tab--drop-after'); // Reset dragging transform before re-render FLIP const draggingEl = tabBarEl.querySelector('.tab.tab--dragging'); if (draggingEl) draggingEl.style.transform = ''; scheduleRenderTabs(); }); el.addEventListener('dragend', e => { // Clear dragging visual state el.classList.remove('tab--dragging'); el.style.transform = ''; // Clean any lingering hints el.classList.remove('tab--drop-before', 'tab--drop-after'); if ( e.clientX < 0 || e.clientX > window.innerWidth || e.clientY < 0 || e.clientY > window.innerHeight ) { ipcRenderer.invoke('open-tab-in-new-window', tab.url); closeTab(tab.id); } }); el.addEventListener('click', () => setActiveTab(tab.id)); frag.appendChild(el); }); // New tab button const plus = document.createElement('button'); plus.className = 'new-tab-button'; plus.title = 'New tab'; plus.setAttribute('aria-label', 'New tab'); plus.textContent = '+'; plus.addEventListener('click', () => createTab()); frag.appendChild(plus); // Swap DOM: to support FLIP, we need to keep the old nodes around until we can measure Last tabBarEl.innerHTML = ''; tabBarEl.appendChild(frag); // Measure final positions (Last) const lastRects = new Map(); Array.from(tabBarEl.querySelectorAll('.tab')).forEach(el => { lastRects.set(el.dataset.tabId, el.getBoundingClientRect()); }); // Apply FLIP: invert then play Array.from(tabBarEl.querySelectorAll('.tab')).forEach(el => { const id = el.dataset.tabId; const first = firstRects.get(id); const last = lastRects.get(id); if (!first || !last) return; const dx = first.left - last.left; const dy = first.top - last.top; if (dx || dy) { el.style.transform = `translate(${dx}px, ${dy}px)`; el.getBoundingClientRect(); // force reflow el.style.transform = ''; } }); // Update order for next render lastTabOrder = currentOrder.slice(); } // 1) handle URL sent by main for a detached window ipcRenderer.on('open-url', (url) => { for (const t of tabs) { ipcRenderer.invoke('browserview-destroy', { tabId: t.id }).catch(() => {}); } tabs = []; activeTabId = null; tabBarEl.innerHTML = ''; if (typeof url === 'string' && url) createTab(url); else createTab(); }); function goBack() { const tab = tabs.find(t => t.id === activeTabId); if (!tab) return; // Use custom history tracking to properly handle internal pages like home if (tab.historyIndex > 0) { tab.historyIndex--; const targetUrl = tab.history[tab.historyIndex]; isHistoryNavigation = true; const resolvedUrl = resolveInternalUrl(targetUrl); ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: resolvedUrl }).catch(() => {}); } } function goForward() { const tab = tabs.find(t => t.id === activeTabId); if (!tab) return; // Use custom history tracking to properly handle internal pages like home if (tab.historyIndex < tab.history.length - 1) { tab.historyIndex++; const targetUrl = tab.history[tab.historyIndex]; isHistoryNavigation = true; const resolvedUrl = resolveInternalUrl(targetUrl); ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: resolvedUrl }).catch(() => {}); } } function updateNavButtons() { const tab = tabs.find(t => t.id === activeTabId); if (!backBtnCached || !fwdBtnCached) { backBtnCached = document.querySelector('.nav-left button:nth-child(1)'); fwdBtnCached = document.querySelector('.nav-left button:nth-child(2)'); } // Use custom history tracking for button state if (backBtnCached) backBtnCached.disabled = !tab || tab.historyIndex <= 0; if (fwdBtnCached) fwdBtnCached.disabled = !tab || tab.historyIndex >= tab.history.length - 1; } function reload() { if (!activeTabId) return; ipcRenderer.invoke('browserview-reload', { tabId: activeTabId, ignoreCache: false }).catch(() => {}); scheduleUpdateNavButtons(); } function hardReload() { if (!activeTabId) return; ipcRenderer.invoke('browserview-reload', { tabId: activeTabId, ignoreCache: true }).catch(() => {}); scheduleUpdateNavButtons(); } function freshReload() { if (!activeTabId) return; ipcRenderer.invoke('browserview-get-url', { tabId: activeTabId }).then((currentUrl) => { if (!currentUrl) return hardReload(); try { const u = new URL(currentUrl); u.searchParams.set('_bust', Date.now().toString()); ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: u.toString() }).catch(() => {}); } catch { hardReload(); } }); } // Function to open the Settings page function openSettings() { createTab('nebula://settings'); } // Open Downloads manager page function openDownloads() { createTab('nebula://downloads'); } // Toggle menu dropdown const menuBtn = document.getElementById('menu-btn'); const menuWrapper = document.querySelector('.menu-wrapper'); // Downloads mini popup elements let downloadsBtnEl = null; let downloadsPopupEl = null; let downloadsListEl = null; let downloadsEmptyEl = null; let downloadsShowAllBtn = null; let ringSvgEl = null; // Open/close on button click; stop propagation so outside-click handler doesn't immediately close it menuBtn.addEventListener('click', (e) => { e.stopPropagation(); showNativeAppMenu(); }); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideMainMenuPopup(); if (e.key === 'Escape' && downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) { hideDownloadsPopup(); } }); // Close menus when BrowserView receives focus ipcRenderer.on('browserview-event', (payload) => { if (!payload || !payload.type) return; const { tabId, type } = payload; if (type === 'focus') { hideMainMenuPopup(); if (downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) hideDownloadsPopup(); return; } if (type === 'page-title-updated') { updateTabMetadata(tabId, 'title', payload.title); return; } if (type === 'page-favicon-updated') { const fav = payload.favicons?.[0]; if (fav) updateTabMetadata(tabId, 'favicon', fav); return; } if (type === 'did-navigate' || type === 'did-navigate-in-page') { if (payload.url) { handleNavigation(tabId, payload.url); if (/\/cdn-cgi\//.test(payload.url) || /challenge/i.test(payload.url)) { console.log('[Nebula] Cloudflare challenge detected at', payload.url); } } return; } if (type === 'did-finish-load') { scheduleUpdateNavButtons(); return; } if (type === 'did-fail-load') { handleLoadFail(tabId)({ validatedURL: payload.validatedURL || '', errorCode: payload.errorCode, errorDescription: payload.errorDescription, isMainFrame: payload.isMainFrame }); } }); window.addEventListener('DOMContentLoaded', () => { // Initialize theme from localStorage const savedTheme = localStorage.getItem('currentTheme'); if (savedTheme) { try { const theme = JSON.parse(savedTheme); applyThemeToMainUI(theme); ipcRenderer.send('browserview-broadcast', { channel: 'theme-update', args: [theme] }); } catch (err) { console.error('Error applying saved theme:', err); } } // Initialize display scale (zoom) from localStorage const savedDisplayScale = localStorage.getItem('nebula-display-scale'); if (savedDisplayScale) { try { const scale = Number(savedDisplayScale); if (scale > 0 && scale <= 300) { const zoomFactor = scale / 100; if (ipcRenderer && typeof ipcRenderer.invoke === 'function') { ipcRenderer.invoke('set-zoom-factor', zoomFactor).catch(err => { console.error('Error setting zoom factor:', err); }); } } } catch (err) { console.error('Error applying saved display scale:', err); } } // Initial boot createTab(); updateBrowserViewBounds(); // Fallback: listen for postMessage navigations from embedded pages (home/settings) window.addEventListener('message', (event) => { if (event.data && event.data.type === 'navigate' && event.data.url) { if (event.data.newTab) { createTab(event.data.url); } else { urlBox.value = event.data.url; navigate(); } } }); // only now bind the reload button (guaranteed to exist) const reloadBtn = document.getElementById('reload-btn'); reloadBtn.addEventListener('click', reload); const hardReloadBtn = document.getElementById('hard-reload-btn'); if (hardReloadBtn) hardReloadBtn.addEventListener('click', hardReload); const freshReloadBtn = document.getElementById('fresh-reload-btn'); if (freshReloadBtn) freshReloadBtn.addEventListener('click', freshReload); // bind zoom buttons (single binding) const zoomInBtn = document.getElementById('zoom-in-btn'); const zoomOutBtn = document.getElementById('zoom-out-btn'); zoomInBtn.addEventListener('click', zoomIn); zoomOutBtn.addEventListener('click', zoomOut); // DevTools toggle button const devtoolsBtn = document.getElementById('devtools-btn'); if (devtoolsBtn && window.electronAPI && window.electronAPI.toggleDevTools) { devtoolsBtn.addEventListener('click', () => { window.electronAPI.toggleDevTools(); }); } // Big Picture Mode button const bigPictureBtn = document.getElementById('bigpicture-btn'); if (bigPictureBtn && window.bigPictureAPI && window.bigPictureAPI.launch) { bigPictureBtn.addEventListener('click', async () => { try { await window.bigPictureAPI.launch(); hideMainMenuPopup(); } catch (e) { console.error('Failed to launch Big Picture Mode:', e); } }); } if (menuPopup) { menuPopup.addEventListener('click', (e) => { const button = e.target instanceof HTMLElement ? e.target.closest('button') : null; if (!button) return; const id = button.id; if (!id) return; if (id !== 'zoom-in-btn' && id !== 'zoom-out-btn') { hideMainMenuPopup(); } e.stopPropagation(); }); } document.addEventListener('click', (e) => { if (!menuPopup || menuPopup.classList.contains('hidden')) return; if (menuWrapper && !menuWrapper.contains(e.target)) { hideMainMenuPopup(); } }); // Cache back/forward buttons for faster updates (no need to add listeners - already in HTML) backBtnCached = document.querySelector('.nav-left button:nth-child(1)'); fwdBtnCached = document.querySelector('.nav-left button:nth-child(2)'); // settings button const settingsBtn = document.getElementById('open-settings-btn'); if (settingsBtn) settingsBtn.addEventListener('click', openSettings); // downloads button downloadsBtnEl = document.getElementById('downloads-btn'); downloadsPopupEl = document.getElementById('downloads-popup'); downloadsListEl = document.getElementById('downloads-list'); downloadsEmptyEl = document.getElementById('downloads-empty'); downloadsShowAllBtn = document.getElementById('downloads-show-all'); if (downloadsBtnEl) { // Insert progress ring SVG const ring = document.createElement('div'); ring.className = 'ring'; ring.innerHTML = ''; downloadsBtnEl.appendChild(ring); ringSvgEl = ring.querySelector('circle.fg'); downloadsBtnEl.addEventListener('click', (e)=>{ e.stopPropagation(); toggleDownloadsPopup(); }); } if (downloadsShowAllBtn) downloadsShowAllBtn.addEventListener('click', ()=> { hideDownloadsPopup(); openDownloads(); }); // Close popup if clicking elsewhere document.addEventListener('click', (e)=>{ if (!downloadsPopupEl || downloadsPopupEl.classList.contains('hidden')) return; const wrapper = downloadsPopupEl.parentElement; if (wrapper && !wrapper.contains(e.target)) hideDownloadsPopup(); }); // Initialize list with any existing downloads refreshDownloadsMini(); // Subscribe to updates window.downloadsAPI?.onStarted(()=> { refreshDownloadsMini(); }); window.downloadsAPI?.onUpdated(()=> { refreshDownloadsMini(); }); window.downloadsAPI?.onDone(()=> { refreshDownloadsMini(); }); window.downloadsAPI?.onCleared(()=> { refreshDownloadsMini(); }); // window control bindings (Windows frameless window) const minBtn = document.getElementById('min-btn'); const maxBtn = document.getElementById('max-btn'); const closeBtn = document.getElementById('close-btn'); const windowControls = document.getElementById('window-controls'); console.log('[WindowControls] Elements found:', { minBtn: !!minBtn, maxBtn: !!maxBtn, closeBtn: !!closeBtn, windowControls: !!windowControls }); // Detect platform - hide controls on macOS (uses native traffic lights) const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; console.log('[WindowControls] Platform:', navigator.platform, 'isMacOS:', isMacOS); if (windowControls) { if (isMacOS) { // Hide window controls on macOS windowControls.style.display = 'none'; // Remove right padding for window controls document.getElementById('tab-bar').style.paddingRight = '10px'; } else if (minBtn && maxBtn && closeBtn) { // Windows/Linux: Set up custom title bar controls console.log('[WindowControls] Setting up event listeners for Windows/Linux'); minBtn.addEventListener('click', (e) => { console.log('[WindowControls] Minimize clicked'); e.stopPropagation(); ipcRenderer.invoke('window-minimize'); }); maxBtn.addEventListener('click', async (e) => { console.log('[WindowControls] Maximize clicked'); e.stopPropagation(); await ipcRenderer.invoke('window-maximize'); updateMaximizeIcon(); }); closeBtn.addEventListener('click', (e) => { console.log('[WindowControls] Close clicked'); e.stopPropagation(); ipcRenderer.invoke('window-close'); }); // Update maximize icon based on window state async function updateMaximizeIcon() { try { const isMaximized = await ipcRenderer.invoke('window-is-maximized'); const maximizeIcon = maxBtn.querySelector('.maximize-icon'); const restoreIcon = maxBtn.querySelector('.restore-icon'); if (maximizeIcon && restoreIcon) { maximizeIcon.style.display = isMaximized ? 'none' : 'block'; restoreIcon.style.display = isMaximized ? 'block' : 'none'; maxBtn.title = isMaximized ? 'Restore' : 'Maximize'; maxBtn.setAttribute('aria-label', isMaximized ? 'Restore' : 'Maximize'); } } catch (e) { // Ignore errors during state check } } // Initial state check updateMaximizeIcon(); // Listen for window resize to update maximize icon window.addEventListener('resize', () => { // Debounce resize events clearTimeout(window._maximizeIconTimeout); window._maximizeIconTimeout = setTimeout(updateMaximizeIcon, 100); }); } } // update initial zoom display ipcRenderer.invoke('get-zoom-factor').then(z => { document.getElementById('zoom-percent').textContent = `${Math.round(z * 100)}%`; }); // (Removed broken duplicate context menu wiring) // Migrate existing site history from JSON file to localStorage (one-time migration) const migrateSiteHistory = async () => { try { // Check if we already have data in localStorage const existingHistory = getSiteHistory(); if (existingHistory.length === 0) { // Try to load from the old JSON file system console.log('Attempting to migrate site history from JSON file...'); // Since we can't access the file directly, we'll just start fresh // The site-history.json file was the old method, localStorage is the new method } } catch (err) { console.log('Site history migration skipped:', err.message); } }; migrateSiteHistory(); // ipcRenderer.invoke('load-bookmarks').then(bs => { // bookmarks = bs; // console.log('[DEBUG] Loaded bookmarks:', bookmarks); // }); }); // Global keyboard shortcut for DevTools (Ctrl+Shift+I or F12) document.addEventListener('keydown', (e) => { const isMod = (e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'I' || e.key === 'i'); if (isMod || e.key === 'F12') { if (window.electronAPI && window.electronAPI.toggleDevTools) { window.electronAPI.toggleDevTools(); e.preventDefault(); } } }); // zoom helpers function updateZoomUI() { const zp = document.getElementById('zoom-percent'); if (zp) { ipcRenderer.invoke('get-zoom-factor').then(zf => { // just show "NN%", not "Zoom: NN%" zp.textContent = `${Math.round(zf * 100)}%`; }); } } function zoomIn() { ipcRenderer.invoke('zoom-in').then(updateZoomUI); } function zoomOut() { ipcRenderer.invoke('zoom-out').then(updateZoomUI); } // Optional: sample plugin demo hook (safe if plugin missing) try { if (window.sampleHello && typeof window.sampleHello.onHello === 'function') { window.sampleHello.onHello((payload) => { console.log('[Sample Plugin] Hello message:', payload); }); } } catch {} // Utility: close the menu when interacting with a given element (e.g., webview) function attachCloseMenuOnInteract(el) { if (!el) return; const closeIfOpen = () => { hideMainMenuPopup(); if (downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) { hideDownloadsPopup(); } }; el.addEventListener('mousedown', closeIfOpen); el.addEventListener('pointerdown', closeIfOpen); el.addEventListener('focus', closeIfOpen, true); } // Use electronAPI from preload - already defined at top of file // Native context menu: delegate to main via preload API document.addEventListener('contextmenu', (e) => { // Determine if inside a webview or general renderer area const inWebviewArea = e.target.tagName === 'WEBVIEW' || e.composedPath().some(el => el.id === 'webviews'); if (!inWebviewArea) return; // Let default OS menu appear in text inputs etc. if desired e.preventDefault(); // Try to extract link/image/selection info (limited for , better done inside page but sandboxed) const selection = window.getSelection()?.toString() || ''; window.electronAPI?.showContextMenu({ clientX: e.clientX, clientY: e.clientY, selectionText: selection, isEditable: false }); }); // Handle commands from main process triggered by context menu window.addEventListener('nebula-context-command', (e) => { const { cmd, url } = e.detail || {}; if (!cmd) return; switch (cmd) { case 'open-link-new-tab': if (url) createTab(url); break; case 'open-image-new-tab': if (url) createTab(url); break; case 'save-image': if (!url) return; // Try direct network save first (http/file/data) if (/^(https?:|file:|data:)/i.test(url)) { window.electronAPI.saveImageFromNet(url); return; } // For blob: URLs we need to resolve inside the active webview by converting to dataURL if (url.startsWith('blob:')) { if (activeTabId) { const code = `(async()=>{try{const r=await fetch('${url}');const b=await r.blob();return new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(b);});}catch(e){return null;}})();`; ipcRenderer.invoke('browserview-execute-js', { tabId: activeTabId, code }).then(dataUrl => { if (dataUrl) { window.electronAPI.saveImageToDisk('image', dataUrl); } }); } } break; } }); // ------------------------------ // Downloads mini UI helpers // ------------------------------ function toggleDownloadsPopup() { if (!downloadsPopupEl) return; if (downloadsPopupEl.classList.contains('hidden')) showDownloadsPopup(); else hideDownloadsPopup(); } function showDownloadsPopup() { if (!downloadsPopupEl) return; downloadsPopupEl.classList.remove('hidden'); } function hideDownloadsPopup() { if (!downloadsPopupEl) return; downloadsPopupEl.classList.add('hidden'); } function fmtBytesMini(n) { if (!n || n <= 0) return '0 B'; const u = ['B','KB','MB','GB','TB']; const i = Math.floor(Math.log(n)/Math.log(1024)); return (n/Math.pow(1024,i)).toFixed(i===0?0:1) + ' ' + u[i]; } async function refreshDownloadsMini() { if (!window.downloadsAPI) return; const items = await window.downloadsAPI.list(); const has = items && items.length > 0; if (downloadsEmptyEl) downloadsEmptyEl.style.display = has ? 'none' : 'block'; if (downloadsListEl) downloadsListEl.innerHTML = (items||[]).slice(0,5).map(d => { const pct = d.totalBytes > 0 ? Math.min(100, Math.round((d.receivedBytes||0)*100/d.totalBytes)) : (d.state==='completed'?100:0); return `
${d.filename}
${d.state==='in-progress' ? ` ` : ` `}
${d.state} · ${fmtBytesMini(d.receivedBytes||0)} / ${fmtBytesMini(d.totalBytes||0)}
`; }).join(''); if (downloadsListEl) { downloadsListEl.onclick = async (e) => { const btn = e.target.closest('button'); if (!btn) return; const itemEl = btn.closest('.dl-item'); const id = itemEl?.getAttribute('data-id'); const act = btn.getAttribute('data-act'); if (!id || !act) return; await window.downloadsAPI.action(id, act); if (act==='cancel') refreshDownloadsMini(); }; } updateDownloadsRing(items||[]); } function updateDownloadsRing(items) { if (!ringSvgEl) return; // Compute aggregate progress for in-progress downloads const inprog = items.filter(d => d.state === 'in-progress'); const total = inprog.reduce((a,d)=> a + (d.totalBytes||0), 0); const done = inprog.reduce((a,d)=> a + (d.receivedBytes||0), 0); let pct = 0; if (total > 0) pct = Math.max(0, Math.min(1, done/total)); // If none in progress but some completed recently, show full ring briefly; else hide const circumference = 103.67; // 2 * PI * r (r=16.5) const offset = circumference * (1 - pct); ringSvgEl.style.strokeDasharray = `${circumference}`; ringSvgEl.style.strokeDashoffset = `${offset}`; // Hide ring when no active downloads const show = inprog.length > 0; ringSvgEl.style.opacity = show ? '1' : '0'; }