diff --git a/package-lock.json b/package-lock.json index 485f754..99aa4e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "phoenix-code-ide", - "version": "5.0.5", + "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "phoenix-code-ide", - "version": "5.0.5", + "version": "5.1.0", "hasInstallScript": true, "devDependencies": { "@tauri-apps/cli": "1.6.3", diff --git a/src-build/serveForPlatform.js b/src-build/serveForPlatform.js index a1e1e21..dc8db30 100644 --- a/src-build/serveForPlatform.js +++ b/src-build/serveForPlatform.js @@ -1,6 +1,7 @@ import {getPlatformDetails} from "./utils.js"; import {execa} from "execa"; import chalk from "chalk"; +import {resolve} from "path"; const {platform} = getPlatformDetails(); @@ -44,14 +45,18 @@ console.log(`Platform: ${platform}, target: ${target}`); console.log('\nEnsure to start phoenix server at http://localhost:8000 for development.'); console.log('Follow https://github.com/phcode-dev/phoenix#running-phoenix for instructions.\n'); -console.log('Setting up src-node...'); -await execa("npm", ["run", "_make_src-node"], {stdio: "inherit"}); - // Run platform-specific command if (target === "tauri") { + console.log('Setting up src-node...'); + await execa("npm", ["run", "_make_src-node"], {stdio: "inherit"}); + console.log('Starting Tauri dev server...'); await execa("npx", ["tauri", "dev"], {stdio: "inherit"}); } else { + const srcNodePath = resolve("../phoenix/src-node"); + console.log(`Running "npm install" in ${srcNodePath}`); + await execa("npm", ["install"], {cwd: srcNodePath, stdio: "inherit"}); + console.log('Starting Electron...'); await execa("./src-electron/node_modules/.bin/electron", ["src-electron/main.js"], {stdio: "inherit"}); } diff --git a/src-electron/config.js b/src-electron/config.js new file mode 100644 index 0000000..4d3047f --- /dev/null +++ b/src-electron/config.js @@ -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 +}; diff --git a/src-electron/ipc-security.js b/src-electron/ipc-security.js new file mode 100644 index 0000000..6d75afd --- /dev/null +++ b/src-electron/ipc-security.js @@ -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 +}; diff --git a/src-electron/main-app-ipc.js b/src-electron/main-app-ipc.js index f85e547..e890e2a 100644 --- a/src-electron/main-app-ipc.js +++ b/src-electron/main-app-ipc.js @@ -1,16 +1,45 @@ +/** + * 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(); -// In-memory key-value store shared across all windows (mirrors Tauri's put_item/get_all_items) -// Used for multi-window storage synchronization -const sharedStorageMap = new Map(); - function waitForTrue(fn, timeout) { return new Promise((resolve) => { const startTime = Date.now(); @@ -45,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})`); @@ -82,7 +112,11 @@ function registerAppIpcHandlers() { }); childProcess.on('error', (err) => { + instance.terminated = true; console.error(`Failed to start process (instance ${instanceId}):`, err); + if (!sender.isDestroyed()) { + sender.send('process-error', instanceId, { message: err.message, code: err.code }); + } }); return instanceId; @@ -90,6 +124,7 @@ 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); @@ -97,46 +132,39 @@ function registerAppIpcHandlers() { }); 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; }); - - // Set zoom factor on the webview (mirrors Tauri's zoom_window) - ipcMain.handle('zoom-window', (event, scaleFactor) => { - event.sender.setZoomFactor(scaleFactor); - }); - - // In-memory storage for multi-window sync (mirrors Tauri's put_item/get_all_items) - ipcMain.handle('put-item', (event, key, value) => { - sharedStorageMap.set(key, value); - }); - - ipcMain.handle('get-all-items', () => { - return Object.fromEntries(sharedStorageMap); - }); } module.exports = { registerAppIpcHandlers, - terminateAllProcesses + terminateAllProcesses, + filterCliArgs }; diff --git a/src-electron/main-cred-ipc.js b/src-electron/main-cred-ipc.js new file mode 100644 index 0000000..cbe613c --- /dev/null +++ b/src-electron/main-cred-ipc.js @@ -0,0 +1,121 @@ +const { ipcMain } = require('electron'); +const crypto = require('crypto'); +const { assertTrusted } = require('./ipc-security'); + +// Per-window AES trust map (mirrors Tauri's WindowAesTrust) +// Uses webContents.id which persists across page reloads but changes when window is destroyed +const windowTrustMap = new Map(); // webContentsId -> { key, iv } + +// Keytar for system keychain (libsecret on Linux, Keychain on macOS, Credential Vault on Windows) +let keytar = null; +try { + keytar = require('keytar'); +} catch (e) { + console.warn('keytar not available, credential storage will not work'); +} + +const PHOENIX_CRED_PREFIX = 'phcode_electron_'; + +function registerCredIpcHandlers() { + // Trust window AES key - can only be called once per window + ipcMain.handle('trust-window-aes-key', (event, key, iv) => { + assertTrusted(event); + const webContentsId = event.sender.id; + + if (windowTrustMap.has(webContentsId)) { + throw new Error('Trust has already been established for this window.'); + } + + // Validate key (64 hex chars = 32 bytes for AES-256) + if (!/^[0-9a-fA-F]{64}$/.test(key)) { + throw new Error('Invalid AES key. Must be 64 hex characters.'); + } + // Validate IV (24 hex chars = 12 bytes for AES-GCM) + if (!/^[0-9a-fA-F]{24}$/.test(iv)) { + throw new Error('Invalid IV. Must be 24 hex characters.'); + } + + windowTrustMap.set(webContentsId, { key, iv }); + // Lazy require to avoid circular dependency + const { getWindowLabel } = require('./main-window-ipc'); + console.log(`AES trust established for window: ${getWindowLabel(webContentsId)} (webContentsId: ${webContentsId})`); + }); + + // Remove trust - requires matching key/iv + ipcMain.handle('remove-trust-window-aes-key', (event, key, iv) => { + assertTrusted(event); + const webContentsId = event.sender.id; + const stored = windowTrustMap.get(webContentsId); + + if (!stored) { + throw new Error('No trust established for this window.'); + } + if (stored.key !== key || stored.iv !== iv) { + throw new Error('Provided key and IV do not match.'); + } + + windowTrustMap.delete(webContentsId); + const { getWindowLabel } = require('./main-window-ipc'); + console.log(`AES trust removed for window: ${getWindowLabel(webContentsId)} (webContentsId: ${webContentsId})`); + }); + + // Store credential in system keychain + ipcMain.handle('store-credential', async (event, scopeName, secretVal) => { + assertTrusted(event); + if (!keytar) { + throw new Error('keytar module not available.'); + } + const service = PHOENIX_CRED_PREFIX + scopeName; + await keytar.setPassword(service, process.env.USER || 'user', secretVal); + }); + + // Get credential (encrypted with window's AES key) + ipcMain.handle('get-credential', async (event, scopeName) => { + assertTrusted(event); + if (!keytar) { + throw new Error('keytar module not available.'); + } + + const webContentsId = event.sender.id; + const trustData = windowTrustMap.get(webContentsId); + if (!trustData) { + throw new Error('Trust needs to be established first.'); + } + + const service = PHOENIX_CRED_PREFIX + scopeName; + const credential = await keytar.getPassword(service, process.env.USER || 'user'); + if (!credential) { + return null; + } + + // Encrypt with AES-256-GCM (same as Tauri) + const keyBytes = Buffer.from(trustData.key, 'hex'); + const ivBytes = Buffer.from(trustData.iv, 'hex'); + const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes, ivBytes); + let encrypted = cipher.update(credential, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag().toString('hex'); + + return encrypted + authTag; // Return ciphertext + authTag as hex string + }); + + // Delete credential from system keychain + ipcMain.handle('delete-credential', async (event, scopeName) => { + assertTrusted(event); + if (!keytar) { + throw new Error('keytar module not available.'); + } + const service = PHOENIX_CRED_PREFIX + scopeName; + await keytar.deletePassword(service, process.env.USER || 'user'); + }); +} + +// Clean up trust when window closes +function cleanupWindowTrust(webContentsId, windowLabel) { + if (windowTrustMap.has(webContentsId)) { + windowTrustMap.delete(webContentsId); + console.log(`AES trust auto-removed for closed window: ${windowLabel} (webContentsId: ${webContentsId})`); + } +} + +module.exports = { registerCredIpcHandlers, cleanupWindowTrust }; diff --git a/src-electron/main-fs-ipc.js b/src-electron/main-fs-ipc.js index d2eeb6f..39e0861 100644 --- a/src-electron/main-fs-ipc.js +++ b/src-electron/main-fs-ipc.js @@ -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 @@ -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; } @@ -78,12 +94,14 @@ 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; @@ -91,6 +109,7 @@ function registerFsIpcHandlers() { // 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() }))) @@ -98,6 +117,7 @@ function registerFsIpcHandlers() { }); ipcMain.handle('fs-stat', async (event, filePath) => { + assertTrusted(event); return fsResult( fsp.stat(filePath).then(stats => ({ isFile: stats.isFile(), @@ -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 = { diff --git a/src-electron/main-window-ipc.js b/src-electron/main-window-ipc.js new file mode 100644 index 0000000..4acd3fc --- /dev/null +++ b/src-electron/main-window-ipc.js @@ -0,0 +1,316 @@ +const { ipcMain, BrowserWindow, shell, clipboard } = require('electron'); +const path = require('path'); +const { spawn } = require('child_process'); +const { cleanupWindowTrust } = require('./main-cred-ipc'); +const { isTrustedOrigin, updateTrustStatus, cleanupTrust, assertTrusted } = require('./ipc-security'); +const { DEFAULTS, trackWindowState } = require('./window-state'); + +const PHOENIX_WINDOW_PREFIX = 'phcode-'; +const PHOENIX_EXTENSION_WINDOW_PREFIX = 'extn-'; +const MAX_WINDOWS = 30; + +// Window registry: windowLabel -> BrowserWindow +const windowRegistry = new Map(); +// Reverse lookup: webContentsId -> windowLabel +const webContentsToLabel = new Map(); + +function getNextLabel(prefix) { + for (let i = 1; i <= MAX_WINDOWS; i++) { + const label = `${prefix}${i}`; + if (!windowRegistry.has(label)) { + return label; + } + } + throw new Error(`No free window label available for prefix: ${prefix}`); +} + +// Track close handlers per window +const windowCloseHandlers = new Map(); + +function registerWindow(win, label) { + const webContents = win.webContents; + const webContentsId = webContents.id; + windowRegistry.set(label, win); + webContentsToLabel.set(webContentsId, label); + + // Initial trust evaluation + updateTrustStatus(webContents); + + // Re-evaluate trust on navigation + webContents.on('did-navigate', () => { + updateTrustStatus(webContents); + }); + webContents.on('did-navigate-in-page', () => { + updateTrustStatus(webContents); + }); + + win.on('closed', () => { + windowRegistry.delete(label); + webContentsToLabel.delete(webContentsId); + windowCloseHandlers.delete(webContentsId); + // Clean up AES trust for closing window (mirrors Tauri's on_window_event CloseRequested handler) + cleanupWindowTrust(webContentsId, label); + // Clean up security trust + cleanupTrust(webContentsId); + }); +} + +function setupCloseHandler(win) { + win.on('close', (event) => { + const hasHandler = windowCloseHandlers.get(win.webContents.id); + if (hasHandler && !win.forceClose) { + event.preventDefault(); + win.webContents.send('close-requested'); + } + }); +} + +function registerWindowIpcHandlers() { + // Get all window labels (mirrors Tauri's _get_window_labels) + ipcMain.handle('get-window-labels', (event) => { + assertTrusted(event); + return Array.from(windowRegistry.keys()); + }); + + // Get current window's label + ipcMain.handle('get-current-window-label', (event) => { + assertTrusted(event); + return webContentsToLabel.get(event.sender.id) || null; + }); + + // Create new window (mirrors openURLInPhoenixWindow for Electron) + ipcMain.handle('create-phoenix-window', async (event, url, options) => { + assertTrusted(event); + const { windowTitle, fullscreen, resizable, height, minHeight, width, minWidth, isExtension } = options || {}; + + const prefix = isExtension ? PHOENIX_EXTENSION_WINDOW_PREFIX : PHOENIX_WINDOW_PREFIX; + const label = getNextLabel(prefix); + + const webPreferences = { + contextIsolation: true, + nodeIntegration: false, + sandbox: true + }; + + // Only inject preload for Phoenix windows with trusted URLs, not extensions + if (!isExtension && isTrustedOrigin(url)) { + webPreferences.preload = path.join(__dirname, 'preload.js'); + } + + let windowConfig; + if (isExtension) { + // Extensions manage their own sizing + windowConfig = { + width: width || 800, + height: height || 600, + minWidth: minWidth, + minHeight: minHeight + }; + } else { + // Phoenix windows: use defaults and ensure dimensions are at least the minimums + const actualMinWidth = Math.max(minWidth || DEFAULTS.minWidth, DEFAULTS.minWidth); + const actualMinHeight = Math.max(minHeight || DEFAULTS.minHeight, DEFAULTS.minHeight); + windowConfig = { + width: Math.max(width || DEFAULTS.width, actualMinWidth), + height: Math.max(height || DEFAULTS.height, actualMinHeight), + minWidth: actualMinWidth, + minHeight: actualMinHeight + }; + } + + const win = new BrowserWindow({ + ...windowConfig, + fullscreen: fullscreen || false, + resizable: resizable !== false, + title: windowTitle || label, + webPreferences + }); + + // Track window state for Phoenix windows (not extensions) + if (!isExtension) { + trackWindowState(win); + } + + registerWindow(win, label); + await win.loadURL(url); + + return label; + }); + + // Close current window + ipcMain.handle('close-window', async (event) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (win) { + win.close(); + } + }); + + // Focus current window and bring to front + ipcMain.handle('focus-window', (event) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (win) { + if (win.isMinimized()) { + win.restore(); + } + win.moveTop(); + win.show(); + win.focus(); + } + }); + + // Get process ID + ipcMain.handle('get-process-id', (event) => { + assertTrusted(event); + return process.pid; + }); + + // Get platform architecture + ipcMain.handle('get-platform-arch', (event) => { + assertTrusted(event); + return process.arch; + }); + + // Get current working directory + ipcMain.handle('get-cwd', (event) => { + assertTrusted(event); + return process.cwd(); + }); + + // Fullscreen APIs + ipcMain.handle('is-fullscreen', (event) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + return win ? win.isFullScreen() : false; + }); + + ipcMain.handle('set-fullscreen', (event, enable) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (win) { + win.setFullScreen(enable); + } + }); + + // Window title APIs + ipcMain.handle('set-window-title', (event, title) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (win) { + win.setTitle(title); + } + }); + + ipcMain.handle('get-window-title', (event) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + return win ? win.getTitle() : ''; + }); + + // Clipboard APIs + ipcMain.handle('clipboard-read-text', (event) => { + assertTrusted(event); + return clipboard.readText(); + }); + + ipcMain.handle('clipboard-write-text', (event, text) => { + assertTrusted(event); + clipboard.writeText(text); + }); + + // Read file paths from clipboard (platform-specific) + ipcMain.handle('clipboard-read-files', (event) => { + assertTrusted(event); + const formats = clipboard.availableFormats(); + + // Windows: FileNameW format contains file paths + if (process.platform === 'win32' && formats.includes('FileNameW')) { + const buffer = clipboard.readBuffer('FileNameW'); + // FileNameW is null-terminated UTF-16LE string + const paths = buffer.toString('utf16le').split('\0').filter(p => p.length > 0); + return paths; + } + + // macOS: public.file-url format + if (process.platform === 'darwin') { + // Try reading as file URLs + const text = clipboard.read('public.file-url'); + if (text) { + // Convert file:// URLs to paths + const paths = text.split('\n') + .filter(url => url.startsWith('file://')) + .map(url => decodeURIComponent(url.replace('file://', ''))); + if (paths.length > 0) { + return paths; + } + } + } + + // Linux: text/uri-list format + if (process.platform === 'linux' && formats.includes('text/uri-list')) { + const text = clipboard.read('text/uri-list'); + if (text) { + const paths = text.split('\n') + .filter(url => url.startsWith('file://')) + .map(url => decodeURIComponent(url.replace('file://', ''))); + if (paths.length > 0) { + return paths; + } + } + } + + return null; + }); + + // Shell APIs + ipcMain.handle('move-to-trash', async (event, platformPath) => { + assertTrusted(event); + await shell.trashItem(platformPath); + }); + + ipcMain.handle('show-in-folder', (event, platformPath) => { + assertTrusted(event); + shell.showItemInFolder(platformPath); + }); + + ipcMain.handle('open-external', async (event, url) => { + assertTrusted(event); + await shell.openExternal(url); + }); + + // Windows-only: open URL in specific browser (fire and forget) + ipcMain.handle('open-url-in-browser-win', (event, url, browser) => { + assertTrusted(event); + if (process.platform !== 'win32') { + throw new Error('open-url-in-browser-win is only supported on Windows'); + } + spawn('cmd', ['/c', 'start', browser, url], { shell: true, detached: true, stdio: 'ignore' }); + }); + + // Register close handler for current window + ipcMain.handle('register-close-handler', (event) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (win) { + windowCloseHandlers.set(win.webContents.id, true); + setupCloseHandler(win); + } + }); + + // Allow close after handler approves + ipcMain.handle('allow-close', (event) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (win) { + win.forceClose = true; + win.close(); + } + }); +} + +function getWindowLabel(webContentsId) { + return webContentsToLabel.get(webContentsId) || 'unknown'; +} + +module.exports = { registerWindowIpcHandlers, registerWindow, setupCloseHandler, windowRegistry, getWindowLabel }; diff --git a/src-electron/main.js b/src-electron/main.js index 161f573..873a4a5 100644 --- a/src-electron/main.js +++ b/src-electron/main.js @@ -1,15 +1,36 @@ -const { app, BrowserWindow, protocol } = require('electron'); +const { app, BrowserWindow, protocol, Menu, ipcMain } = require('electron'); const path = require('path'); +const fs = require('fs'); -const { registerAppIpcHandlers, terminateAllProcesses } = require('./main-app-ipc'); +const { registerAppIpcHandlers, terminateAllProcesses, filterCliArgs } = require('./main-app-ipc'); const { registerFsIpcHandlers, getAppDataDir } = require('./main-fs-ipc'); +const { registerCredIpcHandlers } = require('./main-cred-ipc'); +const { registerWindowIpcHandlers, registerWindow } = require('./main-window-ipc'); +const { assertTrusted } = require('./ipc-security'); +const { getWindowOptions, trackWindowState, DEFAULTS } = require('./window-state'); -let mainWindow; +// Request single instance lock - only one instance of the app should run at a time +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + // Another instance is running, quit this one immediately + app.quit(); +} + +// In-memory key-value store shared across all windows (mirrors Tauri's put_item/get_all_items) +// Used for multi-window storage synchronization +const sharedStorageMap = new Map(); async function createWindow() { - mainWindow = new BrowserWindow({ - width: 1200, - height: 800, + // Get window options with restored state or defaults + const windowOptions = getWindowOptions(); + const wasMaximized = windowOptions._wasMaximized; + delete windowOptions._wasMaximized; + + const win = new BrowserWindow({ + ...windowOptions, + minWidth: DEFAULTS.minWidth, + minHeight: DEFAULTS.minHeight, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -18,15 +39,23 @@ async function createWindow() { icon: path.join(__dirname, '..', 'src-tauri', 'icons', 'icon.png') }); - // Load the test page from the http-server - mainWindow.loadURL('http://localhost:8000/src/'); + // Track window state for persistence + trackWindowState(win); - // Open DevTools for debugging - mainWindow.webContents.openDevTools(); + // Restore maximized state after window is ready + if (wasMaximized) { + win.maximize(); + } - mainWindow.on('closed', () => { - mainWindow = null; - }); + // Register main window with label 'main' (mirrors Tauri's window labeling) + // Trust cleanup is handled by registerWindow's closed handler + registerWindow(win, 'main'); + + // uncomment line below if you want to open dev tools at app start + // win.webContents.openDevTools(); + + // Load the test page from the http-server + win.loadURL('http://localhost:8000/src/'); } async function gracefulShutdown(exitCode = 0) { @@ -38,13 +67,82 @@ async function gracefulShutdown(exitCode = 0) { // Register all IPC handlers registerAppIpcHandlers(); registerFsIpcHandlers(); +registerCredIpcHandlers(); +registerWindowIpcHandlers(); + +/** + * IPC handlers for electronAPI + * Preload location: contextBridge.exposeInMainWorld('electronAPI', { ... }) + */ + +// Set zoom factor on the webview (mirrors Tauri's zoom_window) +ipcMain.handle('zoom-window', (event, scaleFactor) => { + assertTrusted(event); + event.sender.setZoomFactor(scaleFactor); +}); + +// In-memory storage for multi-window sync (mirrors Tauri's put_item/get_all_items) +ipcMain.handle('put-item', (event, key, value) => { + assertTrusted(event); + sharedStorageMap.set(key, value); +}); + +ipcMain.handle('get-all-items', (event) => { + assertTrusted(event); + return Object.fromEntries(sharedStorageMap); +}); + +// Toggle DevTools +ipcMain.handle('toggle-dev-tools', (event) => { + assertTrusted(event); + event.sender.toggleDevTools(); +}); + +// Get path to phnode binary +ipcMain.handle('get-phnode-path', (event) => { + assertTrusted(event); + const phNodePath = path.resolve(__dirname, 'bin', 'phnode'); + if (!fs.existsSync(phNodePath)) { + throw new Error(`phnode binary does not exist: ${phNodePath}`); + } + return phNodePath; +}); + +// Get path to src-node (for development) +ipcMain.handle('get-src-node-path', (event) => { + assertTrusted(event); + const srcNodePath = path.resolve(__dirname, '..', '..', 'phoenix', 'src-node'); + if (!fs.existsSync(srcNodePath)) { + throw new Error(`src-node path does not exist: ${srcNodePath}`); + } + return srcNodePath; +}); // Handle quit request from renderer app.on('quit-requested', (exitCode) => { gracefulShutdown(exitCode); }); +// Handle second instance attempts - forward args to existing windows +app.on('second-instance', (event, commandLine, workingDirectory) => { + // Forward to all windows via IPC + // Window focusing is handled by the renderer's singleInstanceHandler + // Filter out internal electron args (executable and main.js script) + const filteredArgs = filterCliArgs(commandLine); + BrowserWindow.getAllWindows().forEach(win => { + if (!win.isDestroyed()) { + win.webContents.send('single-instance', { + args: filteredArgs, + cwd: workingDirectory + }); + } + }); +}); + app.whenReady().then(async () => { + // Remove default menu bar + Menu.setApplicationMenu(null); + // Register asset:// protocol for serving local files from appLocalData/assets/ const appDataDir = getAppDataDir(); const assetsDir = path.join(appDataDir, 'assets'); @@ -78,6 +176,10 @@ app.on('window-all-closed', () => { gracefulShutdown(0); }); +// macOS: When dock icon is clicked and no windows are open, create a new window. +// Currently this won't fire because window-all-closed quits the app. If macOS support +// is added and we want apps to stay running with no windows, change window-all-closed +// to not quit on macOS (process.platform !== 'darwin'). app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/src-electron/package-lock.json b/src-electron/package-lock.json index e8f4879..ef18303 100644 --- a/src-electron/package-lock.json +++ b/src-electron/package-lock.json @@ -1,12 +1,15 @@ { - "name": "phoenix-fs-electron-shell", - "version": "1.0.0", + "name": "phoenix-code-electron-shell", + "version": "5.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "phoenix-fs-electron-shell", - "version": "1.0.0", + "name": "phoenix-code-electron-shell", + "version": "5.0.5", + "dependencies": { + "keytar": "^7.9.0" + }, "devDependencies": { "electron": "^40.0.0" } @@ -111,6 +114,37 @@ "@types/node": "*" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -119,6 +153,30 @@ "dev": true, "optional": true }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -155,6 +213,12 @@ "node": ">=8" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -188,7 +252,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -203,7 +266,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "engines": { "node": ">=10" }, @@ -211,6 +273,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -256,6 +327,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -285,7 +365,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "dependencies": { "once": "^1.4.0" } @@ -339,6 +418,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -368,6 +456,12 @@ "pend": "~1.2.0" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -397,6 +491,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -521,6 +621,38 @@ "node": ">=10.19.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -543,6 +675,17 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -583,12 +726,63 @@ "node": ">=4" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "license": "MIT" + }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -615,7 +809,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -635,6 +828,32 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "dev": true }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -648,7 +867,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -666,6 +884,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -702,6 +949,26 @@ "node": ">=8.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -734,6 +1001,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -741,6 +1053,24 @@ "dev": true, "optional": true }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -753,6 +1083,46 @@ "node": ">= 8.0" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -781,11 +1151,16 @@ "node": ">= 4.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/yauzl": { "version": "2.10.0", diff --git a/src-electron/package.json b/src-electron/package.json index 4ecda03..2f0f364 100644 --- a/src-electron/package.json +++ b/src-electron/package.json @@ -1,10 +1,15 @@ { "name": "phoenix-code-electron-shell", - "identifier": "io.phcode.dev-electon-migration", + "identifier": "io.phcode.dev", + "stage": "dev", + "trustedElectronDomains": ["phtauri://localhost/", "https://phcode.dev/"], "version": "5.0.5", "productName": "Phoenix Code Experimental Build", "description": "Phoenix Code Experimental Build", "main": "main.js", + "dependencies": { + "keytar": "^7.9.0" + }, "devDependencies": { "electron": "^40.0.0" } diff --git a/src-electron/preload.js b/src-electron/preload.js index 9f9e29e..c32409c 100644 --- a/src-electron/preload.js +++ b/src-electron/preload.js @@ -1,5 +1,10 @@ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, webUtils } = require('electron'); +/** + * electronAppAPI - Process lifecycle and app info APIs + * NOTE: This API block is copied from phoenix-fs library. Do not modify without + * updating the source library. Only add new Phoenix-specific APIs to electronAPI below. + */ contextBridge.exposeInMainWorld('electronAppAPI', { // App info getAppName: () => ipcRenderer.invoke('get-app-name'), @@ -11,6 +16,7 @@ contextBridge.exposeInMainWorld('electronAppAPI', { onProcessStdout: (callback) => ipcRenderer.on('process-stdout', (_event, instanceId, line) => callback(instanceId, line)), onProcessStderr: (callback) => ipcRenderer.on('process-stderr', (_event, instanceId, line) => callback(instanceId, line)), onProcessClose: (callback) => ipcRenderer.on('process-close', (_event, instanceId, data) => callback(instanceId, data)), + onProcessError: (callback) => ipcRenderer.on('process-error', (_event, instanceId, err) => callback(instanceId, err)), // Quit the app with an exit code (for CI) quitApp: (exitCode) => ipcRenderer.invoke('quit-app', exitCode), @@ -25,7 +31,11 @@ contextBridge.exposeInMainWorld('electronAppAPI', { getCliArgs: () => ipcRenderer.invoke('get-cli-args') }); -// the electronFSAPI is the fn that you need to copy to your election app impl for the fs to work. +/** + * electronFSAPI - File system APIs + * NOTE: This API block is copied from phoenix-fs library. Do not modify without + * updating the source library. Only add new Phoenix-specific APIs to electronAPI below. + */ contextBridge.exposeInMainWorld('electronFSAPI', { // Path utilities path: { @@ -69,5 +79,65 @@ contextBridge.exposeInMainWorld('electronAPI', { // In-memory storage for multi-window sync (mirrors Tauri's put_item/get_all_items) putItem: (key, value) => ipcRenderer.invoke('put-item', key, value), - getAllItems: () => ipcRenderer.invoke('get-all-items') + getAllItems: () => ipcRenderer.invoke('get-all-items'), + + // Toggle DevTools + toggleDevTools: () => ipcRenderer.invoke('toggle-dev-tools'), + + // Path to phnode binary (src-electron/bin/phnode) + getPhNodePath: () => ipcRenderer.invoke('get-phnode-path'), + + // Path to src-node for development (../phoenix/src-node) + // Throws if path does not exist + getSrcNodePath: () => ipcRenderer.invoke('get-src-node-path'), + + // Trust ring / credential APIs + trustWindowAesKey: (key, iv) => ipcRenderer.invoke('trust-window-aes-key', key, iv), + removeTrustWindowAesKey: (key, iv) => ipcRenderer.invoke('remove-trust-window-aes-key', key, iv), + storeCredential: (scopeName, secretVal) => ipcRenderer.invoke('store-credential', scopeName, secretVal), + getCredential: (scopeName) => ipcRenderer.invoke('get-credential', scopeName), + deleteCredential: (scopeName) => ipcRenderer.invoke('delete-credential', scopeName), + + // Window management APIs (mirrors Tauri's window labeling scheme) + getWindowLabels: () => ipcRenderer.invoke('get-window-labels'), + getCurrentWindowLabel: () => ipcRenderer.invoke('get-current-window-label'), + createPhoenixWindow: (url, options) => ipcRenderer.invoke('create-phoenix-window', url, options), + closeWindow: () => ipcRenderer.invoke('close-window'), + quitApp: (exitCode) => ipcRenderer.invoke('quit-app', exitCode), + focusWindow: () => ipcRenderer.invoke('focus-window'), + + // Process and platform info + getProcessId: () => ipcRenderer.invoke('get-process-id'), + getPlatformArch: () => ipcRenderer.invoke('get-platform-arch'), + getCwd: () => ipcRenderer.invoke('get-cwd'), + + // Fullscreen APIs + isFullscreen: () => ipcRenderer.invoke('is-fullscreen'), + setFullscreen: (enable) => ipcRenderer.invoke('set-fullscreen', enable), + + // Window title APIs + setWindowTitle: (title) => ipcRenderer.invoke('set-window-title', title), + getWindowTitle: () => ipcRenderer.invoke('get-window-title'), + + // Clipboard APIs + clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'), + clipboardWriteText: (text) => ipcRenderer.invoke('clipboard-write-text', text), + clipboardReadFiles: () => ipcRenderer.invoke('clipboard-read-files'), + + // Shell APIs + moveToTrash: (platformPath) => ipcRenderer.invoke('move-to-trash', platformPath), + showInFolder: (platformPath) => ipcRenderer.invoke('show-in-folder', platformPath), + openExternal: (url) => ipcRenderer.invoke('open-external', url), + openUrlInBrowserWin: (url, browser) => ipcRenderer.invoke('open-url-in-browser-win', url, browser), + + // Close requested handler + onCloseRequested: (callback) => ipcRenderer.on('close-requested', () => callback()), + registerCloseHandler: () => ipcRenderer.invoke('register-close-handler'), + allowClose: () => ipcRenderer.invoke('allow-close'), + + // Single instance event listener (mirrors Tauri's single-instance event) + onSingleInstance: (callback) => ipcRenderer.on('single-instance', (_event, payload) => callback(payload)), + + // Drag and drop: get native file path from a dropped File object + getPathForFile: (file) => webUtils.getPathForFile(file) }); diff --git a/src-electron/window-state.js b/src-electron/window-state.js new file mode 100644 index 0000000..5fdc8ec --- /dev/null +++ b/src-electron/window-state.js @@ -0,0 +1,167 @@ +/** + * Window State Manager + * + * Persists and restores window position, size, and maximized state. + * Handles multi-monitor setups and gracefully handles disconnected monitors. + */ + +const { screen } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { getAppDataDir } = require('./main-fs-ipc'); + +const STATE_FILE = 'window-state.json'; + +// Default window dimensions +const DEFAULTS = { + width: 1366, + height: 900, + minWidth: 800, + minHeight: 600 +}; + +/** + * Get the path to the window state file. + */ +function getStateFilePath() { + return path.join(getAppDataDir(), STATE_FILE); +} + +/** + * Load saved window state from disk. + * Returns null if no saved state or file is corrupted. + */ +function loadWindowState() { + try { + const filePath = getStateFilePath(); + if (!fs.existsSync(filePath)) { + return null; + } + const data = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(data); + } catch (err) { + console.warn('Failed to load window state:', err.message); + return null; + } +} + +/** + * Save window state to disk. + */ +function saveWindowState(state) { + try { + const filePath = getStateFilePath(); + // Ensure directory exists + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(state, null, 2)); + } catch (err) { + console.warn('Failed to save window state:', err.message); + } +} + +/** + * Check if a rectangle is visible on any display. + * Returns true if at least a portion of the window would be visible. + */ +function isVisibleOnAnyDisplay(bounds) { + const displays = screen.getAllDisplays(); + const minVisibleArea = 100; // At least 100px visible + + for (const display of displays) { + const { x, y, width, height } = display.workArea; + + // Calculate overlap between window bounds and display work area + const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); + const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); + const overlapArea = overlapX * overlapY; + + if (overlapArea >= minVisibleArea) { + return true; + } + } + return false; +} + +/** + * Get the display nearest to a point. + */ +function getNearestDisplay(x, y) { + return screen.getDisplayNearestPoint({ x, y }); +} + +/** + * Get window options with restored state or defaults. + * Validates saved position is on a visible display. + */ +function getWindowOptions() { + const savedState = loadWindowState(); + + const options = { + width: DEFAULTS.width, + height: DEFAULTS.height, + minWidth: DEFAULTS.minWidth, + minHeight: DEFAULTS.minHeight + }; + + if (savedState) { + // Restore size (clamped to minimums) + options.width = Math.max(savedState.width || DEFAULTS.width, DEFAULTS.minWidth); + options.height = Math.max(savedState.height || DEFAULTS.height, DEFAULTS.minHeight); + + // Check if saved position is visible on any current display + if (savedState.x !== undefined && savedState.y !== undefined) { + const bounds = { + x: savedState.x, + y: savedState.y, + width: options.width, + height: options.height + }; + + if (isVisibleOnAnyDisplay(bounds)) { + // Position is valid, use it + options.x = savedState.x; + options.y = savedState.y; + } else { + // Position is off-screen (monitor disconnected?), center on nearest display + const nearestDisplay = getNearestDisplay(savedState.x, savedState.y); + const { x, y, width, height } = nearestDisplay.workArea; + options.x = x + Math.round((width - options.width) / 2); + options.y = y + Math.round((height - options.height) / 2); + console.log('Window position was off-screen, repositioned to nearest display'); + } + } + + // Track if we need to maximize after window is created + options._wasMaximized = savedState.isMaximized || false; + } + + return options; +} + +/** + * Track window state and save on close. + * Call this after creating the BrowserWindow. + */ +function trackWindowState(win) { + win.on('close', () => { + // getNormalBounds() returns the non-maximized bounds even when maximized + const bounds = win.getNormalBounds(); + const windowState = { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: win.isMaximized() + }; + saveWindowState(windowState); + }); +} + +module.exports = { + DEFAULTS, + getWindowOptions, + trackWindowState +}; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0960b6f..d0cd12a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3407,7 +3407,7 @@ dependencies = [ [[package]] name = "phoenix-code-ide" -version = "5.0.5" +version = "5.1.0" dependencies = [ "aes-gcm", "backtrace",