Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src-electron/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Centralized Configuration Module
*
* This module provides a single source of truth for all configuration values.
* It reads from package.json and can apply stage-wise transforms as needed.
*
* Usage:
* const { stage, trustedElectronDomains, productName } = require('./config');
*/

const packageJson = require('./package.json');

// Core package.json values
const name = packageJson.name;
const identifier = packageJson.identifier;
const stage = packageJson.stage;
const version = packageJson.version;
const productName = packageJson.productName;
const description = packageJson.description;

// Security configuration
const trustedElectronDomains = packageJson.trustedElectronDomains || [];

/**
* Initialize configuration (call once at app startup if needed).
* Currently a no-op but can be extended for async config loading,
* environment variable overrides, or stage-wise transforms.
*/
function initConfig() {
// Future: Add stage-wise transforms, env overrides, etc.
// Example:
// if (stage === 'prod') {
// // Apply production-specific config
// }
}

module.exports = {
// Package info
name,
identifier,
stage,
version,
productName,
description,

// Security
trustedElectronDomains,

// Initialization
initConfig
};
91 changes: 91 additions & 0 deletions src-electron/ipc-security.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* IPC Security - Trusted Domain Validation
*
* This module implements security measures to ensure Electron APIs are only
* accessible from trusted origins. Trust is evaluated at window load/navigation
* time (not on every IPC call) for optimal performance.
*
* Trust rules:
* - Dev stage: trustedElectronDomains + all localhost URLs
* - Other stages (staging/prod): only trustedElectronDomains
*/

const { stage, trustedElectronDomains } = require('./config');

// Track trusted webContents IDs (Set for O(1) lookup)
const _trustedWebContents = new Set();

/**
* Check if a URL is trusted based on stage configuration.
* - Dev stage: trustedElectronDomains + all localhost URLs
* - Other stages: only trustedElectronDomains
*/
function isTrustedOrigin(url) {
if (!url) return false;

// Check against trustedElectronDomains
for (const domain of trustedElectronDomains) {
if (url.startsWith(domain)) {
return true;
}
}

// In dev stage, also allow localhost URLs
if (stage === 'dev') {
try {
const parsed = new URL(url);
if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
return true;
}
} catch {
return false;
}
}

return false;
}

/**
* Mark a webContents as trusted/untrusted based on its current URL.
* Call this when window loads or navigates.
*/
function updateTrustStatus(webContents) {
const url = webContents.getURL();
if (isTrustedOrigin(url)) {
_trustedWebContents.add(webContents.id);
} else {
_trustedWebContents.delete(webContents.id);
}
}

/**
* Remove trust tracking when webContents is destroyed.
*/
function cleanupTrust(webContentsId) {
_trustedWebContents.delete(webContentsId);
}

/**
* Fast check if webContents is trusted (O(1) lookup).
*/
function _isWebContentsTrusted(webContentsId) {
return _trustedWebContents.has(webContentsId);
}

/**
* Assert that IPC event comes from trusted webContents.
* Throws error if not trusted.
*/
function assertTrusted(event) {
if (!_isWebContentsTrusted(event.sender.id)) {
const url = event.senderFrame?.url || event.sender.getURL() || 'unknown';
throw new Error(`Blocked IPC from untrusted origin: ${url}`);
}
}

module.exports = {
isTrustedOrigin,
updateTrustStatus,
cleanupTrust,
assertTrusted
};
54 changes: 48 additions & 6 deletions src-electron/main-app-ipc.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
/**
* IPC handlers for electronAppAPI
* Preload location: contextBridge.exposeInMainWorld('electronAppAPI', { ... })
*
* NOTE: This file is copied from phoenix-fs library. Do not modify without
* updating the source library. Only add new Phoenix-specific handlers to main-window-ipc.js.
*/

const { app, ipcMain } = require('electron');
const { spawn } = require('child_process');
const readline = require('readline');
const { productName } = require('./package.json');
const path = require('path');
const { productName } = require('./config');
const { assertTrusted } = require('./ipc-security');

let processInstanceId = 0;

// Path to main.js - used to filter it out from CLI args in dev mode
const mainScriptPath = path.resolve(__dirname, 'main.js');

/**
* Filter CLI args to remove internal Electron arguments.
* In dev mode, process.argv includes: [electron, main.js, ...userArgs]
* In production, it includes: [app, ...userArgs]
* This function filters out the main.js entry point in dev mode.
*/
function filterCliArgs(args) {
if (!args || args.length === 0) {
return args;
}

const normalizedMainScript = mainScriptPath.toLowerCase();

return args.filter(arg => {
// Resolve to handle both absolute and relative paths
const resolvedArg = path.resolve(arg).toLowerCase();
return resolvedArg !== normalizedMainScript;
});
}
// Map of instanceId -> { process, terminated }
const spawnedProcesses = new Map();

Expand Down Expand Up @@ -41,6 +74,7 @@ function registerAppIpcHandlers() {
// Spawn a child process and forward stdio to the calling renderer.
// Returns an instanceId so the renderer can target the correct process.
ipcMain.handle('spawn-process', async (event, command, args) => {
assertTrusted(event);
const instanceId = ++processInstanceId;
const sender = event.sender;
console.log(`Spawning: ${command} ${args.join(' ')} (instance ${instanceId})`);
Expand Down Expand Up @@ -90,39 +124,47 @@ function registerAppIpcHandlers() {

// Write data to a specific spawned process stdin
ipcMain.handle('write-to-process', (event, instanceId, data) => {
assertTrusted(event);
const instance = spawnedProcesses.get(instanceId);
if (instance && !instance.terminated) {
instance.process.stdin.write(data);
}
});

ipcMain.handle('quit-app', (event, exitCode) => {
assertTrusted(event);
console.log('Quit requested with exit code:', exitCode);
// This will be handled by the main module's gracefulShutdown
app.emit('quit-requested', exitCode);
});

ipcMain.on('console-log', (event, message) => {
assertTrusted(event);
console.log('Renderer:', message);
});

// CLI args (mirrors Tauri's cli.getMatches for --quit-when-done / -q)
ipcMain.handle('get-cli-args', () => {
return process.argv;
// Filter out internal Electron args (main.js in dev mode)
ipcMain.handle('get-cli-args', (event) => {
assertTrusted(event);
return filterCliArgs(process.argv);
});

// App path (repo root when running from source)
ipcMain.handle('get-app-path', () => {
ipcMain.handle('get-app-path', (event) => {
assertTrusted(event);
return app.getAppPath();
});

// App name from package.json
ipcMain.handle('get-app-name', () => {
ipcMain.handle('get-app-name', (event) => {
assertTrusted(event);
return productName;
});
}

module.exports = {
registerAppIpcHandlers,
terminateAllProcesses
terminateAllProcesses,
filterCliArgs
};
62 changes: 50 additions & 12 deletions src-electron/main-fs-ipc.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
/**
* IPC handlers for electronFSAPI
* Preload location: contextBridge.exposeInMainWorld('electronFSAPI', { ... })
*
* NOTE: This file is copied from phoenix-fs library. Do not modify without
* updating the source library. Only add new Phoenix-specific handlers to main-window-ipc.js.
*/

const { ipcMain, dialog, BrowserWindow } = require('electron');
const path = require('path');
const fsp = require('fs/promises');
const os = require('os');
const { identifier: APP_IDENTIFIER } = require('./package.json');
const { identifier: APP_IDENTIFIER } = require('./config');
const { assertTrusted } = require('./ipc-security');

// Electron IPC only preserves Error.message when errors cross the IPC boundary (see
// https://github.com/electron/electron/issues/24427). To preserve error.code for FS
Expand Down Expand Up @@ -39,25 +48,32 @@ function getAppDataDir() {

function registerFsIpcHandlers() {
// Directory APIs
ipcMain.handle('get-documents-dir', () => {
ipcMain.handle('get-documents-dir', (event) => {
assertTrusted(event);
// Match Tauri's documentDir which ends with a trailing slash
return path.join(os.homedir(), 'Documents') + path.sep;
});

ipcMain.handle('get-home-dir', () => {
ipcMain.handle('get-home-dir', (event) => {
assertTrusted(event);
// Match Tauri's homeDir which ends with a trailing slash
const home = os.homedir();
return home.endsWith(path.sep) ? home : home + path.sep;
});

ipcMain.handle('get-temp-dir', () => {
ipcMain.handle('get-temp-dir', (event) => {
assertTrusted(event);
return os.tmpdir();
});

ipcMain.handle('get-app-data-dir', () => getAppDataDir());
ipcMain.handle('get-app-data-dir', (event) => {
assertTrusted(event);
return getAppDataDir();
});

// Get Windows drive letters (returns null on non-Windows platforms)
ipcMain.handle('get-windows-drives', async () => {
ipcMain.handle('get-windows-drives', async (event) => {
assertTrusted(event);
if (process.platform !== 'win32') {
return null;
}
Expand All @@ -78,26 +94,30 @@ function registerFsIpcHandlers() {

// Dialogs
ipcMain.handle('show-open-dialog', async (event, options) => {
assertTrusted(event);
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win, options);
return result.filePaths;
});

ipcMain.handle('show-save-dialog', async (event, options) => {
assertTrusted(event);
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showSaveDialog(win, options);
return result.filePath;
});

// FS operations
ipcMain.handle('fs-readdir', async (event, dirPath) => {
assertTrusted(event);
return fsResult(
fsp.readdir(dirPath, { withFileTypes: true })
.then(entries => entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() })))
);
});

ipcMain.handle('fs-stat', async (event, filePath) => {
assertTrusted(event);
return fsResult(
fsp.stat(filePath).then(stats => ({
isFile: stats.isFile(),
Expand All @@ -114,12 +134,30 @@ function registerFsIpcHandlers() {
);
});

ipcMain.handle('fs-mkdir', (event, dirPath, options) => fsResult(fsp.mkdir(dirPath, options)));
ipcMain.handle('fs-unlink', (event, filePath) => fsResult(fsp.unlink(filePath)));
ipcMain.handle('fs-rmdir', (event, dirPath, options) => fsResult(fsp.rm(dirPath, options)));
ipcMain.handle('fs-rename', (event, oldPath, newPath) => fsResult(fsp.rename(oldPath, newPath)));
ipcMain.handle('fs-read-file', (event, filePath) => fsResult(fsp.readFile(filePath)));
ipcMain.handle('fs-write-file', (event, filePath, data) => fsResult(fsp.writeFile(filePath, Buffer.from(data))));
ipcMain.handle('fs-mkdir', (event, dirPath, options) => {
assertTrusted(event);
return fsResult(fsp.mkdir(dirPath, options));
});
ipcMain.handle('fs-unlink', (event, filePath) => {
assertTrusted(event);
return fsResult(fsp.unlink(filePath));
});
ipcMain.handle('fs-rmdir', (event, dirPath, options) => {
assertTrusted(event);
return fsResult(fsp.rm(dirPath, options));
});
ipcMain.handle('fs-rename', (event, oldPath, newPath) => {
assertTrusted(event);
return fsResult(fsp.rename(oldPath, newPath));
});
ipcMain.handle('fs-read-file', (event, filePath) => {
assertTrusted(event);
return fsResult(fsp.readFile(filePath));
});
ipcMain.handle('fs-write-file', (event, filePath, data) => {
assertTrusted(event);
return fsResult(fsp.writeFile(filePath, Buffer.from(data)));
});
}

module.exports = {
Expand Down
Loading
Loading