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 @@