From 52a4d877a13d8edec01552dae43692919d0dd83c Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Fri, 2 Jan 2026 17:36:43 +1300 Subject: [PATCH] Enable portable user data on all platforms Portable mode now works on Windows, macOS, and Linux, storing user data in a 'user-data' folder within the app directory by default. The mode is enabled by default and can be disabled via NEBULA_PORTABLE=0. All file operations for history and search data now respect portable mode, using secure file writes where appropriate. Updated .gitignore and package.json to exclude portable data from versioning and packaging. Also fixed Bing icon filename case in home.html. --- .gitignore | 4 + main.js | 43 +++++++-- package.json | 6 ++ portable-data.js | 211 ++++++++++++++++++++++++++++++++------------- renderer/home.html | 2 +- 5 files changed, 198 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index 7e27ca2..e6b8a5a 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,10 @@ typings/ site-history.json bookmarks.json bookmarks.backup.json +search-history.json + +# Portable user data folder +user-data/ # AppImage / SteamOS squashfs-root/ diff --git a/main.js b/main.js index 6600577..0247857 100644 --- a/main.js +++ b/main.js @@ -158,12 +158,13 @@ const gpuConfig = new GPUConfig(); const pluginManager = new PluginManager(); // Initialize portable data paths BEFORE app.ready (must be done early) -// This only affects Linux when NEBULA_PORTABLE=1 is set +// 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 on Linux when in portable mode, otherwise uses __dirname + * 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 */ @@ -177,7 +178,7 @@ function getDataFilePath(filename) { /** * Get the directory path for user data files - * Uses portable path on Linux when in portable mode, otherwise uses __dirname + * Uses portable path when in portable mode, otherwise uses __dirname * @returns {string} The directory path */ function getDataDirPath() { @@ -968,7 +969,11 @@ ipcMain.handle('save-site-history', async (event, history) => { ipcMain.handle('clear-site-history', async () => { const filePath = getDataFilePath('site-history.json'); try { - await fs.promises.writeFile(filePath, JSON.stringify([], null, 2)); + 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; @@ -1110,8 +1115,20 @@ ipcMain.handle('clear-browser-data', async () => { // Also reset on-disk history JSON files managed by the app const siteHistoryPath = getDataFilePath('site-history.json'); const searchHistoryPath = getDataFilePath('search-history.json'); - try { await fs.promises.writeFile(siteHistoryPath, JSON.stringify([], null, 2)); } catch {} - try { await fs.promises.writeFile(searchHistoryPath, JSON.stringify([], null, 2)); } catch {} + 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) { @@ -1124,7 +1141,11 @@ ipcMain.handle('clear-browser-data', async () => { ipcMain.handle('clear-search-history', async () => { const filePath = getDataFilePath('search-history.json'); try { - await fs.promises.writeFile(filePath, JSON.stringify([], null, 2)); + 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; @@ -1183,7 +1204,7 @@ ipcMain.handle('open-tab-in-new-window', (event, url) => { }); ipcMain.handle('save-site-history-entry', async (event, url) => { - const filePath = path.join(__dirname, 'site-history.json'); + const filePath = getDataFilePath('site-history.json'); try { let data = []; try { @@ -1195,7 +1216,11 @@ ipcMain.handle('save-site-history-entry', async (event, url) => { // Add to beginning and clamp size data.unshift(url); if (data.length > 100) data = data.slice(0, 100); - await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2)); + 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); diff --git a/package.json b/package.json index 962babb..f415f41 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,12 @@ "repo": "NebulaBrowser" } ], + "files": [ + "**/*", + "!user-data/**", + "!*.backup.json" + ], + "extraResources": [], "mac": { "category": "public.app-category.productivity", "icon": "assets/images/Logos/Nebula-Favicon.icns" diff --git a/portable-data.js b/portable-data.js index b0cf6c3..2bfa7ac 100644 --- a/portable-data.js +++ b/portable-data.js @@ -1,13 +1,15 @@ /** * Portable Data Manager for Nebula Browser * - * Handles portable user data storage on Linux when running from extracted AppImage - * or portable installations. Does not affect Windows or macOS behavior. + * Handles portable user data storage for all platforms (Windows, macOS, Linux). + * Data is stored in a 'user-data' folder within the application directory, + * keeping all user data local to the compiled project. * * Security considerations: - * - Data is stored with restricted permissions (0700 for directories, 0600 for files) + * - Data is stored with restricted permissions (0700 for directories, 0600 for files on Unix) * - Path validation prevents directory traversal attacks - * - Only enabled when explicitly set via NEBULA_PORTABLE environment variable + * - Portable mode is enabled by default on all platforms + * - Can be disabled via NEBULA_PORTABLE=0 environment variable */ const { app } = require('electron'); @@ -23,37 +25,67 @@ class PortableDataManager { } /** - * Check if we're running in portable mode on Linux - * Portable mode is determined by: - * 1. NEBULA_PORTABLE environment variable is set and truthy - * 2. NEBULA_PORTABLE_PATH environment variable provides the data path - * 3. Platform must be Linux (does not affect Windows or macOS) + * Get the application's root directory + * Works for both development and packaged builds + */ + _getAppRootDir() { + // In packaged app, process.resourcesPath points to resources folder + // We want the parent directory (the app folder itself) + if (app.isPackaged) { + // For packaged apps: + // - Windows: path\to\app\resources -> path\to\app + // - macOS: path/to/App.app/Contents/Resources -> path/to/App.app/Contents + // - Linux: path/to/app/resources -> path/to/app + const resourcesPath = process.resourcesPath; + + if (process.platform === 'darwin') { + // On macOS, go up two levels from Resources to get to App.app parent folder + // But we want to store data inside the .app bundle's Contents folder for portability + return path.dirname(resourcesPath); // Contents folder + } else { + // Windows and Linux: go up one level from resources + return path.dirname(resourcesPath); + } + } else { + // Development mode: use __dirname (the directory containing main.js) + return __dirname; + } + } + + /** + * Check if we're running in portable mode + * Portable mode is enabled by default on all platforms. + * + * Can be disabled by setting NEBULA_PORTABLE=0 or NEBULA_PORTABLE=false + * Can specify custom path via NEBULA_PORTABLE_PATH environment variable */ isPortableMode() { if (this._isPortable !== null) { return this._isPortable; } - // Only portable mode on Linux - if (process.platform !== 'linux') { - this._isPortable = false; - return false; + // Check if NEBULA_PORTABLE is explicitly set to disable + const portableEnv = process.env.NEBULA_PORTABLE; + if (portableEnv !== undefined) { + const isDisabled = portableEnv === '0' || + portableEnv.toLowerCase() === 'false' || + portableEnv.toLowerCase() === 'no'; + + if (isDisabled) { + this._isPortable = false; + console.log('[Portable] Portable mode disabled via NEBULA_PORTABLE environment variable'); + return false; + } } - // Check if NEBULA_PORTABLE is set and truthy - const portableEnv = process.env.NEBULA_PORTABLE; - const isTruthy = portableEnv && - (portableEnv === '1' || - portableEnv.toLowerCase() === 'true' || - portableEnv.toLowerCase() === 'yes'); - - this._isPortable = isTruthy; + // Portable mode is enabled by default on all platforms + this._isPortable = true; return this._isPortable; } /** * Get the portable data directory path - * Uses NEBULA_PORTABLE_PATH if set, otherwise returns null + * Uses NEBULA_PORTABLE_PATH if set, otherwise creates 'user-data' folder in app directory */ getPortableDataPath() { if (this._portableDataPath !== null) { @@ -65,21 +97,29 @@ class PortableDataManager { return ''; } - const portablePath = process.env.NEBULA_PORTABLE_PATH; - if (!portablePath) { - console.warn('[Portable] NEBULA_PORTABLE is set but NEBULA_PORTABLE_PATH is missing'); - this._portableDataPath = ''; - return ''; + // First, check if custom path is provided via environment variable + const customPath = process.env.NEBULA_PORTABLE_PATH; + if (customPath) { + const resolvedPath = path.resolve(customPath); + if (this._isPathSafe(resolvedPath)) { + this._portableDataPath = resolvedPath; + console.log(`[Portable] Using custom portable path: ${resolvedPath}`); + return this._portableDataPath; + } else { + console.warn('[Portable] Custom path is unsafe, using default location'); + } } - // Validate and resolve the path - const resolvedPath = path.resolve(portablePath); + // Default: create 'user-data' folder in the application directory + const appRoot = this._getAppRootDir(); + const dataPath = path.join(appRoot, 'user-data'); - // Security: ensure path doesn't contain suspicious patterns - if (this._isPathSafe(resolvedPath)) { - this._portableDataPath = resolvedPath; + // Validate the path + if (this._isPathSafe(dataPath)) { + this._portableDataPath = dataPath; + console.log(`[Portable] Using portable data path: ${dataPath}`); } else { - console.error('[Portable] Unsafe path detected, falling back to default'); + console.error('[Portable] Default path is unsafe, falling back to system default'); this._portableDataPath = ''; } @@ -113,7 +153,8 @@ class PortableDataManager { this._ensureSecureDirectory(dataPath); // Create subdirectories for organized storage - const subdirs = ['Cache', 'Cookies', 'Local Storage', 'Session Storage', 'IndexedDB']; + // Note: Don't create 'Cache', 'Cookies', 'Network' - Electron manages these internally + const subdirs = ['Local Storage', 'Session Storage', 'IndexedDB']; for (const subdir of subdirs) { this._ensureSecureDirectory(path.join(dataPath, subdir)); } @@ -163,28 +204,40 @@ class PortableDataManager { /** * Ensure a directory exists with secure permissions + * On Unix systems (macOS, Linux), applies restricted permissions (0700) + * On Windows, creates directory with default permissions (ACLs handle security) */ _ensureSecureDirectory(dirPath) { if (!fs.existsSync(dirPath)) { - // Create with restricted permissions (owner only: rwx------) - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + if (process.platform === 'win32') { + // Windows: create directory with default permissions + // Windows ACLs handle security through inheritance + fs.mkdirSync(dirPath, { recursive: true }); + } else { + // Unix (macOS, Linux): create with restricted permissions (owner only: rwx------) + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + } console.log(`[Portable] Created secure directory: ${dirPath}`); } else { - // Verify and fix permissions on existing directory - try { - const stats = fs.statSync(dirPath); - if (stats.isDirectory()) { - // Set secure permissions - fs.chmodSync(dirPath, 0o700); + // Verify and fix permissions on existing directory (Unix only) + if (process.platform !== 'win32') { + try { + const stats = fs.statSync(dirPath); + if (stats.isDirectory()) { + // Set secure permissions + fs.chmodSync(dirPath, 0o700); + } + } catch (err) { + console.warn(`[Portable] Could not verify permissions for ${dirPath}:`, err.message); } - } catch (err) { - console.warn(`[Portable] Could not verify permissions for ${dirPath}:`, err.message); } } } /** * Write a file with secure permissions + * On Unix systems, applies restricted permissions (0600) + * On Windows, writes with default permissions */ writeSecureFile(filePath, data) { // Ensure parent directory exists with secure permissions @@ -192,41 +245,81 @@ class PortableDataManager { this._ensureSecureDirectory(dir); // Write file - fs.writeFileSync(filePath, data, { mode: 0o600 }); + if (process.platform === 'win32') { + fs.writeFileSync(filePath, data); + } else { + fs.writeFileSync(filePath, data, { mode: 0o600 }); + } } /** * Async version of secure file write + * On Unix systems, applies restricted permissions (0600) + * On Windows, writes with default permissions */ async writeSecureFileAsync(filePath, data) { // Ensure parent directory exists with secure permissions const dir = path.dirname(filePath); this._ensureSecureDirectory(dir); - // Write file with restricted permissions (owner only: rw-------) - await fs.promises.writeFile(filePath, data, { mode: 0o600 }); + // Write file with restricted permissions (owner only: rw------- on Unix) + if (process.platform === 'win32') { + await fs.promises.writeFile(filePath, data); + } else { + await fs.promises.writeFile(filePath, data, { mode: 0o600 }); + } } /** * Validate path safety (prevent directory traversal) + * Works across Windows, macOS, and Linux */ _isPathSafe(testPath) { // Resolve to absolute path const resolved = path.resolve(testPath); - // Check for suspicious patterns - const dangerous = ['..', '~root', '/etc', '/var/run', '/proc', '/sys', '/dev']; - for (const pattern of dangerous) { - if (resolved.includes(pattern)) { - return false; - } + // Check for directory traversal patterns + if (resolved.includes('..')) { + return false; } - // Ensure it's not trying to write to system directories - const systemPaths = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/boot', '/lib', '/lib64']; - for (const sysPath of systemPaths) { - if (resolved.startsWith(sysPath)) { - return false; + // Platform-specific system path checks + if (process.platform === 'win32') { + // Windows: block system directories + const dangerousWin = [ + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\ProgramData' + ]; + const resolvedLower = resolved.toLowerCase(); + for (const pattern of dangerousWin) { + if (resolvedLower.startsWith(pattern.toLowerCase())) { + return false; + } + } + } else if (process.platform === 'darwin') { + // macOS: block system directories + const dangerousMac = ['/System', '/Library', '/usr', '/bin', '/sbin', '/etc', '/var']; + for (const pattern of dangerousMac) { + if (resolved.startsWith(pattern) && !resolved.includes('.app')) { + return false; + } + } + } else { + // Linux: block system directories + const dangerousLinux = ['~root', '/etc', '/var/run', '/proc', '/sys', '/dev']; + for (const pattern of dangerousLinux) { + if (resolved.includes(pattern)) { + return false; + } + } + + const systemPaths = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/boot', '/lib', '/lib64']; + for (const sysPath of systemPaths) { + if (resolved.startsWith(sysPath)) { + return false; + } } } @@ -257,8 +350,10 @@ class PortableDataManager { return { isPortable: this.isPortableMode(), portablePath: this.getPortableDataPath(), + appRootDir: this._getAppRootDir(), initialized: this._initialized, platform: process.platform, + isPackaged: app.isPackaged, envPortable: process.env.NEBULA_PORTABLE, envPath: process.env.NEBULA_PORTABLE_PATH }; diff --git a/renderer/home.html b/renderer/home.html index 0104eaf..5e7e56f 100644 --- a/renderer/home.html +++ b/renderer/home.html @@ -32,7 +32,7 @@ Google
- Bing + Bing
DuckDuckGo