From a169c2eafb65a45aa0589987891253ca5b1f49a1 Mon Sep 17 00:00:00 2001 From: cyio Date: Mon, 11 May 2026 09:14:13 +0800 Subject: [PATCH 1/4] Add network proxy settings --- electron/appMenu.ts | 85 +++++++++++ electron/main.ts | 49 +++--- electron/networkSettings.ts | 151 +++++++++++++++++++ electron/preload.ts | 26 ++++ src/global.d.ts | 9 ++ src/lib/UserInterface.tsx | 4 +- src/lib/components/NetworkSettingsDialog.tsx | 132 ++++++++++++++++ 7 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 electron/appMenu.ts create mode 100644 electron/networkSettings.ts create mode 100644 src/lib/components/NetworkSettingsDialog.tsx diff --git a/electron/appMenu.ts b/electron/appMenu.ts new file mode 100644 index 0000000..64951be --- /dev/null +++ b/electron/appMenu.ts @@ -0,0 +1,85 @@ +import { app, Menu, shell } from 'electron'; +import type { BrowserWindow } from 'electron'; + +interface ApplicationMenuOptions { + getMainWindow: () => BrowserWindow | null; +} + +export function buildApplicationMenu({ getMainWindow }: ApplicationMenuOptions): void { + const template: Electron.MenuItemConstructorOptions[] = [ + ...(process.platform === 'darwin' + ? [{ + label: app.name, + submenu: [ + { role: 'about' as const }, + { 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: 'Settings', + submenu: [ + { + label: 'Network', + accelerator: 'CmdOrCtrl+,', + click: () => { + getMainWindow()?.webContents.send('network-settings:open'); + } + } + ] + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' } + ] + }, + { + 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: 'close' } + ] + }, + { + label: 'Help', + submenu: [ + { + label: 'BSV Desktop', + 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 73e9984..f75cacb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,8 @@ 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); @@ -31,6 +33,22 @@ 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) { + await storageManager.cleanup(); + storageManager = null; + } + + if (httpServerCleanup) { + await httpServerCleanup(); + httpServerCleanup = null; + } +} // Store previous focused app on macOS let prevBundleId: string | null = null; @@ -132,6 +150,8 @@ function createWindow() { // ===== IPC Handlers ===== +registerNetworkIpc(); + // Check if window is focused ipcMain.handle('is-focused', () => { return mainWindow?.isFocused() ?? false; @@ -383,6 +403,12 @@ ipcMain.handle('proxy-fetch-manifest', async (_event, url: string) => { } }); +ipcMain.handle('app:restart', async () => { + await cleanupBeforeExit(); + app.relaunch(); + app.exit(0); +}); + // Forward HTTP requests to renderer ipcMain.on('http-response', (_event, response) => { if (mainWindow) { @@ -495,6 +521,9 @@ app.whenReady().then(async () => { app.commandLine.appendSwitch('--disable-web-security'); } + await applyPersistedProxySettings(); + + buildApplicationMenu({ getMainWindow: () => mainWindow }); createWindow(); // Start HTTPS server on port 2121 @@ -514,16 +543,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) => { @@ -531,14 +553,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..40610b0 --- /dev/null +++ b/electron/networkSettings.ts @@ -0,0 +1,151 @@ +import { app, ipcMain, session } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +interface NetworkProxySettings { + mode: 'direct' | 'fixed_servers'; + proxyRules: string; + lastProxyRules?: string; +} + +const DEFAULT_PROXY_SETTINGS: NetworkProxySettings = { + mode: 'direct', + proxyRules: '' +}; + +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 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; + } +} + +async function applyProxySettings(settings: NetworkProxySettings): Promise { + const normalized = normalizeProxySettings(settings); + + if (normalized.mode === 'direct') { + await session.defaultSession.setProxy({ mode: 'direct' }); + console.log('[Network] Proxy disabled'); + return; + } + + await session.defaultSession.setProxy({ + mode: 'fixed_servers', + proxyRules: normalized.proxyRules, + proxyBypassRules: ';localhost;127.0.0.1;::1' + }); + console.log(`[Network] Proxy enabled: ${normalized.proxyRules}`); +} + +export async function applyPersistedProxySettings(): Promise { + const persistedProxySettings = readProxySettings(); + if (persistedProxySettings) { + await applyProxySettings(persistedProxySettings); + } +} + +export function registerNetworkIpc(): void { + ipcMain.handle('network:get-proxy-settings', async () => { + return getEffectiveProxySettings(); + }); + + ipcMain.handle('network:set-proxy-settings', async (_event, settings: NetworkProxySettings) => { + try { + const validated = validateProxySettings(settings); + writeProxySettings(validated); + return { success: true, settings: validated, restartRequired: true }; + } 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 6cbebcf..ca50eec 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)); @@ -82,6 +99,15 @@ 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 }>; + 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: { + restart: () => Promise; + }; onHttpRequest: (callback: (event: any) => void) => void; sendHttpResponse: (response: any) => void; removeHttpRequestListener: () => void; diff --git a/src/global.d.ts b/src/global.d.ts index c8aef47..f3e7c44 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -9,6 +9,15 @@ 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 }>; + 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: { + restart: () => Promise; + }; onHttpRequest: (callback: (event: any) => void) => void; sendHttpResponse: (response: any) => void; removeHttpRequestListener: () => 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..987f7d1 --- /dev/null +++ b/src/lib/components/NetworkSettingsDialog.tsx @@ -0,0 +1,132 @@ +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; +} + +const DEFAULT_PROXY_URL = 'http://127.0.0.1:7890' + +const NetworkSettingsDialog: React.FC = () => { + const [open, setOpen] = useState(false) + const [proxyEnabled, setProxyEnabled] = useState(false) + const [proxyUrl, setProxyUrl] = useState(DEFAULT_PROXY_URL) + const [saving, setSaving] = useState(false) + const [restartAvailable, setRestartAvailable] = useState(false) + + const hasElectronNetworkApi = Boolean(window.electronAPI?.network) + + const loadSettings = useCallback(async () => { + if (!hasElectronNetworkApi) return + + const settings = await window.electronAPI.network.getProxySettings() + setProxyEnabled(settings.mode === 'fixed_servers' && Boolean(settings.proxyRules)) + setProxyUrl(settings.proxyRules || settings.lastProxyRules || DEFAULT_PROXY_URL) + setRestartAvailable(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() } + + const result = await window.electronAPI.network.setProxySettings(settings) + setSaving(false) + + if (!result.success) { + toast.error(result.error || 'Failed to save network settings.') + return + } + + setRestartAvailable(Boolean(result.restartRequired)) + toast.success('Network settings saved.') + } + + const handleRestart = async () => { + if (!hasElectronNetworkApi) return + await window.electronAPI.app.restart() + } + + return ( + setOpen(false)} maxWidth="sm" fullWidth> + Network + + + + Configure an application-wide HTTP proxy for BSV Desktop network requests. + + + + Changes take effect after restarting BSV Desktop. + + + setProxyEnabled(event.target.checked)} + /> + } + label="Use HTTP proxy" + /> + + setProxyUrl(event.target.value)} + helperText="Only HTTP proxy URLs are supported." + /> + + + + + {restartAvailable && ( + + )} + + + + ) +} + +export default NetworkSettingsDialog From 00cc52907b473dda46dfa1444f1ea10c1bada3bb Mon Sep 17 00:00:00 2001 From: Oaker Date: Tue, 26 May 2026 17:04:57 +0800 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- electron/main.ts | 41 ++++++++++++++++---- electron/networkSettings.ts | 8 +++- src/lib/components/NetworkSettingsDialog.tsx | 23 ++++++----- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index f75cacb..19c9a5e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -40,13 +40,24 @@ async function cleanupBeforeExit(): Promise { cleanupStarted = true; if (storageManager) { - await storageManager.cleanup(); - storageManager = null; + try { + await storageManager.cleanup(); + } catch (error) { + console.error('Failed to clean up storage manager before exit:', error); + } finally { + storageManager = null; + } } if (httpServerCleanup) { - await httpServerCleanup(); + const cleanup = httpServerCleanup; httpServerCleanup = null; + + try { + await cleanup(); + } catch (error) { + console.error('Failed to clean up HTTP server before exit:', error); + } } } @@ -404,9 +415,21 @@ ipcMain.handle('proxy-fetch-manifest', async (_event, url: string) => { }); ipcMain.handle('app:restart', async () => { - await cleanupBeforeExit(); - app.relaunch(); - app.exit(0); + let cleanupError: unknown = null; + + try { + await cleanupBeforeExit(); + } catch (error) { + cleanupError = error; + console.error('App restart cleanup failed:', error); + } finally { + app.relaunch(); + app.exit(0); + } + + return cleanupError + ? { success: false, error: String(cleanupError) } + : { success: true }; }); // Forward HTTP requests to renderer @@ -521,7 +544,11 @@ app.whenReady().then(async () => { app.commandLine.appendSwitch('--disable-web-security'); } - await applyPersistedProxySettings(); + try { + await applyPersistedProxySettings(); + } catch (error) { + console.error('[Startup] Failed to apply persisted proxy settings, continuing startup without them:', error); + } buildApplicationMenu({ getMainWindow: () => mainWindow }); createWindow(); diff --git a/electron/networkSettings.ts b/electron/networkSettings.ts index 40610b0..ccbe6c7 100644 --- a/electron/networkSettings.ts +++ b/electron/networkSettings.ts @@ -128,8 +128,12 @@ async function applyProxySettings(settings: NetworkProxySettings): Promise export async function applyPersistedProxySettings(): Promise { const persistedProxySettings = readProxySettings(); - if (persistedProxySettings) { - await applyProxySettings(persistedProxySettings); + const effectiveProxySettings = persistedProxySettings ?? DEFAULT_PROXY_SETTINGS; + + try { + await applyProxySettings(effectiveProxySettings); + } catch (error) { + console.error('[Network] Failed to apply persisted proxy settings:', error); } } diff --git a/src/lib/components/NetworkSettingsDialog.tsx b/src/lib/components/NetworkSettingsDialog.tsx index 987f7d1..0bf768d 100644 --- a/src/lib/components/NetworkSettingsDialog.tsx +++ b/src/lib/components/NetworkSettingsDialog.tsx @@ -63,16 +63,21 @@ const NetworkSettingsDialog: React.FC = () => { ? { mode: 'fixed_servers', proxyRules: proxyUrl.trim() } : { mode: 'direct', proxyRules: '', lastProxyRules: proxyUrl.trim() } - const result = await window.electronAPI.network.setProxySettings(settings) - setSaving(false) - - if (!result.success) { - toast.error(result.error || 'Failed to save network settings.') - return + 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)) + toast.success('Network settings saved.') + } catch { + toast.error('Failed to save network settings.') + } finally { + setSaving(false) } - - setRestartAvailable(Boolean(result.restartRequired)) - toast.success('Network settings saved.') } const handleRestart = async () => { From 9eda8f3e8f13597755bbf9ac468476422b2622b0 Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 9 Jul 2026 12:10:18 -0500 Subject: [PATCH 3/4] fix: opt-in proxy defaults and restore standard app menu Keep proxy settings silent for users who never open them: leave Chromium defaults alone when no network-settings.json exists, apply live when saved, and only offer restart when monitor workers need it. Restore a standard File/Edit/View/Window/Help menu with Quit on all platforms, focus the window before opening Network Settings, honor session proxies for manifest fetch, and set HTTP(S)_PROXY for forked workers so coverage is closer to application-wide. --- electron/appMenu.ts | 81 ++++++++++++--- electron/main.ts | 27 +++-- electron/networkSettings.ts | 100 +++++++++++++++++-- electron/preload.ts | 3 +- electron/storage.ts | 5 + src/global.d.ts | 3 +- src/lib/components/NetworkSettingsDialog.tsx | 51 ++++++---- 7 files changed, 214 insertions(+), 56 deletions(-) diff --git a/electron/appMenu.ts b/electron/appMenu.ts index 64951be..99dce47 100644 --- a/electron/appMenu.ts +++ b/electron/appMenu.ts @@ -5,14 +5,48 @@ 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[] = [ - ...(process.platform === 'darwin' + ...(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 }, @@ -24,15 +58,10 @@ export function buildApplicationMenu({ getMainWindow }: ApplicationMenuOptions): }] : []), { - label: 'Settings', + label: 'File', submenu: [ - { - label: 'Network', - accelerator: 'CmdOrCtrl+,', - click: () => { - getMainWindow()?.webContents.send('network-settings:open'); - } - } + ...(isMac ? [] : [networkSettingsItem, { type: 'separator' as const }]), + isMac ? { role: 'close' as const } : { role: 'quit' as const } ] }, { @@ -44,7 +73,25 @@ export function buildApplicationMenu({ getMainWindow }: ApplicationMenuOptions): { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, - { role: 'selectAll' } + ...(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 } + ]) ] }, { @@ -65,14 +112,24 @@ export function buildApplicationMenu({ getMainWindow }: ApplicationMenuOptions): label: 'Window', submenu: [ { role: 'minimize' }, - { role: 'close' } + { 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', + label: 'BSV Desktop on GitHub', click: async () => { await shell.openExternal('https://github.com/bsv-blockchain/bsv-desktop'); } diff --git a/electron/main.ts b/electron/main.ts index 19c9a5e..9f1658f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -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'; @@ -161,7 +161,12 @@ function createWindow() { // ===== IPC Handlers ===== -registerNetworkIpc(); +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', () => { @@ -388,8 +393,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' @@ -414,22 +420,15 @@ 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 () => { - let cleanupError: unknown = null; - try { await cleanupBeforeExit(); } catch (error) { - cleanupError = error; console.error('App restart cleanup failed:', error); - } finally { - app.relaunch(); - app.exit(0); } - - return cleanupError - ? { success: false, error: String(cleanupError) } - : { success: true }; + app.relaunch(); + app.exit(0); }); // Forward HTTP requests to renderer diff --git a/electron/networkSettings.ts b/electron/networkSettings.ts index ccbe6c7..0a8794d 100644 --- a/electron/networkSettings.ts +++ b/electron/networkSettings.ts @@ -2,17 +2,34 @@ import { app, ipcMain, session } from 'electron'; import fs from 'fs'; import path from 'path'; -interface NetworkProxySettings { +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'); } @@ -67,6 +84,11 @@ function normalizeProxySettings(settings: NetworkProxySettings): NetworkProxySet }; } +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 }; } @@ -109,44 +131,102 @@ function isValidHttpProxyUrl(value: string): boolean { } } +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' }); - console.log('[Network] Proxy disabled'); + clearProcessProxyEnv(); + appliedSettings = normalized; + console.log('[Network] Proxy disabled (direct)'); return; } await session.defaultSession.setProxy({ mode: 'fixed_servers', proxyRules: normalized.proxyRules, - proxyBypassRules: ';localhost;127.0.0.1;::1' + 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 persistedProxySettings = readProxySettings(); - const effectiveProxySettings = persistedProxySettings ?? DEFAULT_PROXY_SETTINGS; + const persisted = readProxySettings(); + if (!persisted) { + appliedSettings = null; + pendingWorkerRestart = false; + return; + } try { - await applyProxySettings(effectiveProxySettings); + await applyProxySettings(persisted); + pendingWorkerRestart = false; } catch (error) { console.error('[Network] Failed to apply persisted proxy settings:', error); } } -export function registerNetworkIpc(): void { - ipcMain.handle('network:get-proxy-settings', async () => { - return getEffectiveProxySettings(); +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); - return { success: true, settings: validated, restartRequired: true }; + 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 ca50eec..22c64c4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -100,12 +100,13 @@ export interface ElectronAPI { 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 }>; + 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; diff --git a/electron/storage.ts b/electron/storage.ts index 161d336..2f992c3 100644 --- a/electron/storage.ts +++ b/electron/storage.ts @@ -46,6 +46,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/src/global.d.ts b/src/global.d.ts index f3e7c44..54d8988 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -10,12 +10,13 @@ export interface ElectronAPI { 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 }>; + 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; diff --git a/src/lib/components/NetworkSettingsDialog.tsx b/src/lib/components/NetworkSettingsDialog.tsx index 0bf768d..05e4d44 100644 --- a/src/lib/components/NetworkSettingsDialog.tsx +++ b/src/lib/components/NetworkSettingsDialog.tsx @@ -15,29 +15,37 @@ import { import { toast } from 'react-toastify' interface ProxySettings { - mode: 'direct' | 'fixed_servers'; - proxyRules: string; - lastProxyRules?: string; + mode: 'direct' | 'fixed_servers' + proxyRules: string + lastProxyRules?: string + restartRequired?: boolean } -const DEFAULT_PROXY_URL = 'http://127.0.0.1:7890' - const NetworkSettingsDialog: React.FC = () => { const [open, setOpen] = useState(false) const [proxyEnabled, setProxyEnabled] = useState(false) - const [proxyUrl, setProxyUrl] = useState(DEFAULT_PROXY_URL) + 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 - const settings = await window.electronAPI.network.getProxySettings() - setProxyEnabled(settings.mode === 'fixed_servers' && Boolean(settings.proxyRules)) - setProxyUrl(settings.proxyRules || settings.lastProxyRules || DEFAULT_PROXY_URL) - setRestartAvailable(false) + 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(() => { @@ -72,7 +80,11 @@ const NetworkSettingsDialog: React.FC = () => { } setRestartAvailable(Boolean(result.restartRequired)) - toast.success('Network settings saved.') + 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 { @@ -91,17 +103,20 @@ const NetworkSettingsDialog: React.FC = () => { - Configure an application-wide HTTP proxy for BSV Desktop network requests. + Optionally route BSV Desktop traffic through an HTTP proxy. Leave this off unless you need a proxy. - - Changes take effect after restarting BSV Desktop. - + {restartAvailable && ( + + Background wallet services are still using the previous proxy settings. Restart BSV Desktop to update them. + + )} setProxyEnabled(event.target.checked)} /> } @@ -110,12 +125,12 @@ const NetworkSettingsDialog: React.FC = () => { setProxyUrl(event.target.value)} - helperText="Only HTTP proxy URLs are supported." + helperText="Only HTTP proxy URLs are supported (for example http://127.0.0.1:8080)." /> @@ -126,7 +141,7 @@ const NetworkSettingsDialog: React.FC = () => { Restart )} - From 52ee0e718cd495cad2eed4a1b7759f9da36331df Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 9 Jul 2026 13:11:24 -0500 Subject: [PATCH 4/4] 2.6.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",