diff --git a/electron/appMenu.ts b/electron/appMenu.ts new file mode 100644 index 0000000..99dce47 --- /dev/null +++ b/electron/appMenu.ts @@ -0,0 +1,142 @@ +import { app, Menu, shell } from 'electron'; +import type { BrowserWindow } from 'electron'; + +interface ApplicationMenuOptions { + getMainWindow: () => BrowserWindow | null; +} + +function focusMainWindow(getMainWindow: () => BrowserWindow | null): BrowserWindow | null { + const win = getMainWindow(); + if (!win) return null; + + if (win.isMinimized()) { + win.restore(); + } + if (!win.isVisible()) { + win.show(); + } + win.focus(); + return win; +} + +function openNetworkSettings(getMainWindow: () => BrowserWindow | null): void { + const win = focusMainWindow(getMainWindow); + win?.webContents.send('network-settings:open'); +} + +/** + * Standard app menu with a discoverable Network Settings entry. + * Keeps File/Edit/View/Window/Help (and Quit on all platforms) so this is not + * a regression vs Electron's default menu for users who never open proxy settings. + */ +export function buildApplicationMenu({ getMainWindow }: ApplicationMenuOptions): void { + const isMac = process.platform === 'darwin'; + + const networkSettingsItem: Electron.MenuItemConstructorOptions = { + label: 'Network Settings…', + accelerator: 'CmdOrCtrl+,', + click: () => openNetworkSettings(getMainWindow) + }; + + const template: Electron.MenuItemConstructorOptions[] = [ + ...(isMac + ? [{ + label: app.name, + submenu: [ + { role: 'about' as const }, + { type: 'separator' as const }, + networkSettingsItem, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const } + ] + }] + : []), + { + label: 'File', + submenu: [ + ...(isMac ? [] : [networkSettingsItem, { type: 'separator' as const }]), + isMac ? { role: 'close' as const } : { role: 'quit' as const } + ] + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + ...(isMac + ? [ + { role: 'pasteAndMatchStyle' as const }, + { role: 'delete' as const }, + { role: 'selectAll' as const }, + { type: 'separator' as const }, + { + label: 'Speech', + submenu: [ + { role: 'startSpeaking' as const }, + { role: 'stopSpeaking' as const } + ] + } + ] + : [ + { role: 'delete' as const }, + { type: 'separator' as const }, + { role: 'selectAll' as const } + ]) + ] + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + ...(isMac + ? [ + { type: 'separator' as const }, + { role: 'front' as const }, + { type: 'separator' as const }, + { role: 'window' as const } + ] + : [ + { role: 'close' as const } + ]) + ] + }, + { + label: 'Help', + submenu: [ + { + label: 'BSV Desktop on GitHub', + click: async () => { + await shell.openExternal('https://github.com/bsv-blockchain/bsv-desktop'); + } + } + ] + } + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} diff --git a/electron/main.ts b/electron/main.ts index 6c8f0dd..9198390 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,9 +1,11 @@ -import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell, session } from 'electron'; import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; import fs from 'fs'; import { startHttpServer } from './httpServer.js'; +import { buildApplicationMenu } from './appMenu.js'; +import { applyPersistedProxySettings, registerNetworkIpc } from './networkSettings.js'; const require = createRequire(import.meta.url); @@ -49,6 +51,33 @@ const __dirname = path.dirname(__filename); let mainWindow: BrowserWindow | null = null; let httpServerCleanup: (() => Promise) | null = null; +let cleanupStarted = false; + +async function cleanupBeforeExit(): Promise { + if (cleanupStarted) return; + cleanupStarted = true; + + if (storageManager) { + try { + await storageManager.cleanup(); + } catch (error) { + console.error('Failed to clean up storage manager before exit:', error); + } finally { + storageManager = null; + } + } + + if (httpServerCleanup) { + const cleanup = httpServerCleanup; + httpServerCleanup = null; + + try { + await cleanup(); + } catch (error) { + console.error('Failed to clean up HTTP server before exit:', error); + } + } +} // Store previous focused app on macOS let prevBundleId: string | null = null; @@ -171,6 +200,13 @@ function isSafeExternalUrl(url: string): boolean { // ===== IPC Handlers ===== +registerNetworkIpc({ + hasActiveMonitorWorkers: () => { + // storageManager is only set after first storage use; no workers if never loaded + return Boolean(storageManager?.hasActiveMonitorWorkers?.()); + } +}); + // Check if window is focused ipcMain.handle('is-focused', () => { return mainWindow?.isFocused() ?? false; @@ -406,8 +442,9 @@ ipcMain.handle('proxy-fetch-manifest', async (_event, url: string) => { throw new Error('Only manifest.json files are allowed'); } - const fetch = (await import('node-fetch')).default; - const response = await fetch(url, { + // Use the Chromium session so manifest fetches honor session proxy settings + // (node-fetch would bypass session.setProxy). + const response = await session.defaultSession.fetch(url, { headers: { 'User-Agent': 'bsv-desktop-electron/1.0', 'Accept': 'application/json, */*;q=0.8' @@ -444,6 +481,17 @@ ipcMain.handle('proxy-fetch-manifest', async (_event, url: string) => { } }); +// Process exits in this handler; the invoke Promise is not observed by the renderer. +ipcMain.handle('app:restart', async () => { + try { + await cleanupBeforeExit(); + } catch (error) { + console.error('App restart cleanup failed:', error); + } + app.relaunch(); + app.exit(0); +}); + // Forward HTTP requests to renderer ipcMain.on('http-response', (_event, response) => { if (mainWindow) { @@ -651,6 +699,13 @@ app.whenReady().then(async () => { app.commandLine.appendSwitch('--disable-web-security'); } + try { + await applyPersistedProxySettings(); + } catch (error) { + console.error('[Startup] Failed to apply persisted proxy settings, continuing startup without them:', error); + } + + buildApplicationMenu({ getMainWindow: () => mainWindow }); createWindow(); // Start HTTPS server on port 2121 @@ -670,16 +725,9 @@ app.whenReady().then(async () => { }); app.on('window-all-closed', async () => { - // Cleanup storage connections - if (storageManager) { - await storageManager.cleanup(); - } - - if (httpServerCleanup) { - await httpServerCleanup(); - } + await cleanupBeforeExit(); - app.quit(); + app.quit(); }); app.on('before-quit', async (event) => { @@ -687,14 +735,7 @@ app.on('before-quit', async (event) => { if (storageManager || httpServerCleanup) { event.preventDefault(); - // Cleanup storage connections - if (storageManager) { - await storageManager.cleanup(); - } - - if (httpServerCleanup) { - await httpServerCleanup(); - } + await cleanupBeforeExit(); // Now actually quit app.exit(0); diff --git a/electron/networkSettings.ts b/electron/networkSettings.ts new file mode 100644 index 0000000..0a8794d --- /dev/null +++ b/electron/networkSettings.ts @@ -0,0 +1,235 @@ +import { app, ipcMain, session } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +export interface NetworkProxySettings { + mode: 'direct' | 'fixed_servers'; + proxyRules: string; + lastProxyRules?: string; +} + +export interface NetworkProxySettingsResponse extends NetworkProxySettings { + restartRequired: boolean; +} + +interface RegisterNetworkIpcOptions { + hasActiveMonitorWorkers?: () => boolean | Promise; +} + +const DEFAULT_PROXY_SETTINGS: NetworkProxySettings = { + mode: 'direct', + proxyRules: '' +}; + +const LOCAL_BYPASS = ';localhost;127.0.0.1;::1'; +const LOCAL_NO_PROXY = 'localhost,127.0.0.1,::1'; + +/** In-memory: workers forked before a mid-session proxy change still need a relaunch. */ +let pendingWorkerRestart = false; + +/** Snapshot of settings currently applied to Chromium session + process.env. */ +let appliedSettings: NetworkProxySettings | null = null; + +function getProxySettingsPath(): string { + return path.join(app.getPath('userData'), 'network-settings.json'); +} + +function readProxySettings(): NetworkProxySettings | null { + try { + const filePath = getProxySettingsPath(); + if (!fs.existsSync(filePath)) return null; + + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (parsed?.mode === 'direct') { + return { + ...DEFAULT_PROXY_SETTINGS, + lastProxyRules: typeof parsed.lastProxyRules === 'string' ? parsed.lastProxyRules.trim() : '' + }; + } + + if (parsed?.mode === 'fixed_servers' && typeof parsed.proxyRules === 'string') { + const proxyRules = parsed.proxyRules.trim(); + return { + mode: 'fixed_servers', + proxyRules, + lastProxyRules: typeof parsed.lastProxyRules === 'string' ? parsed.lastProxyRules.trim() : proxyRules + }; + } + } catch (error) { + console.error('[Network] Failed to read proxy settings:', error); + } + + return null; +} + +function writeProxySettings(settings: NetworkProxySettings): void { + const normalized = normalizeProxySettings(settings); + fs.mkdirSync(app.getPath('userData'), { recursive: true }); + fs.writeFileSync(getProxySettingsPath(), JSON.stringify(normalized, null, 2), 'utf-8'); +} + +function normalizeProxySettings(settings: NetworkProxySettings): NetworkProxySettings { + if (settings.mode === 'direct') { + return { + ...DEFAULT_PROXY_SETTINGS, + lastProxyRules: settings.lastProxyRules?.trim() || '' + }; + } + + const proxyRules = settings.proxyRules.trim(); + return { + mode: 'fixed_servers', + proxyRules, + lastProxyRules: proxyRules + }; +} + +function settingsEqual(a: NetworkProxySettings | null, b: NetworkProxySettings): boolean { + if (!a) return false; + return a.mode === b.mode && a.proxyRules === b.proxyRules; +} + +function getEffectiveProxySettings(): NetworkProxySettings { + return readProxySettings() ?? { ...DEFAULT_PROXY_SETTINGS }; +} + +function validateProxySettings(settings: NetworkProxySettings): NetworkProxySettings { + const normalized = normalizeProxySettings(settings); + + if (normalized.mode === 'direct') { + return normalized; + } + + if (!normalized.proxyRules) { + throw new Error('Proxy server is required'); + } + + if (!isValidHttpProxyUrl(normalized.proxyRules)) { + throw new Error('Use an HTTP proxy URL such as http://host:port'); + } + + return normalized; +} + +function isValidHttpProxyUrl(value: string): boolean { + try { + const url = new URL(value); + const port = Number(url.port); + return url.protocol === 'http:' && + Boolean(url.hostname) && + Boolean(url.port) && + Number.isInteger(port) && + port > 0 && + port <= 65535 && + url.username === '' && + url.password === '' && + url.pathname === '/' && + url.search === '' && + url.hash === ''; + } catch { + return false; + } +} + +function clearProcessProxyEnv(): void { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.ALL_PROXY; + delete process.env.all_proxy; + delete process.env.NO_PROXY; + delete process.env.no_proxy; +} + +function setProcessProxyEnv(proxyRules: string): void { + process.env.HTTP_PROXY = proxyRules; + process.env.HTTPS_PROXY = proxyRules; + process.env.http_proxy = proxyRules; + process.env.https_proxy = proxyRules; + process.env.NO_PROXY = LOCAL_NO_PROXY; + process.env.no_proxy = LOCAL_NO_PROXY; +} + +/** + * Apply Chromium session proxy + process env so Node children forked later inherit the same policy. + * Does not restart already-running workers. + */ +async function applyProxySettings(settings: NetworkProxySettings): Promise { + const normalized = normalizeProxySettings(settings); + + if (normalized.mode === 'direct') { + await session.defaultSession.setProxy({ mode: 'direct' }); + clearProcessProxyEnv(); + appliedSettings = normalized; + console.log('[Network] Proxy disabled (direct)'); + return; + } + + await session.defaultSession.setProxy({ + mode: 'fixed_servers', + proxyRules: normalized.proxyRules, + proxyBypassRules: LOCAL_BYPASS + }); + setProcessProxyEnv(normalized.proxyRules); + appliedSettings = normalized; + console.log(`[Network] Proxy enabled: ${normalized.proxyRules}`); +} + +/** + * Startup: only touch networking if the user previously saved settings. + * No settings file → leave Chromium defaults (including system proxy) alone. + */ +export async function applyPersistedProxySettings(): Promise { + const persisted = readProxySettings(); + if (!persisted) { + appliedSettings = null; + pendingWorkerRestart = false; + return; + } + + try { + await applyProxySettings(persisted); + pendingWorkerRestart = false; + } catch (error) { + console.error('[Network] Failed to apply persisted proxy settings:', error); + } +} + +export function registerNetworkIpc(options: RegisterNetworkIpcOptions = {}): void { + const { hasActiveMonitorWorkers } = options; + + ipcMain.handle('network:get-proxy-settings', async (): Promise => { + const settings = getEffectiveProxySettings(); + return { + ...settings, + restartRequired: pendingWorkerRestart + }; + }); + + ipcMain.handle('network:set-proxy-settings', async (_event, settings: NetworkProxySettings) => { + try { + const validated = validateProxySettings(settings); + const changed = !settingsEqual(appliedSettings, validated); + + writeProxySettings(validated); + await applyProxySettings(validated); + + if (changed) { + const workersActive = hasActiveMonitorWorkers + ? Boolean(await hasActiveMonitorWorkers()) + : false; + pendingWorkerRestart = workersActive; + } + + return { + success: true, + settings: validated, + restartRequired: pendingWorkerRestart + }; + } catch (error: any) { + console.error('[IPC] network:set-proxy-settings error:', error); + return { success: false, error: error.message }; + } + }); +} diff --git a/electron/preload.ts b/electron/preload.ts index e6d7d82..f306274 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -22,6 +22,23 @@ contextBridge.exposeInMainWorld('electronAPI', { proxyFetchManifest: (url: string) => ipcRenderer.invoke('proxy-fetch-manifest', url), + // Network proxy settings + network: { + getProxySettings: () => ipcRenderer.invoke('network:get-proxy-settings'), + setProxySettings: (settings: { mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string }) => + ipcRenderer.invoke('network:set-proxy-settings', settings), + onOpenSettings: (callback: () => void) => { + ipcRenderer.on('network-settings:open', callback); + }, + removeOpenSettingsListener: (callback: () => void) => { + ipcRenderer.removeListener('network-settings:open', callback); + } + }, + + app: { + restart: () => ipcRenderer.invoke('app:restart') + }, + // HTTP request/response handling onHttpRequest: (callback: (event: any) => void) => { ipcRenderer.on('http-request', (_event, request) => callback(request)); @@ -125,6 +142,16 @@ export interface ElectronAPI { saveMnemonic: (mnemonic: string) => Promise<{ success: boolean; path?: string; error?: string }>; savePrivateKey: (privateKey: string) => Promise<{ success: boolean; path?: string; error?: string }>; proxyFetchManifest: (url: string) => Promise<{ status: number; headers: [string, string][]; body: string }>; + network: { + getProxySettings: () => Promise<{ mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string; restartRequired?: boolean }>; + setProxySettings: (settings: { mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string }) => Promise<{ success: boolean; settings?: { mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string }; restartRequired?: boolean; error?: string }>; + onOpenSettings: (callback: () => void) => void; + removeOpenSettingsListener: (callback: () => void) => void; + }; + app: { + /** Relaunches the app; the process exits and the Promise does not resolve. */ + restart: () => Promise; + }; onHttpRequest: (callback: (event: any) => void) => void; onHttpRequestCancelled: (callback: (event: { request_id: number; reason?: string }) => void) => void; sendHttpResponse: (response: any) => void; diff --git a/electron/storage.ts b/electron/storage.ts index 307b269..dece848 100644 --- a/electron/storage.ts +++ b/electron/storage.ts @@ -86,6 +86,11 @@ class StorageManager { // Monitor worker processes private monitorWorkers: Map = new Map(); + /** True when any forked monitor worker is still running (inherits env at fork time). */ + hasActiveMonitorWorkers(): boolean { + return this.monitorWorkers.size > 0; + } + /** * Get or create a storage instance for the given identity key */ diff --git a/package-lock.json b/package-lock.json index 73a2108..35bd60b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bsv-desktop-electron", - "version": "2.6.1", + "version": "2.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bsv-desktop-electron", - "version": "2.6.1", + "version": "2.6.2", "license": "Apache-2.0", "dependencies": { "@bsv/amountinator": "^2.1.0", diff --git a/package.json b/package.json index d5ff2cd..7839bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsv-desktop-electron", - "version": "2.6.1", + "version": "2.6.2", "description": "BSV Desktop Wallet - Electron Edition", "main": "dist-electron/main.js", "type": "module", diff --git a/src/global.d.ts b/src/global.d.ts index be6460a..0925d7f 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -9,6 +9,16 @@ export interface ElectronAPI { saveMnemonic: (mnemonic: string) => Promise<{ success: boolean; path?: string; error?: string }>; savePrivateKey: (privateKey: string) => Promise<{ success: boolean; path?: string; error?: string }>; proxyFetchManifest: (url: string) => Promise<{ status: number; headers: [string, string][]; body: string }>; + network: { + getProxySettings: () => Promise<{ mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string; restartRequired?: boolean }>; + setProxySettings: (settings: { mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string }) => Promise<{ success: boolean; settings?: { mode: 'direct' | 'fixed_servers'; proxyRules: string; lastProxyRules?: string }; restartRequired?: boolean; error?: string }>; + onOpenSettings: (callback: () => void) => void; + removeOpenSettingsListener: (callback: () => void) => void; + }; + app: { + /** Relaunches the app; the process exits and the Promise does not resolve. */ + restart: () => Promise; + }; onHttpRequest: (callback: (event: any) => void) => void; onHttpRequestCancelled: (callback: (event: { request_id: number; reason?: string }) => void) => void; sendHttpResponse: (response: any) => void; diff --git a/src/lib/UserInterface.tsx b/src/lib/UserInterface.tsx index 7c67030..b20636f 100644 --- a/src/lib/UserInterface.tsx +++ b/src/lib/UserInterface.tsx @@ -38,6 +38,7 @@ import GroupPermissionHandler from './components/GroupPermissionHandler' import { UpdateNotification } from './components/UpdateNotification' import PrivacyPolicy from './pages/Policies/privacy' import UsagePolicy from './pages/Policies/usage' +import NetworkSettingsDialog from './components/NetworkSettingsDialog' interface UserInterfaceProps { onWalletReady: (wallet: WalletInterface) => Promise<(() => void) | undefined>; @@ -75,6 +76,7 @@ const UserInterface: React.FC = ({ onWalletReady, nativeHand + @@ -106,4 +108,4 @@ const UpdateNotificationWrapper: React.FC = () => { ); }; -export default UserInterface \ No newline at end of file +export default UserInterface diff --git a/src/lib/components/NetworkSettingsDialog.tsx b/src/lib/components/NetworkSettingsDialog.tsx new file mode 100644 index 0000000..05e4d44 --- /dev/null +++ b/src/lib/components/NetworkSettingsDialog.tsx @@ -0,0 +1,152 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Stack, + Switch, + TextField, + Typography +} from '@mui/material' +import { toast } from 'react-toastify' + +interface ProxySettings { + mode: 'direct' | 'fixed_servers' + proxyRules: string + lastProxyRules?: string + restartRequired?: boolean +} + +const NetworkSettingsDialog: React.FC = () => { + const [open, setOpen] = useState(false) + const [proxyEnabled, setProxyEnabled] = useState(false) + const [proxyUrl, setProxyUrl] = useState('') + const [saving, setSaving] = useState(false) + const [restartAvailable, setRestartAvailable] = useState(false) + const [loading, setLoading] = useState(false) + + const hasElectronNetworkApi = Boolean(window.electronAPI?.network) + + const loadSettings = useCallback(async () => { + if (!hasElectronNetworkApi) return + + setLoading(true) + try { + const settings = await window.electronAPI.network.getProxySettings() + setProxyEnabled(settings.mode === 'fixed_servers' && Boolean(settings.proxyRules)) + setProxyUrl(settings.proxyRules || settings.lastProxyRules || '') + setRestartAvailable(Boolean(settings.restartRequired)) + } catch (error) { + console.error('[NetworkSettings] Failed to load settings:', error) + toast.error('Failed to load network settings.') + } finally { + setLoading(false) + } + }, [hasElectronNetworkApi]) + + useEffect(() => { + if (!hasElectronNetworkApi) return + + const handleOpenSettings = () => { + setOpen(true) + void loadSettings() + } + + window.electronAPI.network.onOpenSettings(handleOpenSettings) + return () => { + window.electronAPI.network.removeOpenSettingsListener(handleOpenSettings) + } + }, [hasElectronNetworkApi, loadSettings]) + + const handleSave = async () => { + if (!hasElectronNetworkApi) return + + setSaving(true) + + const settings: ProxySettings = proxyEnabled + ? { mode: 'fixed_servers', proxyRules: proxyUrl.trim() } + : { mode: 'direct', proxyRules: '', lastProxyRules: proxyUrl.trim() } + + try { + const result = await window.electronAPI.network.setProxySettings(settings) + + if (!result.success) { + toast.error(result.error || 'Failed to save network settings.') + return + } + + setRestartAvailable(Boolean(result.restartRequired)) + if (result.restartRequired) { + toast.success('Network settings saved. Restart to update background services.') + } else { + toast.success('Network settings applied.') + } + } catch { + toast.error('Failed to save network settings.') + } finally { + setSaving(false) + } + } + + const handleRestart = async () => { + if (!hasElectronNetworkApi) return + await window.electronAPI.app.restart() + } + + return ( + setOpen(false)} maxWidth="sm" fullWidth> + Network + + + + Optionally route BSV Desktop traffic through an HTTP proxy. Leave this off unless you need a proxy. + + + {restartAvailable && ( + + Background wallet services are still using the previous proxy settings. Restart BSV Desktop to update them. + + )} + + setProxyEnabled(event.target.checked)} + /> + } + label="Use HTTP proxy" + /> + + setProxyUrl(event.target.value)} + helperText="Only HTTP proxy URLs are supported (for example http://127.0.0.1:8080)." + /> + + + + + {restartAvailable && ( + + )} + + + + ) +} + +export default NetworkSettingsDialog