diff --git a/eslint.config.mjs b/eslint.config.mjs index f5c57a834..d861a059c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -95,6 +95,12 @@ export default tseslint.config( 'error', { checksVoidReturn: { attributes: false } }, ], + // Explicit: already error via strictTypeChecked. ignoreVoid keeps intentional fire-and-forget + // (call sites must still attach .catch when the promise can reject). + '@typescript-eslint/no-floating-promises': [ + 'error', + { ignoreVoid: true, checkThenables: true }, + ], // Off globally: defensive UI ?. / ?? churn. Re-enabled for shared + renderer/lib below. '@typescript-eslint/no-unnecessary-condition': 'off', // Autofix removes generics that TypeScript still needs for inference (tsc errors after diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index d5cd545ab..84cf3efde 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -164,7 +164,7 @@ When [ratspeak/rsReticulum#14](https://github.com/ratspeak/rsReticulum/pull/14) ## Removed: rsReticulum-rnode-tcp-activity-keepalive.patch -Sunset when upstream landed `RNodeIdleProbe` (`88d3d38` — *rnode: restore TCP application idle probes*). [ratspeak/rsReticulum#15](https://github.com/ratspeak/rsReticulum/pull/15) was closed as superseded; mesh-client no longer carries that overlay (pin `9928abed269a83ec5a7ef165ff1142d938cad706` or later already includes idle probes). `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` still tracks `#15` so `pnpm run update` warns on closed-without-merge until the entry is dropped after sunset is confirmed. +Sunset when upstream landed `RNodeIdleProbe` (`88d3d38` — *rnode: restore TCP application idle probes*). [ratspeak/rsReticulum#15](https://github.com/ratspeak/rsReticulum/pull/15) was closed as superseded; mesh-client no longer carries that overlay (pin `9928abed269a83ec5a7ef165ff1142d938cad706` or later already includes idle probes). Tracked entry removed from `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` after sunset confirmation. ## rsReticulum-ble-rnode-pairing-transition-debounce.patch diff --git a/scripts/update.sh b/scripts/update.sh index 11dfeaa33..d14cf5f20 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -210,7 +210,6 @@ check_ratspeak_patches() { 'rsReticulum-packet-tap.patch|ratspeak/rsReticulum|10|rsReticulum packet-tap|https://github.com/ratspeak/rsReticulum/pull/10' 'rsReticulum-auto-beacon-utun.patch|ratspeak/rsReticulum|11|rsReticulum auto-beacon utun|https://github.com/ratspeak/rsReticulum/pull/11' 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' - 'rsReticulum-rnode-tcp-activity-keepalive.patch|ratspeak/rsReticulum|15|rsReticulum RNode TCP activity keepalive|https://github.com/ratspeak/rsReticulum/pull/15' 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum|20|rsReticulum BLE RNode pairing-transition debounce|https://github.com/ratspeak/rsReticulum/pull/20' 'rsReticulum-discovery-announce-egress.patch|ratspeak/rsReticulum|19|rsReticulum discovery announce egress|https://github.com/ratspeak/rsReticulum/pull/19' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' @@ -297,7 +296,7 @@ check_ratspeak_patches() { ;; closed) # Still warn when the .patch is already gone so closed-without-merge stays visible - # (e.g. #15 superseded by upstream RNodeIdleProbe — confirm sunset, then drop entry). + # until sunset is confirmed and the entry is dropped from RATSPEAK_PATCH_ENTRIES. warn_box "${label} (Ratspeak overlay)" "local patch" "PR closed (not merged?)" "${url}" if [ "${patch_present}" -eq 1 ]; then echo " Reason tracked: ${repo}#${pr} closed without merge — verify overlay still needed." diff --git a/src/main/index.ts b/src/main/index.ts index 314ef4d62..044e0ca3f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2393,11 +2393,21 @@ ipcMain.handle('bluetooth-pair', async (event, macAddress: unknown, pin: unknown stderr.includes('AuthenticationCanceled'); const pairingSucceededByOutput = stdout.includes('Pairing successful'); if (!pairingFailedByOutput && (pairingSucceededByOutput || code === 0)) { - void trustDeviceBestEffort().then(() => { - if (settled) return; - console.debug('[IPC] bluetooth-pair success'); - finishResolve(); - }); + void trustDeviceBestEffort() + .then(() => { + if (settled) return; + console.debug('[IPC] bluetooth-pair success'); + finishResolve(); + }) + .catch((e: unknown) => { + // trustDeviceBestEffort is best-effort and should not reject; finish pairing anyway. + console.debug( + '[IPC] bluetooth-pair trust settle:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + if (settled) return; + finishResolve(); + }); } else { console.warn( '[IPC] bluetooth-pair failed:', @@ -6405,134 +6415,147 @@ app.on('child-process-gone', (_event, details) => { ); }); -void app.whenReady().then(() => { - try { - initLogFile(); - console.debug(`[Startup] runtime ${formatRuntimeLogTag()}`); +void app + .whenReady() + .then(() => { try { - console.debug('[main] crashDumps path:', sanitizeLogMessage(app.getPath('crashDumps'))); - } catch (e: unknown) { - console.warn( - '[main] crashDumps path unavailable:', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } + initLogFile(); + console.debug(`[Startup] runtime ${formatRuntimeLogTag()}`); + try { + console.debug('[main] crashDumps path:', sanitizeLogMessage(app.getPath('crashDumps'))); + } catch (e: unknown) { + console.warn( + '[main] crashDumps path unavailable:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + } - // Register lxm:// deep links (dev + packaged). OS-specific: argv in defaultApp. - if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient('lxm', process.execPath, [path.resolve(process.argv[1])]); + // Register lxm:// deep links (dev + packaged). OS-specific: argv in defaultApp. + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('lxm', process.execPath, [path.resolve(process.argv[1])]); + } + } else { + app.setAsDefaultProtocolClient('lxm'); } - } else { - app.setAsDefaultProtocolClient('lxm'); - } - const coldStartUrl = findLxmUrlInArgv(process.argv); - if (coldStartUrl) pendingOpenUrl = coldStartUrl; + const coldStartUrl = findLxmUrlInArgv(process.argv); + if (coldStartUrl) pendingOpenUrl = coldStartUrl; - initDatabase(); + initDatabase(); - // Auto-restore TAK server if auto-start is enabled - const takSettingsPath = path.join(app.getPath('userData'), 'tak-settings.json'); - try { - if (fs.existsSync(takSettingsPath)) { - const raw: unknown = JSON.parse(fs.readFileSync(takSettingsPath, 'utf-8')); - // Backfill autoStart for settings files saved before the field was added. - if ( - raw != null && - typeof raw === 'object' && - typeof (raw as Record).autoStart !== 'boolean' - ) { - (raw as Record).autoStart = false; - } - const saved = raw; - validateTakSettings(saved); - if (saved.autoStart) { - void ensureTakServerManager() - .then((m) => m.start(saved)) - .catch((e: unknown) => { - console.error( - '[TAK] Auto-start failed:', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - }); + // Auto-restore TAK server if auto-start is enabled + const takSettingsPath = path.join(app.getPath('userData'), 'tak-settings.json'); + try { + if (fs.existsSync(takSettingsPath)) { + const raw: unknown = JSON.parse(fs.readFileSync(takSettingsPath, 'utf-8')); + // Backfill autoStart for settings files saved before the field was added. + if ( + raw != null && + typeof raw === 'object' && + typeof (raw as Record).autoStart !== 'boolean' + ) { + (raw as Record).autoStart = false; + } + const saved = raw; + validateTakSettings(saved); + if (saved.autoStart) { + void ensureTakServerManager() + .then((m) => m.start(saved)) + .catch((e: unknown) => { + console.error( + '[TAK] Auto-start failed:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + }); + } } + } catch (e: unknown) { + console.warn( + '[TAK] Settings restore failed:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); } - } catch (e: unknown) { - console.warn( - '[TAK] Settings restore failed:', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } - // Force the dock icon in development on macOS - if (!app.isPackaged && process.platform === 'darwin') { - const iconPath = path.join( - __dirname, - '../../resources/icons/mac/iconset/icon_256x256@1x.png', + // Force the dock icon in development on macOS + if (!app.isPackaged && process.platform === 'darwin') { + const iconPath = path.join( + __dirname, + '../../resources/icons/mac/iconset/icon_256x256@1x.png', + ); + app.dock?.setIcon(iconPath); + } + createWindow(); + + const MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS = 60 * 60 * 1000; + const MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC = 24 * 60 * 60; + setInterval(() => { + if (process.uptime() < MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC) return; + const uptimeSec = Math.floor(process.uptime()); + const mem = process.memoryUsage(); + const ble = nobleBleManager.getLongSessionHealthSnapshot(); + console.debug( + `[main] long-session health uptimeSec=${uptimeSec} rss=${mem.rss} heapUsed=${mem.heapUsed} ble=${JSON.stringify(ble)}`, + ); + }, MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS).unref(); + + setupAppMenu(); + + // ─── Power monitor: notify renderer on suspend/resume ────────── + powerMonitor.on('suspend', () => { + console.debug('[main] System suspending'); + rendererHeartbeatWatchdog.clearResumeWatchdog(); + mqttManager.handlePowerSuspend(); + meshcoreMqttAdapter.handlePowerSuspend(); + mainWindow?.webContents.send('power:suspend'); + }); + powerMonitor.on('resume', () => { + console.debug('[main] System resumed'); + rendererHeartbeatWatchdog.startResumeWatchdog(); + mainWindow?.webContents.send('power:resume'); + }); + } catch (error) { + console.error( + '[main] Fatal startup error:', + sanitizeLogMessage(error instanceof Error ? (error.stack ?? error.message) : String(error)), ); - app.dock?.setIcon(iconPath); + const isNativeModuleError = + error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_DLOPEN_FAILED'; + const message = isDatabaseSchemaTooNewError(error) + ? formatDatabaseSchemaTooNewMessage(error) + : isNativeModuleError + ? `A native module failed to load. This usually means the app needs to be rebuilt for this version of Electron.\n\nFix: run "pnpm install" in the project directory, then restart.\n\nDetails: ${error.message}` + : `The application failed to start:\n\n${error instanceof Error ? error.message : String(error)}\n\nPlease report this issue.`; + showFatalStartupError('Mesh-Client — Startup Error', message); + app.quit(); + return; } - createWindow(); - - const MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS = 60 * 60 * 1000; - const MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC = 24 * 60 * 60; - setInterval(() => { - if (process.uptime() < MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC) return; - const uptimeSec = Math.floor(process.uptime()); - const mem = process.memoryUsage(); - const ble = nobleBleManager.getLongSessionHealthSnapshot(); - console.debug( - `[main] long-session health uptimeSec=${uptimeSec} rss=${mem.rss} heapUsed=${mem.heapUsed} ble=${JSON.stringify(ble)}`, - ); - }, MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS).unref(); - setupAppMenu(); - - // ─── Power monitor: notify renderer on suspend/resume ────────── - powerMonitor.on('suspend', () => { - console.debug('[main] System suspending'); - rendererHeartbeatWatchdog.clearResumeWatchdog(); - mqttManager.handlePowerSuspend(); - meshcoreMqttAdapter.handlePowerSuspend(); - mainWindow?.webContents.send('power:suspend'); - }); - powerMonitor.on('resume', () => { - console.debug('[main] System resumed'); - rendererHeartbeatWatchdog.startResumeWatchdog(); - mainWindow?.webContents.send('power:resume'); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + try { + createWindow(); + } catch (error) { + console.error( + '[main] Window creation error:', + sanitizeLogMessage(error instanceof Error ? error.message : String(error)), + ); + } + } else { + mainWindow?.show(); // Restore hidden window on dock click + } }); - } catch (error) { + }) + .catch((error: unknown) => { console.error( - '[main] Fatal startup error:', + '[main] app.whenReady failed:', sanitizeLogMessage(error instanceof Error ? (error.stack ?? error.message) : String(error)), ); - const isNativeModuleError = - error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_DLOPEN_FAILED'; - const message = isDatabaseSchemaTooNewError(error) - ? formatDatabaseSchemaTooNewMessage(error) - : isNativeModuleError - ? `A native module failed to load. This usually means the app needs to be rebuilt for this version of Electron.\n\nFix: run "pnpm install" in the project directory, then restart.\n\nDetails: ${error.message}` - : `The application failed to start:\n\n${error instanceof Error ? error.message : String(error)}\n\nPlease report this issue.`; - showFatalStartupError('Mesh-Client — Startup Error', message); + showFatalStartupError( + 'Mesh-Client — Startup Error', + `The application failed to start:\n\n${error instanceof Error ? error.message : String(error)}\n\nPlease report this issue.`, + ); app.quit(); - return; - } - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - try { - createWindow(); - } catch (error) { - console.error( - '[main] Window creation error:', - sanitizeLogMessage(error instanceof Error ? error.message : String(error)), - ); - } - } else { - mainWindow?.show(); // Restore hidden window on dock click - } }); -}); app.on('before-quit', (event) => { // Clean up any pending Bluetooth device selection to prevent callback leak diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 411a8964e..aa4d7bb50 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -1050,7 +1050,9 @@ export function ChatComposer({ if (isLinux) { setShowComposePicker((prev) => !prev); } else { - void window.electronAPI.showEmojiPanel(); + void window.electronAPI.showEmojiPanel().catch((e: unknown) => { + console.debug('[ChatComposer] showEmojiPanel failed ' + errLikeToLogString(e)); + }); } }} disabled={disabled || !isConnected} diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index a11fa8242..61be8907f 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -2852,7 +2852,14 @@ function ChatPanel({ } else { reactionPickerTarget.current = { id, channel: msg.channel }; reactionCapturePendingRef.current = true; - void window.electronAPI.showEmojiPanel(); + void window.electronAPI + .showEmojiPanel() + .catch((e: unknown) => { + console.debug( + '[ChatPanel] showEmojiPanel failed ' + + errLikeToLogString(e), + ); + }); } }} {...{ [PARENT_HOVER_ATTR]: '' }} diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 94b5bcfae..98779ef2d 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1242,7 +1242,9 @@ export default function ConnectionPanel({ setWebBluetoothDevice(null); } } else { - void window.electronAPI.stopNobleBleScanning(protocol); + void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { + console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); + }); } } if (showSerialPicker) { @@ -1319,7 +1321,9 @@ export default function ConnectionPanel({ // Don't call onConnect again - the original onConnect will continue from requestDevice() // and proceed to connect(), which triggers the pairing handler. } else { - void window.electronAPI.stopNobleBleScanning(protocol); + void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { + console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); + }); // Trigger the actual connection with the peripheral ID onConnect('ble', undefined, deviceId).catch((err: unknown) => { const errMsg = err instanceof Error ? err.message : String(err); diff --git a/src/renderer/components/LanguageSelector.tsx b/src/renderer/components/LanguageSelector.tsx index 8a258fb1c..7bac8c8b8 100644 --- a/src/renderer/components/LanguageSelector.tsx +++ b/src/renderer/components/LanguageSelector.tsx @@ -7,6 +7,7 @@ import { ICON_MD } from '@/renderer/lib/icons/iconClass'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { mergeAppSetting } from '../lib/appSettingsStorage'; +import { errLikeToLogString } from '../lib/errLikeToLogString'; import i18n from '../lib/i18n'; import { ensureLocaleLoaded } from '../lib/localeResources'; import { SUPPORTED_LANGUAGES } from '../locales/languages'; @@ -40,13 +41,18 @@ export default function LanguageSelector() { // Reconcile DB locale with current i18n locale on mount useEffect(() => { - void window.electronAPI.appSettings.getAll().then(async (settings) => { - const dbLocale = settings.locale; - if (dbLocale && dbLocale !== i18n.language) { - const ok = await ensureLocaleLoaded(i18n, dbLocale); - if (ok) await i18n.changeLanguage(dbLocale); + void (async () => { + try { + const settings = await window.electronAPI.appSettings.getAll(); + const dbLocale = settings.locale; + if (dbLocale && dbLocale !== i18n.language) { + const ok = await ensureLocaleLoaded(i18n, dbLocale); + if (ok) await i18n.changeLanguage(dbLocale); + } + } catch (e: unknown) { + console.warn('[LanguageSelector] locale reconcile failed ' + errLikeToLogString(e)); } - }); + })(); }, []); // Close dropdown on outside click (button + portaled menu) @@ -88,7 +94,9 @@ export default function LanguageSelector() { } await i18n.changeLanguage(code); mergeAppSetting('locale', code, 'LanguageSelector'); - void window.electronAPI.appSettings.set('locale', code); + void window.electronAPI.appSettings.set('locale', code).catch((e: unknown) => { + console.warn('[LanguageSelector] persist locale failed ' + errLikeToLogString(e)); + }); closeMenu(); })(); }; diff --git a/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx b/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx index a2c553559..6a4e8eee3 100644 --- a/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx +++ b/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { forgetMeshcoreRepeaterSavedSecret, getMeshcoreRepeaterSavedSecretsSummary, @@ -43,12 +44,18 @@ export function MeshcoreRepeaterPasswordControls({