28d2daf06d
Adds explicit AppUserModelID for Windows, robust single-instance and protocol URL handling, and protocol registration for http/https in package.json. Enhances default browser logic to handle OS-specific requirements, provides user feedback in the setup UI, and allows opening system settings for default browser selection. Updates preload and renderer logic to support these changes.
2967 lines
103 KiB
JavaScript
2967 lines
103 KiB
JavaScript
// =============================================================================
|
||
// STEAM DECK / STEAMOS CONTROLLER INPUT FIX
|
||
// =============================================================================
|
||
// These environment variables MUST be set before Electron/Chromium initializes.
|
||
// They signal to Steam's input layer that this application handles its own
|
||
// controller input and should NOT have mouse/keyboard emulation applied.
|
||
//
|
||
// Without these, Steam assumes the app needs Desktop mouse emulation when running
|
||
// in Game Mode, which overrides the app's native gamepad support.
|
||
// =============================================================================
|
||
|
||
// Tell SDL (and by extension Steam Input) that this app uses the gamepad API
|
||
// SDL_GAMECONTROLLERCONFIG is used by SDL to know about controllers
|
||
process.env.SDL_GAMECONTROLLERCONFIG = process.env.SDL_GAMECONTROLLERCONFIG || '';
|
||
|
||
// Signal that this app handles gamepad input natively
|
||
// This prevents Steam from applying mouse emulation in Game Mode
|
||
// IMPORTANT: set to 0 to avoid Steam's virtual gamepad layer when possible.
|
||
// Forcing this to 1 can keep Steam virtualization/emulation active.
|
||
process.env.SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD =
|
||
process.env.SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD ?? '0';
|
||
|
||
// Prevent Steam from remapping the controller to keyboard/mouse
|
||
// Setting to '1' tells Steam we want raw controller access
|
||
process.env.SDL_GAMECONTROLLER_IGNORE_DEVICES = '';
|
||
|
||
// Disable Steam's overlay input hooks for this process if possible
|
||
process.env.SteamNoOverlayUIDrawing = process.env.SteamNoOverlayUIDrawing || '0';
|
||
|
||
// Tell Steam Input we're a native controller app
|
||
// When STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD is 0, Steam won't virtualize the gamepad
|
||
process.env.STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD =
|
||
process.env.STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD ?? '0';
|
||
|
||
// Hint that this is a game/controller-focused app
|
||
process.env.SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS = '1';
|
||
|
||
// =============================================================================
|
||
// STEAMWORKS API INTEGRATION
|
||
// =============================================================================
|
||
// Initialize Steam API to properly signal to Steam that this app handles
|
||
// controller input natively. This is more reliable than environment variables
|
||
// alone for disabling Steam Input's mouse/keyboard emulation.
|
||
//
|
||
// NOTE: Since Nebula is categorized as Software (not a Game), we can't configure
|
||
// Steam Input settings in the Steamworks dashboard. Instead, we initialize the
|
||
// Steam Input API directly to signal native controller handling.
|
||
// =============================================================================
|
||
|
||
let steamworksClient = null;
|
||
let steamworksInitialized = false;
|
||
let steamInput = null;
|
||
let steamworksModule = null;
|
||
let steamCallbacksInterval = null;
|
||
|
||
function initializeSteamworks() {
|
||
try {
|
||
const steamworks = require('steamworks.js');
|
||
steamworksModule = steamworks;
|
||
|
||
// Initialize with Nebula's Steam App ID
|
||
steamworksClient = steamworks.init(4290110);
|
||
steamworksInitialized = true;
|
||
|
||
// Log successful initialization
|
||
const playerName = steamworksClient.localplayer.getName();
|
||
console.log(`[Steamworks] Initialized successfully for user: ${playerName}`);
|
||
|
||
// Initialize Steam Input API - this tells Steam we handle controllers natively
|
||
// and should prevent mouse/keyboard emulation in Game Mode
|
||
try {
|
||
steamInput = steamworksClient.input;
|
||
if (steamInput) {
|
||
console.log('[Steamworks] Steam Input API available - native controller mode enabled');
|
||
|
||
// Explicitly initialize Steam Input.
|
||
// Also ensure Steam callbacks are pumped; Steamworks features (including input)
|
||
// depend on runCallbacks being called regularly.
|
||
try {
|
||
if (typeof steamInput.init === 'function') {
|
||
steamInput.init();
|
||
}
|
||
} catch (initErr) {
|
||
console.log('[Steamworks] Steam Input init failed:', initErr.message);
|
||
}
|
||
|
||
if (!steamCallbacksInterval && typeof steamworks.runCallbacks === 'function') {
|
||
steamCallbacksInterval = setInterval(() => {
|
||
try {
|
||
steamworks.runCallbacks();
|
||
} catch {
|
||
// Ignore callback pump errors to avoid crashing the app.
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Try to get connected controllers to verify input is working
|
||
try {
|
||
const controllers = steamInput.getControllers();
|
||
if (controllers && controllers.length > 0) {
|
||
console.log(`[Steamworks] Found ${controllers.length} connected controller(s)`);
|
||
}
|
||
} catch (inputErr) {
|
||
// Controller enumeration may not be available, that's OK
|
||
}
|
||
}
|
||
} catch (inputErr) {
|
||
console.log('[Steamworks] Steam Input API not fully available:', inputErr.message);
|
||
}
|
||
|
||
return true;
|
||
} catch (e) {
|
||
// Not running through Steam, or steamworks.js not available
|
||
// This is fine - app works without Steam API
|
||
if (e.code === 'MODULE_NOT_FOUND') {
|
||
console.log('[Steamworks] steamworks.js not installed - running without Steam API');
|
||
} else if (e.message && e.message.includes('Steam client')) {
|
||
console.log('[Steamworks] Steam client not running - running without Steam API');
|
||
} else {
|
||
console.log('[Steamworks] Failed to initialize:', e.message || e);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Initialize Steamworks early (before app.ready)
|
||
// This is critical for Steam Input to recognize native controller support
|
||
initializeSteamworks();
|
||
|
||
const { app, BrowserWindow, BrowserView, ipcMain, session, screen, shell, dialog, Menu, clipboard, webContents } = require('electron');
|
||
|
||
// Cleanup Steam callback pump on exit
|
||
app.once('before-quit', () => {
|
||
if (steamCallbacksInterval) {
|
||
clearInterval(steamCallbacksInterval);
|
||
steamCallbacksInterval = null;
|
||
}
|
||
try {
|
||
steamInput?.shutdown?.();
|
||
} catch {}
|
||
});
|
||
const { autoUpdater } = require('electron-updater');
|
||
const { pathToFileURL } = require('url');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { spawn } = require('child_process');
|
||
const PerformanceMonitor = require('./performance-monitor');
|
||
const GPUFallback = require('./gpu-fallback');
|
||
const GPUConfig = require('./gpu-config');
|
||
const PluginManager = require('./plugin-manager');
|
||
const portableData = require('./portable-data');
|
||
|
||
// Windows: set explicit AppUserModelID to ensure proper default-app registration
|
||
// and notification branding.
|
||
if (process.platform === 'win32') {
|
||
try {
|
||
app.setAppUserModelId('com.andrewzambazos.nebula');
|
||
} catch {}
|
||
}
|
||
|
||
// --- Single instance + protocol URL handling ---
|
||
let pendingOpenUrl = null;
|
||
|
||
function extractUrlFromArgv(argv = []) {
|
||
return argv.find(arg => /^https?:\/\//i.test(arg));
|
||
}
|
||
|
||
function openUrlInExistingWindow(targetUrl) {
|
||
if (!targetUrl) return false;
|
||
const windows = BrowserWindow.getAllWindows();
|
||
const mainWindow = windows.find(w => {
|
||
try { return w && !w.isDestroyed() && !w.getParentWindow(); } catch { return false; }
|
||
});
|
||
|
||
if (mainWindow) {
|
||
try { mainWindow.show(); } catch {}
|
||
try { mainWindow.focus(); } catch {}
|
||
try {
|
||
mainWindow.webContents.send('open-url-new-tab', targetUrl);
|
||
return true;
|
||
} catch {}
|
||
try {
|
||
mainWindow.webContents.send('open-url', targetUrl);
|
||
return true;
|
||
} catch {}
|
||
}
|
||
|
||
pendingOpenUrl = targetUrl;
|
||
return false;
|
||
}
|
||
|
||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||
if (!gotSingleInstanceLock) {
|
||
app.quit();
|
||
} else {
|
||
app.on('second-instance', (_event, argv) => {
|
||
const url = extractUrlFromArgv(argv);
|
||
if (url) {
|
||
openUrlInExistingWindow(url);
|
||
return;
|
||
}
|
||
const windows = BrowserWindow.getAllWindows();
|
||
const mainWindow = windows.find(w => {
|
||
try { return w && !w.isDestroyed() && !w.getParentWindow(); } catch { return false; }
|
||
});
|
||
if (mainWindow) {
|
||
try { mainWindow.show(); } catch {}
|
||
try { mainWindow.focus(); } catch {}
|
||
}
|
||
});
|
||
}
|
||
|
||
app.on('open-url', (event, url) => {
|
||
event.preventDefault();
|
||
openUrlInExistingWindow(url);
|
||
});
|
||
|
||
// Capture protocol URL if the app was launched with one
|
||
const initialProtocolUrl = extractUrlFromArgv(process.argv);
|
||
if (initialProtocolUrl) {
|
||
pendingOpenUrl = initialProtocolUrl;
|
||
}
|
||
|
||
// Initialize performance monitoring and GPU management
|
||
const perfMonitor = new PerformanceMonitor();
|
||
const gpuFallback = new GPUFallback();
|
||
const gpuConfig = new GPUConfig();
|
||
const pluginManager = new PluginManager();
|
||
|
||
// =============================================================================
|
||
// DESKTOP MODE: BrowserView tab management
|
||
// =============================================================================
|
||
const desktopViewStateByWindowId = new Map();
|
||
const desktopViewByWebContentsId = new Map();
|
||
const menuPopupByWindowId = new Map();
|
||
const MENU_POPUP_SIZE = { width: 240, height: 240 };
|
||
|
||
const SCROLL_NORMALIZATION_CSS = `
|
||
*, *::before, *::after { scroll-behavior: auto !important; }
|
||
html, body { scroll-behavior: auto !important; }
|
||
`;
|
||
|
||
const SCROLL_NORMALIZATION_JS = `
|
||
(function() {
|
||
if (window.__nebulaScrollNormalized) return;
|
||
window.__nebulaScrollNormalized = true;
|
||
const SCROLL_SPEED = 100;
|
||
document.addEventListener('wheel', function(e) {
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
let target = e.target;
|
||
let scrollable = null;
|
||
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 (!scrollable) scrollable = document.scrollingElement || document.documentElement || document.body;
|
||
let deltaY = e.deltaY;
|
||
let deltaX = e.deltaX;
|
||
if (e.deltaMode === 1) {
|
||
deltaY *= SCROLL_SPEED; deltaX *= SCROLL_SPEED;
|
||
} else if (e.deltaMode === 2) {
|
||
deltaY *= window.innerHeight; deltaX *= window.innerWidth;
|
||
} else {
|
||
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);
|
||
}
|
||
e.preventDefault();
|
||
scrollable.scrollBy({ top: deltaY, left: e.shiftKey ? deltaX : 0, behavior: 'auto' });
|
||
}, { passive: false, capture: true });
|
||
})();
|
||
`;
|
||
|
||
function getDesktopViewState(win) {
|
||
if (!win) return null;
|
||
let state = desktopViewStateByWindowId.get(win.id);
|
||
if (!state) {
|
||
state = {
|
||
views: new Map(), // tabId -> BrowserView
|
||
activeTabId: null,
|
||
bounds: null
|
||
};
|
||
desktopViewStateByWindowId.set(win.id, state);
|
||
}
|
||
return state;
|
||
}
|
||
|
||
function createMenuPopupWindow(parentWin) {
|
||
const menuWin = new BrowserWindow({
|
||
parent: parentWin,
|
||
modal: false,
|
||
frame: false,
|
||
transparent: true,
|
||
resizable: false,
|
||
show: false,
|
||
alwaysOnTop: true,
|
||
skipTaskbar: true,
|
||
focusable: true,
|
||
hasShadow: true,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
sandbox: false,
|
||
partition: 'persist:main'
|
||
}
|
||
});
|
||
|
||
menuWin.setMenu(null);
|
||
try { menuWin.setAlwaysOnTop(true, 'pop-up-menu'); } catch {}
|
||
|
||
const hideMenu = () => {
|
||
if (!menuWin.isDestroyed()) menuWin.hide();
|
||
};
|
||
|
||
menuWin.on('blur', hideMenu);
|
||
parentWin.on('move', hideMenu);
|
||
parentWin.on('resize', hideMenu);
|
||
|
||
menuWin.on('closed', () => {
|
||
try { menuPopupByWindowId.delete(parentWin.id); } catch {}
|
||
try { parentWin.removeListener('move', hideMenu); } catch {}
|
||
try { parentWin.removeListener('resize', hideMenu); } catch {}
|
||
});
|
||
|
||
menuWin.loadFile(path.join(__dirname, 'renderer', 'menu-popup.html'));
|
||
return menuWin;
|
||
}
|
||
|
||
function positionMenuPopup(parentWin, menuWin, anchorRect) {
|
||
if (!parentWin || !menuWin || !anchorRect) return;
|
||
const contentBounds = parentWin.getContentBounds();
|
||
const display = screen.getDisplayMatching(contentBounds);
|
||
const workArea = display?.workArea || contentBounds;
|
||
|
||
const width = MENU_POPUP_SIZE.width;
|
||
const height = MENU_POPUP_SIZE.height;
|
||
let x = Math.round(contentBounds.x + anchorRect.x + anchorRect.width - width);
|
||
let y = Math.round(contentBounds.y + anchorRect.y + anchorRect.height + 6);
|
||
|
||
if (x < workArea.x) x = workArea.x;
|
||
if (y < workArea.y) y = workArea.y;
|
||
if (x + width > workArea.x + workArea.width) x = workArea.x + workArea.width - width;
|
||
if (y + height > workArea.y + workArea.height) y = workArea.y + workArea.height - height;
|
||
|
||
menuWin.setBounds({ x, y, width, height }, false);
|
||
}
|
||
|
||
function getOwnerWindowForContents(contents) {
|
||
if (!contents) return null;
|
||
try {
|
||
if (contents.hostWebContents) {
|
||
return BrowserWindow.fromWebContents(contents.hostWebContents);
|
||
}
|
||
} catch {}
|
||
try {
|
||
const maybeWin = BrowserWindow.fromWebContents(contents);
|
||
if (maybeWin) return maybeWin;
|
||
} catch {}
|
||
const mapped = desktopViewByWebContentsId.get(contents.id);
|
||
return mapped?.win || null;
|
||
}
|
||
|
||
function getActiveDesktopViewWebContents(win) {
|
||
const state = getDesktopViewState(win);
|
||
if (!state || !state.activeTabId) return null;
|
||
const view = state.views.get(state.activeTabId);
|
||
return view?.webContents || null;
|
||
}
|
||
|
||
function sendBrowserViewEvent(win, payload) {
|
||
try {
|
||
if (win && !win.isDestroyed()) {
|
||
win.webContents.send('browserview-event', payload);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function createBrowserViewForTab(win, tabId, url) {
|
||
const state = getDesktopViewState(win);
|
||
if (!state) return null;
|
||
if (state.views.has(tabId)) return state.views.get(tabId);
|
||
|
||
const view = new BrowserView({
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
partition: 'persist:main',
|
||
sandbox: false,
|
||
webSecurity: true,
|
||
allowRunningInsecureContent: false,
|
||
nativeWindowOpen: false,
|
||
additionalArguments: [`--nebula-tab-id=${tabId}`]
|
||
}
|
||
});
|
||
|
||
try {
|
||
if (!process.env.NEBULA_DEBUG_ELECTRON_UA) {
|
||
view.webContents.setUserAgent(app.userAgentFallback || computeBaseUA());
|
||
}
|
||
} catch {}
|
||
|
||
state.views.set(tabId, view);
|
||
desktopViewByWebContentsId.set(view.webContents.id, { win, tabId, view });
|
||
|
||
view.webContents.on('page-title-updated', (_e, title) => {
|
||
sendBrowserViewEvent(win, { tabId, type: 'page-title-updated', title });
|
||
});
|
||
|
||
view.webContents.on('destroyed', () => {
|
||
try { desktopViewByWebContentsId.delete(view.webContents.id); } catch {}
|
||
try { state.views.delete(tabId); } catch {}
|
||
if (state.activeTabId === tabId) state.activeTabId = null;
|
||
});
|
||
|
||
view.webContents.on('page-favicon-updated', (_e, favicons) => {
|
||
sendBrowserViewEvent(win, { tabId, type: 'page-favicon-updated', favicons });
|
||
});
|
||
|
||
view.webContents.on('did-navigate', (_e, url) => {
|
||
sendBrowserViewEvent(win, { tabId, type: 'did-navigate', url });
|
||
});
|
||
|
||
view.webContents.on('did-navigate-in-page', (_e, url) => {
|
||
sendBrowserViewEvent(win, { tabId, type: 'did-navigate-in-page', url });
|
||
});
|
||
|
||
view.webContents.on('did-finish-load', () => {
|
||
sendBrowserViewEvent(win, { tabId, type: 'did-finish-load' });
|
||
});
|
||
|
||
view.webContents.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||
sendBrowserViewEvent(win, {
|
||
tabId,
|
||
type: 'did-fail-load',
|
||
errorCode,
|
||
errorDescription,
|
||
validatedURL,
|
||
isMainFrame
|
||
});
|
||
});
|
||
|
||
view.webContents.on('dom-ready', () => {
|
||
try { view.webContents.insertCSS(SCROLL_NORMALIZATION_CSS); } catch {}
|
||
try { view.webContents.executeJavaScript(SCROLL_NORMALIZATION_JS, true); } catch {}
|
||
sendBrowserViewEvent(win, { tabId, type: 'dom-ready' });
|
||
});
|
||
|
||
view.webContents.on('focus', () => {
|
||
sendBrowserViewEvent(win, { tabId, type: 'focus' });
|
||
});
|
||
|
||
// Route window.open() calls to tabs unless OAuth allowlist matched
|
||
view.webContents.setWindowOpenHandler((details) => {
|
||
const { url: targetUrl } = details;
|
||
if (!/^https?:\/\//i.test(targetUrl)) return { action: 'deny' };
|
||
const oauthDomains = [
|
||
'accounts.google.com',
|
||
'login.microsoftonline.com',
|
||
'appleid.apple.com',
|
||
'github.com/login',
|
||
'auth0.com',
|
||
'okta.com',
|
||
'login.live.com',
|
||
'facebook.com/dialog',
|
||
'api.twitter.com/oauth',
|
||
'discord.com/oauth2'
|
||
];
|
||
const isOAuthDomain = oauthDomains.some(domain => targetUrl.toLowerCase().includes(domain.toLowerCase()));
|
||
if (isOAuthDomain) return { action: 'allow' };
|
||
try { win.webContents.send('open-url-new-tab', targetUrl); } catch {}
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
if (url) {
|
||
try { view.webContents.loadURL(url); } catch {}
|
||
}
|
||
|
||
return view;
|
||
}
|
||
|
||
function setActiveBrowserView(win, tabId) {
|
||
const state = getDesktopViewState(win);
|
||
if (!state) return null;
|
||
const view = state.views.get(tabId);
|
||
if (!view) return null;
|
||
|
||
state.activeTabId = tabId;
|
||
try {
|
||
win.setBrowserView(view);
|
||
if (state.bounds) {
|
||
view.setBounds(state.bounds);
|
||
}
|
||
view.setAutoResize({ width: true, height: true });
|
||
view.webContents.focus();
|
||
} catch {}
|
||
return view;
|
||
}
|
||
|
||
function destroyBrowserView(win, tabId) {
|
||
const state = getDesktopViewState(win);
|
||
if (!state) return false;
|
||
const view = state.views.get(tabId);
|
||
if (!view) return false;
|
||
try {
|
||
if (state.activeTabId === tabId) {
|
||
try { win.setBrowserView(null); } catch {}
|
||
state.activeTabId = null;
|
||
}
|
||
state.views.delete(tabId);
|
||
desktopViewByWebContentsId.delete(view.webContents.id);
|
||
try { view.webContents.destroy(); } catch {}
|
||
} catch {}
|
||
return true;
|
||
}
|
||
|
||
function getZoomTargetForEvent(event) {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return null;
|
||
if (win.__nebulaMode === 'desktop') {
|
||
return getActiveDesktopViewWebContents(win) || win.webContents;
|
||
}
|
||
return win.webContents;
|
||
}
|
||
|
||
// =============================================================================
|
||
// FIRST-TIME SETUP UTILITIES
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Check if this is the first run of the application
|
||
*/
|
||
function getOnboardingFilePath() {
|
||
try {
|
||
const portablePath = portableData.getDataFilePath?.('first-run.json');
|
||
if (portablePath) return portablePath;
|
||
} catch {}
|
||
return path.join(app.getPath('userData'), 'first-run.json');
|
||
}
|
||
|
||
function migrateFirstRunFile() {
|
||
const newPath = getOnboardingFilePath();
|
||
const legacyPath = path.join(__dirname, 'first-run.json');
|
||
if (newPath === legacyPath) return;
|
||
try {
|
||
if (!fs.existsSync(newPath) && fs.existsSync(legacyPath)) {
|
||
const data = fs.readFileSync(legacyPath, 'utf8');
|
||
fs.writeFileSync(newPath, data);
|
||
try { fs.unlinkSync(legacyPath); } catch {}
|
||
console.log('[FirstRun] Migrated first-run.json to user data path');
|
||
}
|
||
} catch (err) {
|
||
console.error('[FirstRun] Error migrating first-run.json:', err);
|
||
}
|
||
}
|
||
|
||
function isFirstRun() {
|
||
migrateFirstRunFile();
|
||
const firstRunPath = getOnboardingFilePath();
|
||
try {
|
||
if (fs.existsSync(firstRunPath)) {
|
||
const data = JSON.parse(fs.readFileSync(firstRunPath, 'utf8'));
|
||
return !data.completed;
|
||
}
|
||
return true; // File doesn't exist, so it's first run
|
||
} catch (err) {
|
||
console.error('[FirstRun] Error checking first-run status:', err);
|
||
return true; // Assume first run on error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get first-run data
|
||
*/
|
||
function getFirstRunData() {
|
||
migrateFirstRunFile();
|
||
const firstRunPath = getOnboardingFilePath();
|
||
try {
|
||
if (fs.existsSync(firstRunPath)) {
|
||
return JSON.parse(fs.readFileSync(firstRunPath, 'utf8'));
|
||
}
|
||
return null;
|
||
} catch (err) {
|
||
console.error('[FirstRun] Error reading first-run data:', err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Complete first-run setup and save preferences
|
||
*/
|
||
async function completeFirstRun(preferences = {}) {
|
||
migrateFirstRunFile();
|
||
const firstRunPath = getOnboardingFilePath();
|
||
const data = {
|
||
completed: true,
|
||
skipped: preferences.skipped || false,
|
||
selectedThemeId: preferences.selectedTheme || 'default',
|
||
defaultBrowserAttempted: preferences.defaultBrowserSet || false,
|
||
defaultBrowserSet: preferences.defaultBrowserSet || false,
|
||
steamCloudOptIn: preferences.steamCloudOptIn || false,
|
||
completedAt: new Date().toISOString()
|
||
};
|
||
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(firstRunPath, JSON.stringify(data, null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(firstRunPath, JSON.stringify(data, null, 2));
|
||
}
|
||
console.log('[FirstRun] First-run setup completed:', data);
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[FirstRun] Error saving first-run data:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if Nebula is set as the default browser
|
||
*/
|
||
function getProtocolClientArgs() {
|
||
if (process.platform === 'win32' && process.defaultApp) {
|
||
const appPath = path.resolve(process.argv[1]);
|
||
return { exe: process.execPath, args: [appPath] };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isDefaultBrowser() {
|
||
try {
|
||
const protocolArgs = getProtocolClientArgs();
|
||
if (protocolArgs) {
|
||
return app.isDefaultProtocolClient('http', protocolArgs.exe, protocolArgs.args)
|
||
&& app.isDefaultProtocolClient('https', protocolArgs.exe, protocolArgs.args);
|
||
}
|
||
return app.isDefaultProtocolClient('http') && app.isDefaultProtocolClient('https');
|
||
} catch (err) {
|
||
console.error('[DefaultBrowser] Error checking default browser status:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set Nebula as the default browser
|
||
*/
|
||
function setAsDefaultBrowser() {
|
||
try {
|
||
const protocolArgs = getProtocolClientArgs();
|
||
const httpResult = protocolArgs
|
||
? app.setAsDefaultProtocolClient('http', protocolArgs.exe, protocolArgs.args)
|
||
: app.setAsDefaultProtocolClient('http');
|
||
const httpsResult = protocolArgs
|
||
? app.setAsDefaultProtocolClient('https', protocolArgs.exe, protocolArgs.args)
|
||
: app.setAsDefaultProtocolClient('https');
|
||
const htmlResult = protocolArgs
|
||
? app.setAsDefaultProtocolClient('html', protocolArgs.exe, protocolArgs.args)
|
||
: app.setAsDefaultProtocolClient('html');
|
||
|
||
const success = httpResult && httpsResult;
|
||
const needsUserAction = success && !isDefaultBrowser();
|
||
|
||
console.log('[DefaultBrowser] Set as default:', { httpResult, httpsResult, htmlResult, needsUserAction });
|
||
return { success, needsUserAction };
|
||
} catch (err) {
|
||
console.error('[DefaultBrowser] Error setting as default browser:', err);
|
||
return { success: false, needsUserAction: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
function openDefaultBrowserSettings() {
|
||
try {
|
||
if (process.platform === 'win32') {
|
||
return shell.openExternal('ms-settings:defaultapps');
|
||
}
|
||
if (process.platform === 'darwin') {
|
||
return shell.openExternal('x-apple.systempreferences:com.apple.preference.general?DefaultWebBrowser');
|
||
}
|
||
} catch (err) {
|
||
console.warn('[DefaultBrowser] Failed to open system settings:', err.message || err);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// =============================================================================
|
||
|
||
// Initialize portable data paths BEFORE app.ready (must be done early)
|
||
// This enables portable mode on all platforms (Windows, macOS, Linux)
|
||
// Data is stored in 'user-data' folder within the application directory
|
||
portableData.initialize();
|
||
|
||
/**
|
||
* Get the path for a user data file (bookmarks, history, etc.)
|
||
* Uses portable path when in portable mode, otherwise uses __dirname
|
||
* @param {string} filename - The filename (e.g., 'bookmarks.json')
|
||
* @returns {string} The full path to the file
|
||
*/
|
||
function getDataFilePath(filename) {
|
||
const portablePath = portableData.getDataFilePath(filename);
|
||
if (portablePath) {
|
||
return portablePath;
|
||
}
|
||
return path.join(__dirname, filename);
|
||
}
|
||
|
||
/**
|
||
* Get the directory path for user data files
|
||
* Uses portable path when in portable mode, otherwise uses __dirname
|
||
* @returns {string} The directory path
|
||
*/
|
||
function getDataDirPath() {
|
||
if (portableData.isPortableMode()) {
|
||
const portablePath = portableData.getPortableDataPath();
|
||
if (portablePath) {
|
||
return portablePath;
|
||
}
|
||
}
|
||
return __dirname;
|
||
}
|
||
|
||
// Try to enable WebAuthn/platform authenticator features early.
|
||
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
||
try {
|
||
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
|
||
// Add common WebAuthn-related feature flags. These are safe attempts to enable platform
|
||
// authenticators and related WebAuthn plumbing in embedded Chromium builds.
|
||
app.commandLine.appendSwitch('enable-features', 'WebAuthn,WebAuthnNestedAssertions,WebAuthnCable');
|
||
} catch (e) {
|
||
// Non-fatal: some environments may not allow commandLine changes at this time.
|
||
}
|
||
|
||
// =============================================================================
|
||
// GAMEPAD / CONTROLLER CHROMIUM FLAGS
|
||
// =============================================================================
|
||
// Enable native gamepad support in Chromium - helps with Steam Deck compatibility
|
||
try {
|
||
// Enable raw gamepad access (bypasses Steam's virtualization when possible)
|
||
app.commandLine.appendSwitch('enable-gamepad-extensions');
|
||
|
||
// Ensure the Gamepad API is enabled and working
|
||
app.commandLine.appendSwitch('enable-blink-features', 'GamepadExtensions');
|
||
|
||
// On Linux/Steam Deck, this can help with gamepad detection
|
||
if (process.platform === 'linux') {
|
||
// Disable Chromium's sandbox for gamepad access if having issues
|
||
// (Only needed in some SteamOS configurations)
|
||
// app.commandLine.appendSwitch('no-sandbox');
|
||
|
||
// Use the system's gamepad config rather than Chromium's built-in
|
||
app.commandLine.appendSwitch('enable-features', 'WebGamepad');
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Gamepad] Failed to set Chromium gamepad flags:', e.message);
|
||
}
|
||
|
||
// Configure GPU settings before app is ready
|
||
gpuConfig.configure();
|
||
|
||
// Set a custom application name
|
||
app.setName('Nebula');
|
||
|
||
// --- Custom User Agent (hide Electron token & brand as Nebula) ---
|
||
// Many sites rely on UA sniffing. Default Electron UA contains 'Electron/x.y.z' which
|
||
// makes detection sites label the app as an Electron application. We construct a
|
||
// Chrome‑compatible UA string without the Electron token, appending a Nebula marker.
|
||
// NOTE: Keep the Chrome and Safari tokens for maximum compatibility.
|
||
// If you ever need to temporarily reveal Electron for debugging, set NEBULA_DEBUG_ELECTRON_UA=1.
|
||
const chromeVersion = process.versions.chrome; // matches bundled Chromium
|
||
const nebulaVersion = app.getVersion();
|
||
function computeBaseUA() {
|
||
let platformPart;
|
||
if (process.platform === 'win32') {
|
||
// Use generic Windows 10 token; detailed build numbers rarely needed and can cause UA entropy issues.
|
||
platformPart = 'Windows NT 10.0; Win64; x64';
|
||
} else if (process.platform === 'darwin') {
|
||
// A neutral modern macOS token; avoid exposing real minor version for stability.
|
||
platformPart = 'Macintosh; Intel Mac OS X 10_15_7';
|
||
} else {
|
||
platformPart = 'X11; Linux x86_64';
|
||
}
|
||
return `Mozilla/5.0 (${platformPart}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 Nebula/${nebulaVersion}`;
|
||
}
|
||
|
||
if (!process.env.NEBULA_DEBUG_ELECTRON_UA) {
|
||
// Set a fallback UA so any new sessions inherit it automatically.
|
||
try { app.userAgentFallback = computeBaseUA(); } catch {}
|
||
}
|
||
|
||
// Setup GPU crash handling
|
||
gpuFallback.setupCrashHandling();
|
||
|
||
// --- clear any prior registrations to prevent duplicate‐handler errors ---
|
||
ipcMain.removeHandler('window-minimize');
|
||
ipcMain.removeHandler('window-maximize');
|
||
ipcMain.removeHandler('window-close');
|
||
|
||
// =============================================================================
|
||
// BIG PICTURE MODE - Steam Deck / Console UI
|
||
// =============================================================================
|
||
|
||
function envTruthy(value) {
|
||
if (value === undefined || value === null) return false;
|
||
const s = String(value).trim().toLowerCase();
|
||
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
||
}
|
||
|
||
function argvHasFlag(flag) {
|
||
return process.argv.includes(flag);
|
||
}
|
||
|
||
/**
|
||
* Heuristic: detect Steam Deck / SteamOS Gaming Mode (gamescope) launches.
|
||
*
|
||
* This is intentionally conservative and only used for picking the *default*
|
||
* startup UI. Users can override via CLI/env.
|
||
*/
|
||
function isGameModeEnvironment() {
|
||
const env = process.env;
|
||
|
||
// Common Steam tenfoot / gamepad UI markers
|
||
if (envTruthy(env.STEAM_GAMEPADUI)) return true;
|
||
if (envTruthy(env.SteamTenfoot)) return true;
|
||
if (envTruthy(env.STEAM_TENFOOT)) return true;
|
||
|
||
// SteamOS / gamescope compositor markers
|
||
const currentDesktop = String(env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
||
const sessionDesktop = String(env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
||
if (currentDesktop.includes('gamescope') || sessionDesktop.includes('gamescope')) return true;
|
||
|
||
if (env.GAMESCOPE_WSI || env.GAMESCOPE_SESSION || env.GAMESCOPE_FOCUSED_APP) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
function shouldStartInBigPictureMode() {
|
||
// Explicit CLI overrides first
|
||
if (argvHasFlag('--no-big-picture') || argvHasFlag('--no-bigpicture')) return false;
|
||
if (argvHasFlag('--big-picture') || argvHasFlag('--bigpicture') || argvHasFlag('--tenfoot') || argvHasFlag('--game-mode')) return true;
|
||
|
||
// Explicit env overrides
|
||
if (envTruthy(process.env.NEBULA_NO_BIG_PICTURE) || envTruthy(process.env.NEBULA_NO_BIGPICTURE)) return false;
|
||
if (envTruthy(process.env.NEBULA_BIG_PICTURE) || envTruthy(process.env.NEBULA_BIGPICTURE) || envTruthy(process.env.NEBULA_GAME_MODE)) return true;
|
||
|
||
// Auto-detect SteamOS Gaming Mode
|
||
return isGameModeEnvironment();
|
||
}
|
||
|
||
// Steam Deck screen dimensions: 1280x800
|
||
const STEAM_DECK_WIDTH = 1280;
|
||
const STEAM_DECK_HEIGHT = 800;
|
||
const HANDHELD_THRESHOLD = 1366; // Consider screens smaller than this as "handheld"
|
||
|
||
// Track if main window is currently in Big Picture Mode (no separate window anymore)
|
||
let isInBigPictureMode = false;
|
||
|
||
/**
|
||
* Check if the current display is likely a Steam Deck or similar handheld
|
||
*/
|
||
function isSteamDeckDisplay() {
|
||
const primaryDisplay = screen.getPrimaryDisplay();
|
||
const { width, height } = primaryDisplay.size;
|
||
|
||
// Check for Steam Deck resolution or similar small screens
|
||
const isSteamDeckRes = width === STEAM_DECK_WIDTH && height === STEAM_DECK_HEIGHT;
|
||
const isSmallScreen = width <= HANDHELD_THRESHOLD;
|
||
|
||
// Also check for certain aspect ratios common in handhelds (16:10, 16:9)
|
||
const aspectRatio = width / height;
|
||
const isHandheldAspect = aspectRatio >= 1.5 && aspectRatio <= 1.8;
|
||
|
||
return isSteamDeckRes || (isSmallScreen && isHandheldAspect);
|
||
}
|
||
|
||
/**
|
||
* Get screen info for UI decisions
|
||
*/
|
||
function getScreenInfo() {
|
||
const primaryDisplay = screen.getPrimaryDisplay();
|
||
const { width, height } = primaryDisplay.size;
|
||
const { scaleFactor } = primaryDisplay;
|
||
|
||
return {
|
||
width,
|
||
height,
|
||
scaleFactor,
|
||
isSteamDeck: width === STEAM_DECK_WIDTH && height === STEAM_DECK_HEIGHT,
|
||
isSmallScreen: width <= HANDHELD_THRESHOLD,
|
||
aspectRatio: width / height,
|
||
suggestBigPicture: isSteamDeckDisplay()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Launch Big Picture Mode in the main window (no separate window)
|
||
* This keeps resources low and prevents SteamOS from creating desktop mode alongside.
|
||
*/
|
||
function launchBigPictureMode() {
|
||
const windows = BrowserWindow.getAllWindows();
|
||
// Prefer the top-level app window (menu popup is a child window)
|
||
const mainWindow = windows.find(w => {
|
||
try { return w && !w.isDestroyed() && !w.getParentWindow(); } catch { return false; }
|
||
});
|
||
|
||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||
console.warn('[BigPicture] No main window available');
|
||
return null;
|
||
}
|
||
|
||
if (isInBigPictureMode) {
|
||
console.log('[BigPicture] Already in Big Picture Mode');
|
||
mainWindow.focus();
|
||
return mainWindow;
|
||
}
|
||
|
||
isInBigPictureMode = true;
|
||
|
||
// Switch mode and ensure any active BrowserView is detached so it can't cover the UI.
|
||
try { mainWindow.__nebulaMode = 'bigpicture'; } catch {}
|
||
try { mainWindow.setBrowserView(null); } catch {}
|
||
|
||
// Enter fullscreen for Big Picture experience
|
||
mainWindow.setFullScreen(true);
|
||
mainWindow.setTitle('Nebula - Big Picture Mode');
|
||
|
||
// Navigate to Big Picture UI
|
||
mainWindow.loadFile('renderer/bigpicture.html');
|
||
|
||
console.log('[BigPicture] Launched in main window');
|
||
return mainWindow;
|
||
}
|
||
|
||
/**
|
||
* Exit Big Picture Mode and return to desktop UI in the same window
|
||
*/
|
||
function exitBigPictureMode() {
|
||
const windows = BrowserWindow.getAllWindows();
|
||
// Prefer the top-level app window (menu popup is a child window)
|
||
const mainWindow = windows.find(w => {
|
||
try { return w && !w.isDestroyed() && !w.getParentWindow(); } catch { return false; }
|
||
});
|
||
|
||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||
console.warn('[BigPicture] No main window to exit from');
|
||
return;
|
||
}
|
||
|
||
if (!isInBigPictureMode) {
|
||
console.log('[BigPicture] Not in Big Picture Mode');
|
||
return;
|
||
}
|
||
|
||
isInBigPictureMode = false;
|
||
|
||
// Restore desktop mode and (after the UI reloads) reattach the active BrowserView.
|
||
try { mainWindow.__nebulaMode = 'desktop'; } catch {}
|
||
|
||
// Exit fullscreen and restore normal window
|
||
mainWindow.setFullScreen(false);
|
||
mainWindow.setTitle('Nebula');
|
||
|
||
// Navigate back to desktop UI
|
||
mainWindow.loadFile('renderer/index.html');
|
||
|
||
try {
|
||
mainWindow.webContents.once('did-finish-load', () => {
|
||
try {
|
||
const state = getDesktopViewState(mainWindow);
|
||
const tabId = state?.activeTabId;
|
||
const view = tabId ? state?.views?.get(tabId) : null;
|
||
if (view) {
|
||
try { mainWindow.setBrowserView(view); } catch {}
|
||
try { if (state.bounds) view.setBounds(state.bounds); } catch {}
|
||
try { view.setAutoResize({ width: true, height: true }); } catch {}
|
||
}
|
||
} catch {}
|
||
});
|
||
} catch {}
|
||
|
||
// Maximize on Windows after exiting fullscreen
|
||
if (process.platform === 'win32') {
|
||
setTimeout(() => {
|
||
try { mainWindow.maximize(); } catch {}
|
||
}, 100);
|
||
}
|
||
|
||
console.log('[BigPicture] Exited to desktop mode');
|
||
}
|
||
|
||
// IPC handlers for Big Picture Mode
|
||
ipcMain.handle('get-screen-info', () => getScreenInfo());
|
||
|
||
ipcMain.handle('launch-bigpicture', () => {
|
||
launchBigPictureMode();
|
||
return { success: true };
|
||
});
|
||
|
||
ipcMain.handle('exit-bigpicture', () => {
|
||
exitBigPictureMode();
|
||
return { success: true };
|
||
});
|
||
|
||
ipcMain.handle('is-bigpicture-suggested', () => {
|
||
return isSteamDeckDisplay();
|
||
});
|
||
|
||
// Check if currently in Big Picture Mode
|
||
ipcMain.handle('is-in-bigpicture', () => {
|
||
return isInBigPictureMode;
|
||
});
|
||
|
||
ipcMain.on('exit-bigpicture', () => {
|
||
exitBigPictureMode();
|
||
});
|
||
|
||
// IPC handler for sending mouse input events to webviews (used by Big Picture Mode)
|
||
ipcMain.handle('webview-send-input-event', async (event, { webContentsId, inputEvent }) => {
|
||
try {
|
||
const { webContents: webContentsModule } = require('electron');
|
||
const targetWebContents = webContentsModule.fromId(webContentsId);
|
||
if (targetWebContents && !targetWebContents.isDestroyed()) {
|
||
targetWebContents.sendInputEvent(inputEvent);
|
||
return { success: true };
|
||
}
|
||
return { success: false, error: 'WebContents not found' };
|
||
} catch (err) {
|
||
console.error('[Main] webview-send-input-event error:', err);
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
// =============================================================================
|
||
|
||
|
||
function createWindow(startUrl, bigPictureMode = false) {
|
||
// Capture high‑resolution startup timing markers
|
||
const perfMarks = { createWindow_called: performance.now() };
|
||
|
||
// Track Big Picture Mode state if starting in that mode
|
||
if (bigPictureMode) {
|
||
isInBigPictureMode = true;
|
||
}
|
||
|
||
// Get the available screen size (avoid full workArea allocation jank by starting slightly smaller then maximizing later if desired)
|
||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||
const initialWidth = Math.min(width, Math.round(width * 0.9));
|
||
const initialHeight = Math.min(height, Math.round(height * 0.9));
|
||
|
||
// Window is created hidden; we only show after first meaningful paint to avoid OS‑level pointer jank while Chromium initializes
|
||
let windowOptions = {
|
||
width: bigPictureMode ? width : initialWidth,
|
||
height: bigPictureMode ? height : initialHeight,
|
||
show: false,
|
||
useContentSize: true,
|
||
backgroundColor: bigPictureMode ? '#0a0a0f' : '#121212', // Big Picture uses darker bg
|
||
resizable: true,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
nodeIntegration: false, // Security & performance improvement
|
||
contextIsolation: true,
|
||
webviewTag: true,
|
||
enableRemoteModule: false, // Deprecated and slow
|
||
nodeIntegrationInSubFrames: false, // Security & performance
|
||
nativeWindowOpen: false,
|
||
spellcheck: false,
|
||
webSecurity: true,
|
||
allowRunningInsecureContent: false,
|
||
experimentalFeatures: false,
|
||
offscreen: false,
|
||
enableWebSQL: false,
|
||
plugins: false,
|
||
backgroundThrottling: false, // keep UI responsive during early load
|
||
// OAuth compatibility settings
|
||
partition: 'persist:main',
|
||
sandbox: false
|
||
},
|
||
fullscreen: bigPictureMode, // Start in fullscreen for Big Picture Mode
|
||
autoHideMenuBar: true,
|
||
icon: process.platform === 'darwin'
|
||
? path.join(__dirname, 'assets/images/Logos/Nebula-Favicon.icns')
|
||
: path.join(__dirname, 'assets/images/Logos/Nebula-favicon.png'),
|
||
title: 'Nebula'
|
||
};
|
||
|
||
if (process.platform === 'darwin') {
|
||
// Use a hidden/transparent title bar on macOS so we can render a
|
||
// custom, sleeker header in the renderer while still supporting
|
||
// native traffic-light placement. The renderer will expose a
|
||
// draggable region via CSS (-webkit-app-region: drag).
|
||
Object.assign(windowOptions, {
|
||
frame: true,
|
||
titleBarStyle: 'hiddenInset',
|
||
trafficLightPosition: { x: 15, y: 20 },
|
||
// Transparent background so renderer chrome blends with content.
|
||
backgroundColor: '#00000000',
|
||
transparent: true,
|
||
});
|
||
} else if (process.platform === 'win32') {
|
||
// Use frameless window on Windows with custom title bar controls
|
||
// rendered in the tab strip area (Firefox-style).
|
||
Object.assign(windowOptions, {
|
||
frame: false,
|
||
backgroundColor: '#0b0d10',
|
||
});
|
||
} else {
|
||
windowOptions.frame = true;
|
||
}
|
||
|
||
const win = new BrowserWindow(windowOptions);
|
||
win.__nebulaMode = bigPictureMode ? 'bigpicture' : 'desktop';
|
||
win.on('closed', () => {
|
||
const state = desktopViewStateByWindowId.get(win.id);
|
||
if (state) {
|
||
for (const view of state.views.values()) {
|
||
try { desktopViewByWebContentsId.delete(view.webContents.id); } catch {}
|
||
try { view.webContents.destroy(); } catch {}
|
||
}
|
||
desktopViewStateByWindowId.delete(win.id);
|
||
}
|
||
});
|
||
perfMarks.browserWindow_instantiated = performance.now();
|
||
|
||
// Intercept window.open() requests and route them into the existing window as a new tab
|
||
// instead of spawning separate BrowserWindows. We allow a small list of specific OAuth
|
||
// domains to open real popups if the flow depends on window.opener relationships.
|
||
// Everything else becomes a new tab.
|
||
win.webContents.setWindowOpenHandler((details) => {
|
||
const { url } = details;
|
||
if (!/^https?:\/\//i.test(url)) return { action: 'deny' };
|
||
// OAuth / SSO allowlist - only allow specific authentication provider domains
|
||
// Be restrictive to prevent normal links from opening in new windows
|
||
const oauthDomains = [
|
||
'accounts.google.com',
|
||
'login.microsoftonline.com',
|
||
'appleid.apple.com',
|
||
'github.com/login',
|
||
'auth0.com',
|
||
'okta.com',
|
||
'login.live.com',
|
||
'facebook.com/dialog',
|
||
'api.twitter.com/oauth',
|
||
'discord.com/oauth2'
|
||
];
|
||
const isOAuthDomain = oauthDomains.some(domain => url.toLowerCase().includes(domain.toLowerCase()));
|
||
if (isOAuthDomain) {
|
||
return { action: 'allow' }; // preserve popup semantics for complex auth flows
|
||
}
|
||
// Forward to renderer to open as tab
|
||
try { win.webContents.send('open-url-new-tab', url); } catch {}
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
// IMPORTANT: Do NOT intercept 'will-navigate' with preventDefault() because
|
||
// that strips POST bodies (turning logins into GET requests). Let Chromium
|
||
// perform the navigation normally. If you need to observe navigations, add
|
||
// a listener without calling preventDefault().
|
||
// (Previous code here was causing login forms to fail.)
|
||
|
||
// Remove deprecated 'new-window' handler that forcibly loaded targets in the
|
||
// same window; this also broke some auth popup flows. setWindowOpenHandler
|
||
// above now governs popup behavior.
|
||
|
||
// ensure all embedded <webview> tags behave predictably without heavy injections
|
||
win.webContents.on('did-attach-webview', (event, webviewContents) => {
|
||
// Route <webview> window.open() calls to tabs unless OAuth allowlist matched
|
||
webviewContents.setWindowOpenHandler((details) => {
|
||
const { url } = details;
|
||
if (!/^https?:\/\//i.test(url)) return { action: 'deny' };
|
||
// OAuth / SSO allowlist - only allow specific authentication provider domains
|
||
const oauthDomains = [
|
||
'accounts.google.com',
|
||
'login.microsoftonline.com',
|
||
'appleid.apple.com',
|
||
'github.com/login',
|
||
'auth0.com',
|
||
'okta.com',
|
||
'login.live.com',
|
||
'facebook.com/dialog',
|
||
'api.twitter.com/oauth',
|
||
'discord.com/oauth2'
|
||
];
|
||
const isOAuthDomain = oauthDomains.some(domain => url.toLowerCase().includes(domain.toLowerCase()));
|
||
if (isOAuthDomain) {
|
||
return { action: 'allow' }; // keep popup for auth
|
||
}
|
||
// Send to main window's webContents to open a new tab
|
||
try {
|
||
win.webContents.send('open-url-new-tab', url);
|
||
} catch {}
|
||
return { action: 'deny' };
|
||
});
|
||
});
|
||
|
||
// Load appropriate UI based on mode (Big Picture or Desktop)
|
||
// Check for first-run and load setup page if needed
|
||
if (bigPictureMode) {
|
||
win.loadFile('renderer/bigpicture.html');
|
||
win.setTitle('Nebula - Big Picture Mode');
|
||
} else {
|
||
// Check if this is the first run (only for desktop mode)
|
||
const firstRun = isFirstRun();
|
||
if (firstRun) {
|
||
console.log('[Startup] First run detected, loading setup page');
|
||
win.loadFile('renderer/setup.html');
|
||
win.setTitle('Welcome to Nebula');
|
||
} else {
|
||
win.loadFile('renderer/index.html');
|
||
}
|
||
}
|
||
perfMarks.loadFile_issued = performance.now();
|
||
|
||
// if caller passed in a URL, forward it to the renderer after load
|
||
if (startUrl) {
|
||
win.webContents.once('did-finish-load', () => {
|
||
win.webContents.send('open-url', startUrl);
|
||
});
|
||
}
|
||
|
||
// Set default zoom to 100%
|
||
const zoomFactor = 1.0;
|
||
const loadStartTime = Date.now();
|
||
// Show window ASAP after first paint for perceived performance
|
||
let shown = false;
|
||
const showNow = (reason) => {
|
||
if (shown) return;
|
||
shown = true;
|
||
win.show();
|
||
if (process.platform === 'win32') {
|
||
// Defer maximize to next frame to avoid large-surface first paint cost
|
||
setTimeout(() => {
|
||
try { win.maximize(); } catch {}
|
||
}, 16);
|
||
}
|
||
console.log(`[Startup] Window shown (${reason}) in ${(performance.now() - perfMarks.createWindow_called).toFixed(1)}ms`);
|
||
};
|
||
|
||
win.webContents.once('ready-to-show', () => showNow('ready-to-show'));
|
||
// Fallback in case ready-to-show is delayed
|
||
setTimeout(() => showNow('timeout-fallback'), 4000);
|
||
|
||
win.webContents.on('did-finish-load', () => {
|
||
win.webContents.setZoomFactor(zoomFactor);
|
||
const loadTime = Date.now() - loadStartTime;
|
||
perfMonitor.trackLoadTime(win.webContents.getURL(), loadTime);
|
||
perfMarks.did_finish_load = performance.now();
|
||
|
||
// Defer heavier, non‑critical tasks to next idle slice to keep UI smooth
|
||
setTimeout(() => {
|
||
// Kick off GPU status check here (was earlier) to avoid competing with first paint
|
||
gpuConfig.checkGPUStatus()
|
||
.then(gpuStatus => {
|
||
console.log('[Deferred] GPU Configuration Results:');
|
||
console.log('- GPU Status:', gpuStatus);
|
||
console.log('- Recommendations:', gpuConfig.getRecommendations());
|
||
})
|
||
.catch(err => console.error('[Deferred] GPU status check failed:', err));
|
||
|
||
// Start performance monitoring after initial load
|
||
perfMonitor.start();
|
||
}, 300);
|
||
// Diagnostic: check WebAuthn / platform authenticator availability in renderer
|
||
try {
|
||
win.webContents.executeJavaScript(`(async function(){
|
||
const out = { hasNavigator: !!window.navigator, hasCredentials: !!navigator.credentials, hasCreate: !!(navigator.credentials && navigator.credentials.create), hasGet: !!(navigator.credentials && navigator.credentials.get) };
|
||
try {
|
||
if (window.PublicKeyCredential) {
|
||
out.PublicKeyCredential = true;
|
||
out.isUserVerifyingPlatformAuthenticatorAvailable = typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function' ? await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() : 'unknown';
|
||
} else {
|
||
out.PublicKeyCredential = false;
|
||
}
|
||
} catch (e) { out.webauthnError = String(e); }
|
||
return out;
|
||
})()`)
|
||
.then(result => {
|
||
console.log('[WebAuthn Diagnostic] renderer report:', result);
|
||
}).catch(err => {
|
||
console.error('[WebAuthn Diagnostic] executeJavaScript failed:', err);
|
||
});
|
||
} catch (e) {
|
||
console.warn('WebAuthn diagnostic injection skipped:', e);
|
||
}
|
||
|
||
// After the first load, let plugins know a window exists
|
||
try { pluginManager.emit('window-created', win); } catch {}
|
||
});
|
||
|
||
// Renderer manages history; no main-process recording here
|
||
}
|
||
|
||
// This method will be called when Electron has finished initialization
|
||
// Configure sessions asynchronously (non-blocking for window creation)
|
||
function configureSessionsAsync() {
|
||
const sessionsToConfigure = [session.fromPartition('persist:main'), session.defaultSession];
|
||
try {
|
||
for (const ses of sessionsToConfigure) {
|
||
if (!ses) continue;
|
||
ses.setPermissionRequestHandler((webContents, permission, callback) => {
|
||
if (['notifications', 'geolocation', 'camera', 'microphone'].includes(permission)) {
|
||
callback(false);
|
||
} else {
|
||
callback(true);
|
||
}
|
||
});
|
||
try {
|
||
let realUA = ses.getUserAgent();
|
||
// If Electron token present and we're not in debug mode, recompute using base builder.
|
||
if (!process.env.NEBULA_DEBUG_ELECTRON_UA) {
|
||
const hasElectron = /Electron\//i.test(realUA);
|
||
if (hasElectron || !/Nebula\//.test(realUA)) {
|
||
realUA = app.userAgentFallback || computeBaseUA();
|
||
ses.setUserAgent(realUA);
|
||
}
|
||
} else {
|
||
// Debug mode: just append Nebula tag if missing (keeps Electron segment visible)
|
||
if (realUA && !/Nebula\//.test(realUA)) {
|
||
ses.setUserAgent(realUA + ' Nebula/' + app.getVersion());
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to read real user agent, keeping default:', e);
|
||
}
|
||
ses.cookies.on('changed', (event, cookie, cause, removed) => {
|
||
if (cookie.domain && (cookie.domain.includes('google') || cookie.domain.includes('accounts'))) {
|
||
console.log(`Cookie ${removed ? 'removed' : 'added'}: ${cookie.name} for ${cookie.domain}`);
|
||
}
|
||
});
|
||
ses.webRequest.onBeforeSendHeaders((details, callback) => {
|
||
const headers = details.requestHeaders;
|
||
if (details.url.includes('accounts.google.com') || details.url.includes('oauth')) {
|
||
headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
|
||
headers['Accept'] = headers['Accept'] || 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8';
|
||
}
|
||
if (!headers['Accept-Language'] && !headers['accept-language']) {
|
||
headers['Accept-Language'] = 'en-US,en;q=0.9';
|
||
}
|
||
callback({ requestHeaders: headers });
|
||
});
|
||
}
|
||
console.log('Session configured successfully for OAuth compatibility');
|
||
} catch (err) {
|
||
console.error('Session setup error:', err);
|
||
}
|
||
}
|
||
|
||
app.whenReady().then(() => {
|
||
const t0 = performance.now();
|
||
|
||
// If launched via SteamOS Gaming Mode / gamepad UI, default to Big Picture Mode.
|
||
// Desktop launches remain unchanged. Big Picture now opens in main window to keep resources low.
|
||
const startUrl = pendingOpenUrl;
|
||
pendingOpenUrl = null;
|
||
|
||
const startInBigPicture = startUrl ? false : shouldStartInBigPictureMode();
|
||
if (startInBigPicture) {
|
||
console.log('[Startup] Detected game mode launch; starting in Big Picture Mode (in main window)');
|
||
createWindow(null, true); // Pass bigPictureMode flag
|
||
} else {
|
||
createWindow(startUrl || null, false);
|
||
}
|
||
|
||
// Initialize user plugins after app ready
|
||
try {
|
||
pluginManager.ensureUserPluginsDir();
|
||
pluginManager.loadAll();
|
||
pluginManager.emit('app-ready');
|
||
} catch (e) {
|
||
console.error('[Plugins] initialization error:', e);
|
||
}
|
||
console.log('[Startup] initial window created (', startInBigPicture ? 'bigpicture' : 'desktop', ') in', (performance.now() - t0).toFixed(1), 'ms after app.whenReady');
|
||
|
||
// Handle GPU process crashes (still register early)
|
||
app.on('gpu-process-crashed', (event, killed) => {
|
||
console.warn('GPU process crashed, killed:', killed);
|
||
if (!killed) {
|
||
console.log('Attempting to recover GPU process...');
|
||
}
|
||
});
|
||
|
||
// Defer session configuration to microtask/next tick (already inexpensive) – keep explicit
|
||
setImmediate(configureSessionsAsync);
|
||
|
||
// Register download handlers for common sessions
|
||
try {
|
||
const mainSes = session.fromPartition('persist:main');
|
||
const defSes = session.defaultSession;
|
||
if (mainSes) registerDownloadHandling(mainSes);
|
||
if (defSes && defSes !== mainSes) registerDownloadHandling(defSes);
|
||
// Allow plugins to attach webRequest hooks
|
||
if (mainSes) pluginManager.applyWebRequestHandlers(mainSes);
|
||
if (defSes) pluginManager.applyWebRequestHandlers(defSes);
|
||
pluginManager.emit('session-configured', { mainSes, defSes });
|
||
} catch (e) {
|
||
console.warn('Failed to register download handlers:', e);
|
||
}
|
||
|
||
if (process.platform === 'darwin') {
|
||
app.dock.setIcon(path.join(__dirname, 'assets/images/Logos/Nebula-Icon.icns'));
|
||
}
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
});
|
||
|
||
// --- Auto-Updater Setup ---
|
||
// Configure auto-updater logging
|
||
try {
|
||
autoUpdater.logger = require('electron-updater').autoUpdater.logger;
|
||
if (autoUpdater.logger && autoUpdater.logger.transports && autoUpdater.logger.transports.file) {
|
||
autoUpdater.logger.transports.file.level = 'info';
|
||
}
|
||
} catch (err) {
|
||
console.log('[AutoUpdater] Could not configure logger:', err.message);
|
||
}
|
||
|
||
// Check for updates after a short delay to not block startup
|
||
setTimeout(() => {
|
||
autoUpdater.checkForUpdatesAndNotify().catch(err => {
|
||
console.log('[AutoUpdater] Update check failed:', err.message);
|
||
});
|
||
}, 3000);
|
||
|
||
// Auto-updater event handlers
|
||
autoUpdater.on('checking-for-update', () => {
|
||
console.log('[AutoUpdater] Checking for updates...');
|
||
broadcastToAll('update-status', { status: 'checking' });
|
||
});
|
||
|
||
autoUpdater.on('update-available', (info) => {
|
||
console.log('[AutoUpdater] Update available:', info.version);
|
||
broadcastToAll('update-status', { status: 'available', version: info.version });
|
||
});
|
||
|
||
autoUpdater.on('update-not-available', (info) => {
|
||
console.log('[AutoUpdater] No update available. Current version:', app.getVersion());
|
||
broadcastToAll('update-status', { status: 'not-available', currentVersion: app.getVersion() });
|
||
});
|
||
|
||
autoUpdater.on('download-progress', (progress) => {
|
||
console.log(`[AutoUpdater] Download progress: ${progress.percent.toFixed(1)}%`);
|
||
broadcastToAll('update-status', { status: 'downloading', progress: progress.percent });
|
||
});
|
||
|
||
autoUpdater.on('update-downloaded', (info) => {
|
||
console.log('[AutoUpdater] Update downloaded:', info.version);
|
||
broadcastToAll('update-status', { status: 'downloaded', version: info.version });
|
||
// Optionally prompt user to restart
|
||
dialog.showMessageBox({
|
||
type: 'info',
|
||
title: 'Update Ready',
|
||
message: `Nebula ${info.version} has been downloaded.`,
|
||
detail: 'The update will be installed when you restart the app.',
|
||
buttons: ['Restart Now', 'Later']
|
||
}).then(result => {
|
||
if (result.response === 0) {
|
||
autoUpdater.quitAndInstall();
|
||
}
|
||
});
|
||
});
|
||
|
||
autoUpdater.on('error', (err) => {
|
||
console.error('[AutoUpdater] Error:', err.message);
|
||
broadcastToAll('update-status', { status: 'error', message: err.message });
|
||
});
|
||
});
|
||
|
||
// Quit when all windows are closed.
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') app.quit();
|
||
});
|
||
|
||
// ipcMain handlers
|
||
|
||
// --- Auto-Update IPC handlers ---
|
||
ipcMain.handle('check-for-updates', async () => {
|
||
try {
|
||
const result = await autoUpdater.checkForUpdates();
|
||
return { success: true, updateInfo: result?.updateInfo };
|
||
} catch (err) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('download-update', async () => {
|
||
try {
|
||
await autoUpdater.downloadUpdate();
|
||
return { success: true };
|
||
} catch (err) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('install-update', () => {
|
||
autoUpdater.quitAndInstall();
|
||
});
|
||
|
||
ipcMain.handle('get-app-version', () => {
|
||
return app.getVersion();
|
||
});
|
||
|
||
ipcMain.handle('get-app-info', () => {
|
||
return {
|
||
version: app.getVersion(),
|
||
electron: process.versions.electron,
|
||
chrome: process.versions.chrome,
|
||
node: process.versions.node,
|
||
v8: process.versions.v8,
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
isPackaged: app.isPackaged,
|
||
isDevelopment: !app.isPackaged
|
||
};
|
||
});
|
||
|
||
// --- First-Time Setup IPC handlers ---
|
||
ipcMain.handle('is-first-run', () => {
|
||
return isFirstRun();
|
||
});
|
||
|
||
ipcMain.handle('get-first-run-data', () => {
|
||
return getFirstRunData();
|
||
});
|
||
|
||
ipcMain.handle('complete-first-run', async (event, preferences) => {
|
||
try {
|
||
const success = await completeFirstRun(preferences);
|
||
return { success };
|
||
} catch (err) {
|
||
console.error('[FirstRun] Error in IPC handler:', err);
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('get-all-themes', () => {
|
||
try {
|
||
const ThemeManager = require('./theme-manager.js');
|
||
const manager = new ThemeManager();
|
||
const themes = manager.getAllThemes();
|
||
const defaultThemeCount = Object.keys(themes.default || {}).length;
|
||
const userThemeCount = Object.keys(themes.user || {}).length;
|
||
const downloadedThemeCount = Object.keys(themes.downloaded || {}).length;
|
||
console.log('[Themes] Loaded themes:', {
|
||
default: defaultThemeCount,
|
||
user: userThemeCount,
|
||
downloaded: downloadedThemeCount
|
||
});
|
||
return themes;
|
||
} catch (err) {
|
||
console.error('[Themes] Error loading themes:', err);
|
||
return { default: { default: { name: 'Default', colors: {} } } };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('apply-theme', async (event, themeId) => {
|
||
try {
|
||
// The theme will be applied in the renderer
|
||
// Here we just save the preference
|
||
console.log('[Themes] Theme selected:', themeId);
|
||
return { success: true };
|
||
} catch (err) {
|
||
console.error('[Themes] Error applying theme:', err);
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('is-default-browser', () => {
|
||
return isDefaultBrowser();
|
||
});
|
||
|
||
ipcMain.handle('set-as-default-browser', () => {
|
||
try {
|
||
const result = setAsDefaultBrowser();
|
||
return result;
|
||
} catch (err) {
|
||
console.error('[DefaultBrowser] Error in IPC handler:', err);
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('open-default-browser-settings', () => {
|
||
try {
|
||
const result = openDefaultBrowserSettings();
|
||
return { success: !!result };
|
||
} catch (err) {
|
||
console.error('[DefaultBrowser] Error opening system settings:', err);
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
// --- window control handlers (only registered once now)
|
||
ipcMain.handle('window-minimize', event => {
|
||
BrowserWindow.fromWebContents(event.sender).minimize();
|
||
});
|
||
ipcMain.handle('window-maximize', event => {
|
||
const w = BrowserWindow.fromWebContents(event.sender);
|
||
w.isMaximized() ? w.unmaximize() : w.maximize();
|
||
});
|
||
ipcMain.handle('window-close', event => {
|
||
BrowserWindow.fromWebContents(event.sender).close();
|
||
});
|
||
ipcMain.handle('window-is-maximized', event => {
|
||
return BrowserWindow.fromWebContents(event.sender).isMaximized();
|
||
});
|
||
|
||
// Add site and search history IPC handlers
|
||
// Site history is now handled via localStorage in the renderer
|
||
// But keep these handlers for compatibility and potential future use
|
||
ipcMain.handle('load-site-history', async () => {
|
||
const filePath = getDataFilePath('site-history.json');
|
||
try {
|
||
const data = await fs.promises.readFile(filePath, 'utf-8');
|
||
return JSON.parse(data);
|
||
} catch (err) {
|
||
return [];
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('save-site-history', async (event, history) => {
|
||
const filePath = getDataFilePath('site-history.json');
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(filePath, JSON.stringify(history, null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('clear-site-history', async () => {
|
||
const filePath = getDataFilePath('site-history.json');
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(filePath, JSON.stringify([], null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(filePath, JSON.stringify([], null, 2));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('load-search-history', async () => {
|
||
const filePath = getDataFilePath('search-history.json');
|
||
try {
|
||
const data = await fs.promises.readFile(filePath, 'utf-8');
|
||
return JSON.parse(data);
|
||
} catch (err) {
|
||
return [];
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('save-search-history', async (event, history) => {
|
||
const filePath = getDataFilePath('search-history.json');
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(filePath, JSON.stringify(history, null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// debug: log default‐homepage changes from renderer
|
||
ipcMain.on('homepage-changed', (event, url) => {
|
||
console.log('[MAIN] homepage-changed →', url);
|
||
});
|
||
|
||
// Handle theme changes - broadcast to all windows
|
||
ipcMain.on('theme-changed', (event, theme) => {
|
||
console.log('[MAIN] theme-changed →', theme?.name || 'unknown');
|
||
// Broadcast theme change to all browser windows
|
||
BrowserWindow.getAllWindows().forEach(win => {
|
||
if (win.webContents && win.webContents.id !== event.sender.id) {
|
||
win.webContents.send('theme-changed', theme);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle display scale changes
|
||
ipcMain.on('set-display-scale', (event, scale) => {
|
||
console.log('[MAIN] set-display-scale →', scale);
|
||
try {
|
||
// Get the webcontents from the event (will be bigPictureWindow)
|
||
const wc = event.sender;
|
||
if (wc && typeof wc.setZoomFactor === 'function') {
|
||
const zoomFactor = Math.max(0.5, Math.min(3, scale / 100));
|
||
wc.setZoomFactor(zoomFactor);
|
||
console.log(`[MAIN] Applied zoom factor: ${zoomFactor} for scale ${scale}%`);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[MAIN] Failed to apply display scale:', err);
|
||
}
|
||
});
|
||
|
||
// Bookmark management
|
||
ipcMain.handle('load-bookmarks', async () => {
|
||
try {
|
||
const bookmarksPath = getDataFilePath('bookmarks.json');
|
||
try {
|
||
await fs.promises.access(bookmarksPath);
|
||
} catch {
|
||
console.log('No bookmarks file found, starting with empty array');
|
||
return [];
|
||
}
|
||
const data = await fs.promises.readFile(bookmarksPath, 'utf8');
|
||
const bookmarks = JSON.parse(data);
|
||
console.log(`Loaded ${bookmarks.length} bookmarks from file`);
|
||
return bookmarks;
|
||
} catch (error) {
|
||
console.error('Error loading bookmarks:', error);
|
||
// Try to create a backup if the file is corrupted
|
||
const bookmarksPath = getDataFilePath('bookmarks.json');
|
||
const backupPath = getDataFilePath(`bookmarks.backup.${Date.now()}.json`);
|
||
try {
|
||
await fs.promises.copyFile(bookmarksPath, backupPath);
|
||
console.log(`Corrupted bookmarks file backed up to: ${backupPath}`);
|
||
} catch (backupError) {
|
||
console.error('Failed to create backup:', backupError);
|
||
}
|
||
return [];
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('save-bookmarks', async (event, bookmarks) => {
|
||
try {
|
||
const bookmarksPath = getDataFilePath('bookmarks.json');
|
||
try {
|
||
await fs.promises.access(bookmarksPath);
|
||
const backupPath = getDataFilePath('bookmarks.backup.json');
|
||
await fs.promises.copyFile(bookmarksPath, backupPath);
|
||
} catch {}
|
||
// Use secure file writing in portable mode
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(bookmarksPath, JSON.stringify(bookmarks, null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(bookmarksPath, JSON.stringify(bookmarks, null, 2));
|
||
}
|
||
console.log(`Saved ${bookmarks.length} bookmarks to file`);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Error saving bookmarks:', error);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('clear-browser-data', async () => {
|
||
try {
|
||
const sessionsToClear = [session.defaultSession, session.fromPartition('persist:main')];
|
||
|
||
for (const ses of sessionsToClear) {
|
||
if (!ses) continue;
|
||
// Clear all common site storage types
|
||
await ses.clearStorageData({
|
||
storages: [
|
||
'cookies',
|
||
'localstorage',
|
||
'indexdb',
|
||
'filesystem',
|
||
'websql',
|
||
'serviceworkers',
|
||
'caches',
|
||
'shadercache',
|
||
'appcache'
|
||
],
|
||
});
|
||
// Clear caches and auth
|
||
await ses.clearCache();
|
||
await ses.clearAuthCache();
|
||
}
|
||
|
||
// Also reset on-disk history JSON files managed by the app
|
||
const siteHistoryPath = getDataFilePath('site-history.json');
|
||
const searchHistoryPath = getDataFilePath('search-history.json');
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(siteHistoryPath, JSON.stringify([], null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(siteHistoryPath, JSON.stringify([], null, 2));
|
||
}
|
||
} catch {}
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(searchHistoryPath, JSON.stringify([], null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(searchHistoryPath, JSON.stringify([], null, 2));
|
||
}
|
||
} catch {}
|
||
|
||
return true; // Indicate success
|
||
} catch (error) {
|
||
console.error('Failed to clear browser data:', error);
|
||
return false; // Indicate failure
|
||
}
|
||
});
|
||
|
||
// Optional: standalone clear for search history JSON
|
||
ipcMain.handle('clear-search-history', async () => {
|
||
const filePath = getDataFilePath('search-history.json');
|
||
try {
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(filePath, JSON.stringify([], null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(filePath, JSON.stringify([], null, 2));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('get-zoom-factor', event => {
|
||
const wc = getZoomTargetForEvent(event);
|
||
return wc ? wc.getZoomFactor() : 1.0;
|
||
});
|
||
|
||
ipcMain.handle('zoom-in', event => {
|
||
const wc = getZoomTargetForEvent(event);
|
||
if (!wc) return 1.0;
|
||
const current = wc.getZoomFactor();
|
||
const z = Math.min(current + 0.1, 3);
|
||
wc.setZoomFactor(z);
|
||
return z;
|
||
});
|
||
|
||
|
||
ipcMain.handle('zoom-out', event => {
|
||
const wc = getZoomTargetForEvent(event);
|
||
if (!wc) return 1.0;
|
||
const current = wc.getZoomFactor();
|
||
const z = Math.max(current - 0.1, 0.25);
|
||
wc.setZoomFactor(z);
|
||
return z;
|
||
});
|
||
|
||
ipcMain.handle('get-display-scale', async (event) => {
|
||
// Try to read from localStorage data (user data path)
|
||
const userDataPath = app.getPath('userData');
|
||
const storageFile = path.join(userDataPath, 'localStorage');
|
||
|
||
try {
|
||
// Try to get from electron store or persistent storage
|
||
// For now, we'll just return a default and let the app set it
|
||
// The display scale is stored in localStorage on the client side
|
||
return 100; // Default to 100%
|
||
} catch (err) {
|
||
return 100; // Default to 100%
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('set-zoom-factor', (event, zoomFactor) => {
|
||
const wc = getZoomTargetForEvent(event);
|
||
if (wc && typeof wc.setZoomFactor === 'function') {
|
||
wc.setZoomFactor(zoomFactor);
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
|
||
// allow renderer to pop a tab into its own window
|
||
ipcMain.handle('open-tab-in-new-window', (event, url) => {
|
||
createWindow(url);
|
||
});
|
||
|
||
ipcMain.handle('save-site-history-entry', async (event, url) => {
|
||
const filePath = getDataFilePath('site-history.json');
|
||
try {
|
||
let data = [];
|
||
try {
|
||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||
data = JSON.parse(raw);
|
||
} catch {}
|
||
// Remove if already exists to avoid duplicates
|
||
data = data.filter(item => item !== url);
|
||
// Add to beginning and clamp size
|
||
data.unshift(url);
|
||
if (data.length > 100) data = data.slice(0, 100);
|
||
if (portableData.isPortableMode()) {
|
||
await portableData.writeSecureFileAsync(filePath, JSON.stringify(data, null, 2));
|
||
} else {
|
||
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[MAIN] Error saving site history entry:', err);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// Add performance monitoring IPC handlers
|
||
ipcMain.handle('get-performance-report', () => {
|
||
return perfMonitor.getReport();
|
||
});
|
||
|
||
ipcMain.handle('force-gc', () => {
|
||
perfMonitor.forceGC();
|
||
return true;
|
||
});
|
||
|
||
// GPU diagnostics handler
|
||
ipcMain.handle('get-gpu-info', async () => {
|
||
try {
|
||
const gpuStatus = await gpuConfig.checkGPUStatus();
|
||
const fallbackStatus = gpuFallback.getStatus();
|
||
const recommendations = gpuConfig.getRecommendations();
|
||
|
||
return {
|
||
...gpuStatus,
|
||
fallbackStatus: fallbackStatus,
|
||
recommendations: recommendations,
|
||
isOptimized: gpuStatus.isSupported && !fallbackStatus.fallbackLevel
|
||
};
|
||
} catch (err) {
|
||
console.error('Error getting GPU info:', err);
|
||
return { error: err.message, isSupported: false };
|
||
}
|
||
});
|
||
|
||
// Force GPU fallback handler
|
||
ipcMain.handle('apply-gpu-fallback', (event, level) => {
|
||
try {
|
||
gpuFallback.applyFallback(level);
|
||
return { success: true, level: level };
|
||
} catch (err) {
|
||
console.error('Error applying GPU fallback:', err);
|
||
return { error: err.message };
|
||
}
|
||
});
|
||
|
||
// About/info handler
|
||
ipcMain.handle('get-about-info', () => {
|
||
try {
|
||
return {
|
||
appName: app.getName(),
|
||
appVersion: app.getVersion(),
|
||
isPackaged: app.isPackaged,
|
||
appPath: app.getAppPath(),
|
||
userDataPath: app.getPath('userData'),
|
||
electronVersion: process.versions.electron,
|
||
chromeVersion: process.versions.chrome,
|
||
nodeVersion: process.versions.node,
|
||
v8Version: process.versions.v8,
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
osType: os.type(),
|
||
osRelease: os.release(),
|
||
cpu: os.cpus()?.[0]?.model || 'Unknown CPU',
|
||
totalMemGB: Math.round((os.totalmem() / (1024 ** 3)) * 10) / 10,
|
||
};
|
||
} catch (err) {
|
||
console.error('Error building about info:', err);
|
||
return { error: err.message };
|
||
}
|
||
});
|
||
|
||
// Toggle DevTools for the requesting window (main window webContents)
|
||
ipcMain.handle('open-devtools', (event) => {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return false;
|
||
const contents = win.__nebulaMode === 'desktop'
|
||
? (getActiveDesktopViewWebContents(win) || win.webContents)
|
||
: win.webContents;
|
||
if (contents.isDevToolsOpened()) {
|
||
contents.closeDevTools();
|
||
} else {
|
||
// Open docked inside the main window (bottom). Other options: 'right', 'undocked', 'detach'
|
||
contents.openDevTools({ mode: 'bottom' });
|
||
}
|
||
return contents.isDevToolsOpened();
|
||
});
|
||
|
||
// =============================================================================
|
||
// BrowserView IPC (desktop mode tabs)
|
||
// =============================================================================
|
||
ipcMain.handle('browserview-create', (event, { tabId, url }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return { success: false, error: 'no-window' };
|
||
if (win.__nebulaMode !== 'desktop') return { success: false, error: 'not-desktop' };
|
||
const view = createBrowserViewForTab(win, tabId, url);
|
||
return { success: !!view };
|
||
} catch (err) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-set-active', (event, { tabId }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return false;
|
||
if (win.__nebulaMode !== 'desktop') return false;
|
||
return !!setActiveBrowserView(win, tabId);
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-destroy', (event, { tabId }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return false;
|
||
if (win.__nebulaMode !== 'desktop') return false;
|
||
return destroyBrowserView(win, tabId);
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-load-url', (event, { tabId, url }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return false;
|
||
if (win.__nebulaMode !== 'desktop') return false;
|
||
const state = getDesktopViewState(win);
|
||
const view = state?.views.get(tabId);
|
||
if (!view) return false;
|
||
view.webContents.loadURL(url);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-reload', (event, { tabId, ignoreCache }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return false;
|
||
if (win.__nebulaMode !== 'desktop') return false;
|
||
const state = getDesktopViewState(win);
|
||
const view = state?.views.get(tabId);
|
||
if (!view) return false;
|
||
if (ignoreCache && typeof view.webContents.reloadIgnoringCache === 'function') {
|
||
view.webContents.reloadIgnoringCache();
|
||
} else {
|
||
view.webContents.reload();
|
||
}
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-get-url', (event, { tabId }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return null;
|
||
if (win.__nebulaMode !== 'desktop') return null;
|
||
const state = getDesktopViewState(win);
|
||
const view = state?.views.get(tabId);
|
||
return view?.webContents.getURL() || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-execute-js', async (event, { tabId, code }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return null;
|
||
if (win.__nebulaMode !== 'desktop') return null;
|
||
const state = getDesktopViewState(win);
|
||
const view = state?.views.get(tabId);
|
||
if (!view) return null;
|
||
return await view.webContents.executeJavaScript(code, true);
|
||
} catch {
|
||
return null;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('browserview-set-bounds', (event, bounds) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return false;
|
||
if (win.__nebulaMode !== 'desktop') return false;
|
||
const state = getDesktopViewState(win);
|
||
if (!state) return false;
|
||
const safeBounds = {
|
||
x: Math.max(0, Math.round(bounds?.x || 0)),
|
||
y: Math.max(0, Math.round(bounds?.y || 0)),
|
||
width: Math.max(0, Math.round(bounds?.width || 0)),
|
||
height: Math.max(0, Math.round(bounds?.height || 0))
|
||
};
|
||
state.bounds = safeBounds;
|
||
if (state.activeTabId) {
|
||
const view = state.views.get(state.activeTabId);
|
||
if (view) {
|
||
view.setBounds(safeBounds);
|
||
}
|
||
}
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// Overlay menu (to sit above BrowserView)
|
||
ipcMain.on('menu-popup-toggle', (event, payload = {}) => {
|
||
try {
|
||
const parentWin = BrowserWindow.fromWebContents(event.sender);
|
||
if (!parentWin) return;
|
||
|
||
let menuWin = menuPopupByWindowId.get(parentWin.id);
|
||
if (!menuWin || menuWin.isDestroyed()) {
|
||
menuWin = createMenuPopupWindow(parentWin);
|
||
menuPopupByWindowId.set(parentWin.id, menuWin);
|
||
}
|
||
|
||
if (menuWin.isVisible()) {
|
||
menuWin.hide();
|
||
return;
|
||
}
|
||
|
||
positionMenuPopup(parentWin, menuWin, payload.anchorRect);
|
||
|
||
const initPayload = { theme: payload.theme || null };
|
||
const sendInit = () => {
|
||
try { menuWin.webContents.send('menu-popup-init', initPayload); } catch {}
|
||
};
|
||
try {
|
||
if (menuWin.webContents.isLoadingMainFrame()) {
|
||
menuWin.webContents.once('did-finish-load', sendInit);
|
||
} else {
|
||
sendInit();
|
||
}
|
||
} catch {}
|
||
|
||
menuWin.show();
|
||
menuWin.focus();
|
||
} catch {}
|
||
});
|
||
|
||
ipcMain.on('menu-popup-hide', (event) => {
|
||
try {
|
||
const parentWin = BrowserWindow.fromWebContents(event.sender);
|
||
if (!parentWin) return;
|
||
const menuWin = menuPopupByWindowId.get(parentWin.id);
|
||
if (menuWin && !menuWin.isDestroyed()) menuWin.hide();
|
||
} catch {}
|
||
});
|
||
|
||
ipcMain.on('menu-popup-command', (event, payload = {}) => {
|
||
try {
|
||
const menuWin = BrowserWindow.fromWebContents(event.sender);
|
||
const parentWin = menuWin?.getParentWindow();
|
||
if (menuWin && !menuWin.isDestroyed()) menuWin.hide();
|
||
if (!parentWin || parentWin.isDestroyed()) return;
|
||
if (!payload?.cmd || payload.cmd === 'close') return;
|
||
parentWin.webContents.send('menu-command', payload);
|
||
} catch {}
|
||
});
|
||
|
||
ipcMain.on('browserview-broadcast', (event, { channel, args }) => {
|
||
try {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return;
|
||
if (win.__nebulaMode !== 'desktop') return;
|
||
const state = getDesktopViewState(win);
|
||
if (!state) return;
|
||
for (const view of state.views.values()) {
|
||
try { view.webContents.send(channel, ...(args || [])); } catch {}
|
||
}
|
||
} catch {}
|
||
});
|
||
|
||
ipcMain.on('browserview-host-message', (event, payload = {}) => {
|
||
try {
|
||
const { tabId, channel, args } = payload || {};
|
||
console.log('[IPC Main] browserview-host-message received, tabId:', tabId, 'channel:', channel);
|
||
|
||
let win = getOwnerWindowForContents(event.sender);
|
||
console.log('[IPC Main] getOwnerWindowForContents returned:', win ? 'window found' : 'null');
|
||
|
||
if (!win && tabId) {
|
||
console.log('[IPC Main] Trying to find window by tabId...');
|
||
for (const candidate of BrowserWindow.getAllWindows()) {
|
||
const state = desktopViewStateByWindowId.get(candidate.id);
|
||
console.log('[IPC Main] Checking window', candidate.id, 'state:', state ? 'found' : 'null', 'has tabId:', state?.views?.has(tabId));
|
||
if (state && state.views && state.views.has(tabId)) {
|
||
win = candidate;
|
||
console.log('[IPC Main] Found window by tabId');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!win || win.isDestroyed()) {
|
||
console.log('[IPC Main] No valid window found, returning');
|
||
return;
|
||
}
|
||
console.log('[IPC Main] Forwarding to renderer');
|
||
win.webContents.send('browserview-host-message', { tabId, channel, args: args || [] });
|
||
} catch (err) {
|
||
console.error('[IPC Main] Error:', err);
|
||
}
|
||
});
|
||
|
||
// Helper function to read package.json version
|
||
function getInstalledElectronVersion() {
|
||
try {
|
||
const packageJsonPath = path.join(__dirname, 'package.json');
|
||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||
|
||
// Get the version from devDependencies
|
||
const electronDep = packageJson.devDependencies?.electron;
|
||
const electronNightlyDep = packageJson.devDependencies?.['electron-nightly'];
|
||
|
||
if (electronDep) {
|
||
return electronDep.replace(/^\D+/, ''); // Remove ^ or ~ or other version specifiers
|
||
}
|
||
if (electronNightlyDep) {
|
||
return electronNightlyDep.replace(/^\D+/, '');
|
||
}
|
||
|
||
return app.getVersion();
|
||
} catch (err) {
|
||
console.error('Error reading installed electron version:', err);
|
||
return app.getVersion();
|
||
}
|
||
}
|
||
|
||
// Electron version management handlers
|
||
ipcMain.handle('get-electron-versions', async (event, buildType = 'stable') => {
|
||
const https = require('https');
|
||
|
||
return new Promise((resolve) => {
|
||
let url;
|
||
|
||
if (buildType === 'nightly') {
|
||
// Get latest nightly version from npm
|
||
url = 'https://registry.npmjs.org/electron-nightly/latest';
|
||
} else {
|
||
// Get latest stable version from npm
|
||
url = 'https://registry.npmjs.org/electron/latest';
|
||
}
|
||
|
||
const request = https.get(url, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try {
|
||
const packageInfo = JSON.parse(data);
|
||
// Get the actual installed version from package.json, not app.getVersion()
|
||
const installedVersion = getInstalledElectronVersion();
|
||
resolve({
|
||
available: packageInfo.version,
|
||
current: installedVersion,
|
||
buildType: buildType
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to parse version info:', err);
|
||
resolve({
|
||
available: null,
|
||
current: getInstalledElectronVersion(),
|
||
error: 'Failed to fetch version info'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
request.on('error', (err) => {
|
||
console.error('Failed to fetch versions:', err);
|
||
resolve({
|
||
available: null,
|
||
current: getInstalledElectronVersion(),
|
||
error: err.message
|
||
});
|
||
});
|
||
|
||
request.setTimeout(5000, () => {
|
||
request.destroy();
|
||
resolve({
|
||
available: null,
|
||
current: getInstalledElectronVersion(),
|
||
error: 'Version check timed out'
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
ipcMain.handle('upgrade-electron', async (event, buildType = 'stable') => {
|
||
const https = require('https');
|
||
const { exec } = require('child_process');
|
||
|
||
console.log('[ELECTRON-UPGRADE] Checking environment...');
|
||
console.log('[ELECTRON-UPGRADE] app.isPackaged:', app.isPackaged);
|
||
console.log('[ELECTRON-UPGRADE] __dirname:', __dirname);
|
||
console.log('[ELECTRON-UPGRADE] process.resourcesPath:', process.resourcesPath);
|
||
|
||
// For packaged apps (like win-unpacked), we can't use npm
|
||
// This feature is only for development with `npm start`
|
||
// Steam users will get updates through Steam
|
||
|
||
return new Promise((resolve) => {
|
||
resolve({
|
||
success: false,
|
||
error: 'Electron updates are not available in packaged builds',
|
||
message: 'For Steam users: Updates are delivered through Steam.\n\nFor developers: Use "npm start" to enable Electron updates during development.'
|
||
});
|
||
});
|
||
|
||
/* Keeping this code commented for future reference if needed
|
||
const packageName = buildType === 'nightly' ? 'electron-nightly' : 'electron';
|
||
const packageJsonPath = path.join(__dirname, 'package.json');
|
||
const nodeModulesPath = path.join(__dirname, 'node_modules');
|
||
|
||
return new Promise((resolve) => {
|
||
// Check if we're in a real development environment
|
||
if (app.isPackaged || !fs.existsSync(packageJsonPath) || !fs.existsSync(nodeModulesPath)) {
|
||
resolve({
|
||
success: false,
|
||
error: 'Electron updates are only available in development mode',
|
||
message: 'Run the app with "npm start" to enable Electron updates.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Run npm install to upgrade the package
|
||
const command = `npm install --save-dev ${packageName}@latest`;
|
||
|
||
console.log('[ELECTRON-UPGRADE] Running command:', command);
|
||
console.log('[ELECTRON-UPGRADE] Working directory:', __dirname);
|
||
|
||
exec(command,
|
||
{
|
||
cwd: __dirname,
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
shell: true,
|
||
env: process.env
|
||
},
|
||
(error, stdout, stderr) => {
|
||
if (error) {
|
||
console.error('[ELECTRON-UPGRADE] Upgrade failed:', error);
|
||
console.error('[ELECTRON-UPGRADE] stderr:', stderr);
|
||
|
||
let errorMsg = error.message;
|
||
if (errorMsg.includes('ENOENT')) {
|
||
errorMsg = 'npm command not found. Please ensure Node.js and npm are installed.';
|
||
} else if (errorMsg.includes('EACCES')) {
|
||
errorMsg = 'Permission denied. Try running as administrator.';
|
||
}
|
||
|
||
resolve({
|
||
success: false,
|
||
error: errorMsg,
|
||
message: 'Failed to upgrade Electron'
|
||
});
|
||
} else {
|
||
console.log('[ELECTRON-UPGRADE] Upgrade output:', stdout);
|
||
if (stderr) console.log('[ELECTRON-UPGRADE] stderr:', stderr);
|
||
|
||
// Clean up alternate package
|
||
try {
|
||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||
if (buildType === 'nightly' && packageJson.devDependencies?.electron) {
|
||
delete packageJson.devDependencies.electron;
|
||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||
} else if (buildType === 'stable' && packageJson.devDependencies?.['electron-nightly']) {
|
||
delete packageJson.devDependencies['electron-nightly'];
|
||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||
}
|
||
} catch (err) {
|
||
console.warn('[ELECTRON-UPGRADE] Could not clean up alternate package:', err);
|
||
}
|
||
|
||
resolve({
|
||
success: true,
|
||
message: 'Electron upgrade completed. Restarting application...'
|
||
});
|
||
}
|
||
}
|
||
);
|
||
});
|
||
*/
|
||
});
|
||
|
||
ipcMain.handle('restart-app', async (event) => {
|
||
// Quit and relaunch the app
|
||
app.relaunch();
|
||
app.quit();
|
||
});
|
||
|
||
// Open local file dialog -> returns file:// URL (or null if cancelled)
|
||
ipcMain.handle('show-open-file-dialog', async () => {
|
||
try {
|
||
const result = await dialog.showOpenDialog({
|
||
properties: ['openFile'],
|
||
filters: [
|
||
{ name: 'HTML Files', extensions: ['html', 'htm', 'xhtml'] },
|
||
{ name: 'All Files', extensions: ['*'] }
|
||
]
|
||
});
|
||
if (result.canceled || !result.filePaths || !result.filePaths.length) return null;
|
||
const filePath = result.filePaths[0];
|
||
try {
|
||
return pathToFileURL(filePath).href;
|
||
} catch {
|
||
// Fallback manual conversion
|
||
let p = filePath.replace(/\\/g, '/');
|
||
if (!p.startsWith('/')) p = '/' + p; // ensure leading slash for drive letters
|
||
return 'file://' + (p.startsWith('/') ? '/' : '') + p; // double slash safety
|
||
}
|
||
} catch (err) {
|
||
console.error('open-file dialog failed:', err);
|
||
return null;
|
||
}
|
||
});
|
||
|
||
// Helper to build and show a native context menu for a given webContents + params
|
||
function buildAndShowContextMenu(sender, params = {}) {
|
||
try {
|
||
const ownerWin = getOwnerWindowForContents(sender);
|
||
const embedder = ownerWin?.webContents || sender.hostWebContents || sender;
|
||
const template = [];
|
||
|
||
template.push(
|
||
{ label: 'Back', enabled: sender.canGoBack?.(), click: () => { try { sender.goBack(); } catch {} } },
|
||
{ label: 'Forward', enabled: sender.canGoForward?.(), click: () => { try { sender.goForward(); } catch {} } },
|
||
{ label: 'Reload', click: () => { try { sender.reload(); } catch {} } },
|
||
{ type: 'separator' }
|
||
);
|
||
|
||
// Link actions
|
||
const linkURL = params.linkURL && params.linkURL.startsWith('http') ? params.linkURL : undefined;
|
||
if (linkURL) {
|
||
template.push(
|
||
{ label: 'Open Link in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-link-new-tab', url: linkURL }) },
|
||
{ label: 'Download Link', click: () => {
|
||
try { (sender.hostWebContents || sender).downloadURL(linkURL); } catch (e) { console.error('downloadURL failed:', e); }
|
||
}
|
||
},
|
||
{ label: 'Open Link Externally', click: () => shell.openExternal(linkURL).catch(()=>{}) },
|
||
{ label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) },
|
||
{ type: 'separator' }
|
||
);
|
||
}
|
||
|
||
// Image actions
|
||
const imageURL = (params.mediaType === 'image' && params.srcURL) ? params.srcURL : (params.imgURL || undefined);
|
||
if (imageURL) {
|
||
template.push(
|
||
{ label: 'Open Image in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-image-new-tab', url: imageURL }) },
|
||
{ label: 'Copy Image Address', click: () => clipboard.writeText(imageURL) },
|
||
{ label: 'Save Image As...', click: () => embedder.send('context-menu-command', { cmd: 'save-image', url: imageURL, mime: params.mediaType === 'image' ? params.mimeType : undefined }) },
|
||
{ type: 'separator' }
|
||
);
|
||
}
|
||
|
||
// Text / editable
|
||
if (params.isEditable) {
|
||
template.push(
|
||
{ label: 'Undo', role: 'undo' },
|
||
{ label: 'Redo', role: 'redo' },
|
||
{ type: 'separator' },
|
||
{ label: 'Cut', role: 'cut' },
|
||
{ label: 'Copy', role: 'copy' },
|
||
{ label: 'Paste', role: 'paste' },
|
||
{ label: 'Select All', role: 'selectAll' },
|
||
{ type: 'separator' }
|
||
);
|
||
} else if (params.selectionText) {
|
||
template.push(
|
||
{ label: 'Copy', role: 'copy' },
|
||
{ label: 'Select All', role: 'selectAll' },
|
||
{ type: 'separator' }
|
||
);
|
||
}
|
||
|
||
template.push({
|
||
label: 'Inspect Element',
|
||
click: () => {
|
||
try {
|
||
const inspectTarget = sender;
|
||
const inspectX = params.x ?? params.clientX ?? 0;
|
||
const inspectY = params.y ?? params.clientY ?? 0;
|
||
|
||
// Open DevTools docked at bottom if not already open
|
||
if (!inspectTarget.isDevToolsOpened()) {
|
||
inspectTarget.openDevTools({ mode: 'bottom' });
|
||
}
|
||
|
||
// Inspect the element
|
||
setTimeout(() => {
|
||
try {
|
||
inspectTarget.inspectElement(inspectX, inspectY);
|
||
} catch (e) {
|
||
// Fallback: try on original sender
|
||
try { sender.inspectElement(inspectX, inspectY); } catch {}
|
||
}
|
||
}, 50);
|
||
} catch (err) {
|
||
console.error('Inspect Element failed:', err);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Allow plugins to customize/append context menu
|
||
try { pluginManager.applyContextMenuContrib(template, params, sender); } catch {}
|
||
const menu = Menu.buildFromTemplate(template);
|
||
const win = ownerWin || BrowserWindow.fromWebContents(embedder);
|
||
if (win) menu.popup({ window: win });
|
||
} catch (err) {
|
||
console.error('Failed to build context menu:', err);
|
||
}
|
||
}
|
||
|
||
// IPC trigger (legacy / renderer-requested)
|
||
ipcMain.handle('show-context-menu', (event, params = {}) => {
|
||
buildAndShowContextMenu(event.sender, params);
|
||
});
|
||
|
||
// Plugins: expose renderer preload list
|
||
ipcMain.handle('plugins-get-renderer-preloads', () => {
|
||
try { return pluginManager.getRendererPreloads(); } catch { return []; }
|
||
});
|
||
|
||
// Plugins: expose registered internal pages (nebula://<id>)
|
||
ipcMain.handle('plugins-get-pages', () => {
|
||
try { return pluginManager.getRendererPages(); } catch { return []; }
|
||
});
|
||
|
||
// Plugins: management IPC for settings UI
|
||
ipcMain.handle('plugins-list', () => pluginManager.discoverPlugins());
|
||
ipcMain.handle('plugins-set-enabled', async (_e, { id, enabled }) => {
|
||
const ok = await pluginManager.setEnabled(id, enabled);
|
||
// Reload to apply enable/disable (requires app reload for renderer preloads)
|
||
pluginManager.reload();
|
||
return ok;
|
||
});
|
||
ipcMain.handle('plugins-reload', (_e, { id } = {}) => {
|
||
pluginManager.reload(id);
|
||
return true;
|
||
});
|
||
|
||
// Automatic native context menu for any webContents (windows + webviews)
|
||
app.on('web-contents-created', (event, contents) => {
|
||
contents.on('context-menu', (e, params) => {
|
||
buildAndShowContextMenu(contents, params);
|
||
});
|
||
|
||
// Emit to plugins
|
||
try { pluginManager.emit('web-contents-created', contents); } catch {}
|
||
|
||
// On macOS, when a page (or a <webview>) enters HTML fullscreen (e.g., YouTube video),
|
||
// also toggle the BrowserWindow into simple fullscreen so the content uses the whole
|
||
// screen and macOS traffic lights/titlebar are hidden. Revert when HTML fullscreen exits.
|
||
if (process.platform === 'darwin') {
|
||
const getOwningWindow = () => {
|
||
try {
|
||
const host = contents.hostWebContents || contents;
|
||
return BrowserWindow.fromWebContents(host) || null;
|
||
} catch { return null; }
|
||
};
|
||
|
||
contents.on('enter-html-full-screen', () => {
|
||
const win = getOwningWindow();
|
||
if (!win) return;
|
||
win.__htmlFsDepth = (win.__htmlFsDepth || 0) + 1;
|
||
// If the window is already in native fullscreen (green button), don't switch modes
|
||
const alreadyNativeFs = typeof win.isFullScreen === 'function' && win.isFullScreen();
|
||
if (!alreadyNativeFs && !win.isSimpleFullScreen?.()) {
|
||
try { win.setSimpleFullScreen?.(true); win.__htmlFsUsingSimple = true; } catch {}
|
||
}
|
||
});
|
||
|
||
contents.on('leave-html-full-screen', () => {
|
||
const win = getOwningWindow();
|
||
if (!win) return;
|
||
win.__htmlFsDepth = Math.max(0, (win.__htmlFsDepth || 1) - 1);
|
||
if (win.__htmlFsDepth === 0 && win.__htmlFsUsingSimple) {
|
||
try { if (win.isSimpleFullScreen?.()) win.setSimpleFullScreen?.(false); } catch {}
|
||
win.__htmlFsUsingSimple = false;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// --- Image save handlers ---
|
||
ipcMain.handle('save-image-from-dataurl', async (event, { suggestedName = 'image', dataUrl }) => {
|
||
try {
|
||
if (!dataUrl || !dataUrl.startsWith('data:')) return false;
|
||
const match = /^data:(.*?);base64,(.*)$/.exec(dataUrl);
|
||
if (!match) return false;
|
||
const mime = match[1] || 'application/octet-stream';
|
||
const ext = (mime.split('/')[1] || 'png').split(';')[0];
|
||
const buf = Buffer.from(match[2], 'base64');
|
||
const win = BrowserWindow.fromWebContents(event.sender.hostWebContents || event.sender);
|
||
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: `${suggestedName}.${ext}` });
|
||
if (canceled || !filePath) return false;
|
||
await fs.promises.writeFile(filePath, buf);
|
||
return true;
|
||
} catch (err) {
|
||
console.error('save-image-from-dataurl failed:', err);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('save-image-from-url', async (event, { url }) => {
|
||
if (!url) return false;
|
||
const win = BrowserWindow.fromWebContents(event.sender.hostWebContents || event.sender);
|
||
try {
|
||
let dataBuf;
|
||
if (url.startsWith('http')) {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error('HTTP '+res.status);
|
||
const arrayBuf = await res.arrayBuffer();
|
||
dataBuf = Buffer.from(arrayBuf);
|
||
const ctype = res.headers.get('content-type') || 'application/octet-stream';
|
||
const ext = (ctype.split('/')[1] || 'png').split(';')[0];
|
||
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: `image.${ext}` });
|
||
if (canceled || !filePath) return false;
|
||
await fs.promises.writeFile(filePath, dataBuf);
|
||
return true;
|
||
} else if (url.startsWith('data:')) {
|
||
// Forward to dataURL handler path – easier to keep logic single
|
||
return ipcMain.emit('save-image-from-dataurl', event, { dataUrl: url });
|
||
} else if (url.startsWith('file:')) {
|
||
// Copy file to chosen destination
|
||
const filePathSrc = new URL(url).pathname.replace(/^\//, '');
|
||
const base = path.basename(filePathSrc);
|
||
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: base });
|
||
if (canceled || !filePath) return false;
|
||
await fs.promises.copyFile(filePathSrc, filePath);
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
} catch (err) {
|
||
console.error('save-image-from-url failed:', err);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// =========================
|
||
// Download manager plumbing
|
||
// =========================
|
||
|
||
// In-memory download registry
|
||
const downloads = new Map(); // id -> { id, url, filename, savePath, totalBytes, receivedBytes, state, startedAt, mime, canResume, paused, scan? }
|
||
|
||
function broadcastToAll(channel, payload) {
|
||
try {
|
||
for (const wc of webContents.getAllWebContents()) {
|
||
try { wc.send(channel, payload); } catch {}
|
||
}
|
||
} catch (e) {
|
||
// Fallback to windows only
|
||
for (const win of BrowserWindow.getAllWindows()) {
|
||
try { win.webContents.send(channel, payload); } catch {}
|
||
}
|
||
}
|
||
}
|
||
|
||
function registerDownloadHandling(ses) {
|
||
if (!ses || ses.__nebulaDownloadsHooked) return;
|
||
ses.__nebulaDownloadsHooked = true;
|
||
ses.on('will-download', async (event, item, wc) => {
|
||
try {
|
||
// Build an id (prefer stable GUID if available)
|
||
const id = typeof item.getGUID === 'function' ? item.getGUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
item.__nebulaId = id;
|
||
const filename = item.getFilename();
|
||
const mime = item.getMimeType?.() || 'application/octet-stream';
|
||
const totalBytes = item.getTotalBytes();
|
||
const url = item.getURL();
|
||
|
||
// Choose a default save path under user's Downloads, ensure unique to avoid overwrite
|
||
const defaultDir = app.getPath('downloads');
|
||
const uniquePath = await computeUniqueSavePath(defaultDir, filename);
|
||
try { item.setSavePath(uniquePath); } catch {}
|
||
|
||
const info = {
|
||
id, url, filename,
|
||
savePath: uniquePath,
|
||
totalBytes,
|
||
receivedBytes: 0,
|
||
state: 'in-progress',
|
||
startedAt: Date.now(),
|
||
mime,
|
||
canResume: false,
|
||
paused: false,
|
||
scan: { status: process.platform === 'win32' ? 'pending' : 'unavailable', engine: process.platform === 'win32' ? 'Windows Defender' : 'none' }
|
||
};
|
||
downloads.set(id, { ...info, item });
|
||
const payload = { ...info };
|
||
broadcastToAll('downloads-started', payload);
|
||
|
||
item.on('updated', (e, state) => {
|
||
const d = downloads.get(id);
|
||
if (!d) return;
|
||
d.receivedBytes = item.getReceivedBytes();
|
||
d.canResume = !!item.canResume?.();
|
||
d.paused = !!item.isPaused?.();
|
||
d.state = state === 'interrupted' ? 'interrupted' : 'in-progress';
|
||
downloads.set(id, d);
|
||
broadcastToAll('downloads-updated', {
|
||
id,
|
||
receivedBytes: d.receivedBytes,
|
||
totalBytes: d.totalBytes,
|
||
state: d.state,
|
||
canResume: d.canResume,
|
||
paused: d.paused
|
||
});
|
||
});
|
||
|
||
item.once('done', async (e, state) => {
|
||
const d = downloads.get(id) || {};
|
||
const finalState = state === 'completed' ? 'completed' : (state === 'cancelled' ? 'cancelled' : 'interrupted');
|
||
const final = {
|
||
id,
|
||
url,
|
||
filename,
|
||
savePath: item.getSavePath?.() || d.savePath,
|
||
totalBytes: d.totalBytes || item.getTotalBytes?.() || 0,
|
||
receivedBytes: item.getReceivedBytes?.() || d.receivedBytes || 0,
|
||
state: finalState,
|
||
startedAt: d.startedAt || Date.now(),
|
||
endedAt: Date.now(),
|
||
mime,
|
||
scan: d.scan || { status: process.platform === 'win32' ? 'pending' : 'unavailable', engine: process.platform === 'win32' ? 'Windows Defender' : 'none' }
|
||
};
|
||
// Store minimal object; drop live item ref
|
||
downloads.set(id, final);
|
||
broadcastToAll('downloads-done', final);
|
||
|
||
// Kick off a malware scan on Windows if the download completed and path exists
|
||
if (finalState === 'completed' && final.savePath && process.platform === 'win32') {
|
||
try {
|
||
// Update to scanning state and broadcast
|
||
const cur = downloads.get(id) || final;
|
||
cur.scan = { ...(cur.scan || {}), status: 'scanning', engine: 'Windows Defender' };
|
||
downloads.set(id, cur);
|
||
broadcastToAll('downloads-scan-started', { id, savePath: final.savePath });
|
||
|
||
const result = await scanFileForMalware(final.savePath);
|
||
const updated = downloads.get(id) || cur;
|
||
updated.scan = result;
|
||
downloads.set(id, updated);
|
||
broadcastToAll('downloads-scan-result', { id, scan: result });
|
||
} catch (scanErr) {
|
||
const updated = downloads.get(id) || final;
|
||
updated.scan = { status: 'error', engine: 'Windows Defender', details: String(scanErr && scanErr.message || scanErr) };
|
||
downloads.set(id, updated);
|
||
broadcastToAll('downloads-scan-result', { id, scan: updated.scan });
|
||
}
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error('will-download handler error:', err);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function computeUniqueSavePath(dir, baseName) {
|
||
try {
|
||
const target = path.join(dir, baseName);
|
||
try {
|
||
await fs.promises.access(target);
|
||
// Already exists, create a (n) suffix
|
||
const { name, ext } = splitNameExt(baseName);
|
||
for (let i = 1; i < 10000; i++) {
|
||
const candidate = path.join(dir, `${name} (${i})${ext}`);
|
||
try { await fs.promises.access(candidate); } catch { return candidate; }
|
||
}
|
||
// Fallback if too many
|
||
return path.join(dir, `${Date.now()}-${baseName}`);
|
||
} catch {
|
||
return target; // does not exist
|
||
}
|
||
} catch (e) {
|
||
// Fallback to temp directory
|
||
return path.join(app.getPath('downloads'), `${Date.now()}-${baseName}`);
|
||
}
|
||
}
|
||
|
||
function splitNameExt(filename) {
|
||
const ext = path.extname(filename);
|
||
const name = filename.slice(0, filename.length - ext.length);
|
||
return { name, ext };
|
||
}
|
||
|
||
// IPC: list downloads
|
||
ipcMain.handle('downloads-get-all', () => {
|
||
return Array.from(downloads.values()).map(d => {
|
||
const { item, ...rest } = d;
|
||
if (item) {
|
||
return {
|
||
...rest,
|
||
receivedBytes: item.getReceivedBytes?.() ?? rest.receivedBytes ?? 0,
|
||
totalBytes: item.getTotalBytes?.() ?? rest.totalBytes ?? 0,
|
||
state: rest.state || 'in-progress',
|
||
paused: item.isPaused?.() || false,
|
||
canResume: item.canResume?.() || false,
|
||
scan: rest.scan || { status: process.platform === 'win32' ? 'pending' : 'unavailable', engine: process.platform === 'win32' ? 'Windows Defender' : 'none' }
|
||
};
|
||
}
|
||
return rest;
|
||
});
|
||
});
|
||
|
||
// IPC: control a download (pause/resume/cancel/open/show)
|
||
ipcMain.handle('downloads-action', async (event, { id, action }) => {
|
||
const d = downloads.get(id);
|
||
if (!d) return false;
|
||
const item = d.item;
|
||
try {
|
||
switch (action) {
|
||
case 'pause':
|
||
if (item && !item.isPaused?.()) item.pause?.();
|
||
return true;
|
||
case 'resume':
|
||
if (item && item.canResume?.()) item.resume?.();
|
||
return true;
|
||
case 'cancel':
|
||
if (item && d.state === 'in-progress') item.cancel?.();
|
||
return true;
|
||
case 'delete-file': {
|
||
if (d.savePath) {
|
||
try {
|
||
await fs.promises.unlink(d.savePath);
|
||
// Mark entry as deleted (custom state) and clear savePath
|
||
const updated = { ...d, state: d.state === 'completed' ? 'deleted' : d.state, savePath: null };
|
||
downloads.set(id, updated);
|
||
broadcastToAll('downloads-updated', { id, state: updated.state, savePath: null });
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Failed to delete file:', e);
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
case 'rescan': {
|
||
if (d.savePath && process.platform === 'win32') {
|
||
try {
|
||
const cur = downloads.get(id) || d;
|
||
cur.scan = { status: 'scanning', engine: 'Windows Defender' };
|
||
downloads.set(id, cur);
|
||
broadcastToAll('downloads-scan-started', { id, savePath: d.savePath });
|
||
const result = await scanFileForMalware(d.savePath);
|
||
const updated = downloads.get(id) || cur;
|
||
updated.scan = result;
|
||
downloads.set(id, updated);
|
||
broadcastToAll('downloads-scan-result', { id, scan: result });
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Rescan failed:', e);
|
||
const updated = downloads.get(id) || d;
|
||
updated.scan = { status: 'error', engine: 'Windows Defender', details: String(e && e.message || e) };
|
||
downloads.set(id, updated);
|
||
broadcastToAll('downloads-scan-result', { id, scan: updated.scan });
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
case 'open-file':
|
||
if (d.savePath) {
|
||
await shell.openPath(d.savePath);
|
||
return true;
|
||
}
|
||
return false;
|
||
case 'show-in-folder':
|
||
if (d.savePath) {
|
||
shell.showItemInFolder(d.savePath);
|
||
return true;
|
||
}
|
||
return false;
|
||
default:
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
console.error('downloads-action error:', e);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// IPC: clear completed entries from the registry (keeps in-progress)
|
||
ipcMain.handle('downloads-clear-completed', () => {
|
||
for (const [id, d] of downloads.entries()) {
|
||
if (d.state === 'completed' || d.state === 'cancelled' || d.state === 'deleted') downloads.delete(id);
|
||
}
|
||
broadcastToAll('downloads-cleared');
|
||
return true;
|
||
});
|
||
|
||
// ---------------------------
|
||
// Malware scan helpers (Windows Defender)
|
||
// ---------------------------
|
||
async function findDefenderMpCmdRun() {
|
||
if (process.platform !== 'win32') return null;
|
||
const candidates = [];
|
||
const programData = process.env['ProgramData'];
|
||
if (programData) {
|
||
const platformDir = path.join(programData, 'Microsoft', 'Windows Defender', 'Platform');
|
||
try {
|
||
const entries = await fs.promises.readdir(platformDir, { withFileTypes: true });
|
||
const versions = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||
// Sort versions descending (simple lex sort approximates ok as versions are zero-padded; fallback to reverse chronological by stats)
|
||
versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
|
||
for (const v of versions) {
|
||
candidates.push(path.join(platformDir, v, 'MpCmdRun.exe'));
|
||
}
|
||
} catch {}
|
||
}
|
||
const programFiles = process.env['ProgramFiles'] || 'C://Program Files';
|
||
candidates.push(path.join(programFiles, 'Windows Defender', 'MpCmdRun.exe'));
|
||
candidates.push(path.join(programFiles, 'Microsoft Defender', 'MpCmdRun.exe'));
|
||
for (const c of candidates) {
|
||
try {
|
||
await fs.promises.access(c, fs.constants.X_OK | fs.constants.R_OK);
|
||
return c;
|
||
} catch {}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function scanFileForMalware(filePath) {
|
||
if (process.platform !== 'win32') {
|
||
return { status: 'unavailable', engine: 'none', details: 'Malware scanning is only available on Windows with Microsoft Defender.' };
|
||
}
|
||
try {
|
||
// Ensure file exists
|
||
await fs.promises.access(filePath, fs.constants.R_OK);
|
||
} catch {
|
||
return { status: 'error', engine: 'Windows Defender', details: 'File not found for scanning.' };
|
||
}
|
||
const exe = await findDefenderMpCmdRun();
|
||
if (!exe) {
|
||
return { status: 'unavailable', engine: 'Windows Defender', details: 'Microsoft Defender command-line scanner not found.' };
|
||
}
|
||
|
||
return await new Promise((resolve) => {
|
||
const args = ['-Scan', '-ScanType', '3', '-File', filePath];
|
||
let stdout = '';
|
||
let stderr = '';
|
||
const child = spawn(exe, args, { windowsHide: true });
|
||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
||
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
||
child.on('error', (err) => {
|
||
resolve({ status: 'error', engine: 'Windows Defender', details: 'Failed to run scanner: ' + String(err && err.message || err) });
|
||
});
|
||
child.on('close', (code) => {
|
||
const out = (stdout + '\n' + stderr).toLowerCase();
|
||
// Heuristics: exit code 2 indicates threats found; also parse output
|
||
const infected = code === 2 || /threat|infected|malware|found\s*:\s*[1-9]/i.test(stdout) || /threat|infected|malware/.test(stderr);
|
||
if (infected) {
|
||
resolve({ status: 'infected', engine: 'Windows Defender', details: stdout || stderr, exitCode: code });
|
||
} else if (code === 0 || /no threats/.test(out) || /found\s*:\s*0/.test(out)) {
|
||
resolve({ status: 'clean', engine: 'Windows Defender', details: stdout || 'No threats found.', exitCode: code });
|
||
} else {
|
||
resolve({ status: 'error', engine: 'Windows Defender', details: (stdout || stderr || 'Unknown scan result') + ` (code ${code})`, exitCode: code });
|
||
}
|
||
});
|
||
});
|
||
}
|