diff --git a/README.md b/README.md index 57eeccbaf..3e1280e5c 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,12 @@ flatpak run org.coloradomesh.MeshClient VMware guests and other GPU edge cases: [Flatpak troubleshooting](docs/troubleshooting.md#flatpak-vmwgfx-driver-missing-vmware-on-macos). +**Arch Linux (AUR, third-party):** community package [`mesh-client`](https://aur.archlinux.org/packages/mesh-client) (maintainer `victorix`) — **not** maintained by Colorado Mesh. Prefer [GitHub Releases](https://github.com/Colorado-Mesh/mesh-client/releases) AppImage / `.deb` / `.rpm` / Flatpak for official builds. Report packaging issues on the AUR package page; report app bugs on GitHub. + +```bash +yay -S mesh-client # or: paru -S mesh-client +``` + **macOS (release download):** - **Official [GitHub Releases](https://github.com/Colorado-Mesh/mesh-client/releases) (v5.22.0+):** macOS builds are **Developer ID signed and notarized**. Drag to **Applications** and open normally — you should **not** need `xattr` or Right-click → Open. diff --git a/docs/index.md b/docs/index.md index 8fda680f2..626758859 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,6 +45,8 @@ Key outcomes: Pre-built binaries are available in [GitHub Releases](https://github.com/Colorado-Mesh/mesh-client/releases). +Arch Linux users may also find a **third-party** AUR package ([`mesh-client`](https://aur.archlinux.org/packages/mesh-client)) — not maintained by Colorado Mesh; prefer GitHub Releases for official builds. + For development setup, scripts, test harness, and git hooks, see [Development Guide](development-environment.md). Also useful: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 507747651..620c1deef 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -422,6 +422,8 @@ flatpak run org.coloradomesh.MeshClient - The app uses Web Bluetooth (Chromium's built-in BLE API). You still need a working Bluetooth stack (`systemctl status bluetooth`). - Linux BLE uses the in-app Bluetooth picker (triggered from a button click); if no picker appears, restart the app and try Connect again. +- **Immediate "User cancelled the requestDevice() chooser"** on Connect (AppImage / `.deb` / `.rpm`) without dismissing a picker: Chromium multi-fires `select-bluetooth-device`; the app must retain the first callback. Upgrade to a build that includes that fix, then retry Connect. If the picker still never opens, check `systemctl status bluetooth` and `rfkill list`. +- **Flatpak:** Connect that fails with little or no UI often means the sandbox lacked `--allow=bluetooth` (needed with `--system-talk-name=org.bluez`). Reinstall a Flatpak from a release that includes that finish-arg. If pairing then fails with **bluetoothctl not found**, use the official AppImage/`.deb`/`.rpm`, or pair the radio on the host with `bluetoothctl` and retry. - If the Bluetooth adapter isn't detected, check: `systemctl status bluetooth` and `rfkill list`. - **MeshCore:** After you pick a radio, the app checks `bluetoothctl info `. If the device is **not** paired at the OS level, you are prompted for the **PIN shown on the device** and pairing runs via **`bluetooth-pair`** before Web Bluetooth finishes connecting. Meshtastic does not use this gate in the same way (it may use PIN `123456` on the first pairing prompt from Chromium). - If device pairing fails with "Connection attempt failed", try the **"Remove & Re-pair Device"** button in the app, or manually remove via `bluetoothctl`: diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index e5d973525..ee3c46fdd 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -16,6 +16,7 @@ build-options: finish-args: - --device=all + - --allow=bluetooth - --share=network - --share=ipc - --socket=x11 diff --git a/scripts/check-flatpak.mjs b/scripts/check-flatpak.mjs index 890400f7d..837e8b2bf 100644 --- a/scripts/check-flatpak.mjs +++ b/scripts/check-flatpak.mjs @@ -259,6 +259,14 @@ function checkManifestBranchAndElectronPayload(pkg) { }); } + if (!yaml.includes('--allow=bluetooth')) { + violations.push({ + file: rel, + message: + 'manifest finish-args must include --allow=bluetooth for Web Bluetooth (AF_BLUETOOTH)', + }); + } + return violations; } diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index a0edcd1f7..2fd751bb0 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -57,9 +57,31 @@ describe('Meshtastic MQTT waypoint IPC (source contract)', () => { describe('MQTT forwarder dropped-event logs (source contract)', () => { it('sanitizes dynamic MQTT fields when mainWindow is not ready', () => { - expect((INDEX_SOURCE.match(/sanitizeLogMessage\(s\)/g) ?? []).length).toBe(2); - expect((INDEX_SOURCE.match(/sanitizeLogMessage\(msg\)/g) ?? []).length).toBe(3); - expect((INDEX_SOURCE.match(/sanitizeLogMessage\(id\)/g) ?? []).length).toBe(2); + // Path-specific (not aggregate counts): each dropped-event branch sanitizes its payload. + expect(INDEX_SOURCE).toMatch( + /mqtt:status dropped \(mainWindow not ready\)',\s*sanitizeLogMessage\(s\)/, + ); + expect(INDEX_SOURCE).toMatch( + /mqtt:error dropped \(mainWindow not ready\)',\s*sanitizeLogMessage\(msg\)/, + ); + expect(INDEX_SOURCE).toMatch( + /mqtt:clientId dropped \(mainWindow not ready\)',\s*sanitizeLogMessage\(id\)/, + ); + expect(INDEX_SOURCE).toMatch( + /mqtt:status \(meshcore\) dropped \(mainWindow not ready\)',[\s\S]{0,40}sanitizeLogMessage\(s\)/, + ); + expect(INDEX_SOURCE).toMatch( + /mqtt:error \(meshcore\) dropped \(mainWindow not ready\)',[\s\S]{0,40}sanitizeLogMessage\(msg\)/, + ); + expect(INDEX_SOURCE).toMatch( + /mqtt:clientId \(meshcore\) dropped \(mainWindow not ready\)',[\s\S]{0,40}sanitizeLogMessage\(id\)/, + ); + }); + + it('sanitizes Linux bluetoothctl spawn-error log paths', () => { + expect(INDEX_SOURCE).toMatch(/bluetooth-unpair error:',\s*sanitizeLogMessage\(msg\)/); + expect(INDEX_SOURCE).toMatch(/bluetooth-start-scan error:',\s*sanitizeLogMessage\(msg\)/); + expect(INDEX_SOURCE).toMatch(/bluetooth-connect error:',\s*sanitizeLogMessage\(msg\)/); }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 93ef22f6b..6d638ef9c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,7 +47,7 @@ import { effectiveMessageTimestampMs } from '../shared/messageTimestampSkew'; import { sanitizeUnicodeReactionScalar } from '../shared/reactionEmoji'; import type { ReticulumSidecarStatus } from '../shared/reticulum-types'; import type { TAKServerStatus, TAKSettings } from '../shared/tak-types'; -import { MS_PER_MINUTE } from '../shared/timeConstants'; +import { MS_PER_MINUTE, MS_PER_SECOND } from '../shared/timeConstants'; import { bleCoexistenceCoordinator, type BlePeripheralOwner, @@ -111,6 +111,10 @@ import { registerReticulumIdentityIpcHandlers } from './ipc/reticulum-identity-h import { registerRrcDbIpcHandlers } from './ipc/rrc-db-handlers'; import { registerTakIpcHandlers } from './ipc/tak-handlers'; import { createIpcRateLimiter } from './ipcRateLimit'; +import { + formatBluetoothctlSpawnError, + linuxWebBluetoothDeviceSelection, +} from './linuxWebBluetoothDeviceSelection'; import { clearLogFile, exportLogTo, @@ -442,9 +446,10 @@ function clearPendingSerialSelectionTimer(): void { // (empty string always allowed = cancel). Prevents arbitrary id injection from a compromised renderer. let lastSerialPortIds = new Set(); -// Pending Web Bluetooth callback (Linux only — select-bluetooth-device on webContents) -let pendingBluetoothCallback: ((deviceId: string) => void) | null = null; -let lastBluetoothDeviceIds = new Set(); +// Linux Web Bluetooth device selection session: linuxWebBluetoothDeviceSelection +// (retain-first callback + device merge — see linuxWebBluetoothDeviceSelection.ts) +// MeshCore may need bluetoothctl pairing + PIN before resolving requestDevice(). +const BLUETOOTH_DEVICE_SELECTION_TIMEOUT_MS = 300 * MS_PER_SECOND; // Bluetooth pairing state (Linux only — setBluetoothPairingHandler) // Electron's Response type requires confirmed: boolean, pin is optional @@ -987,12 +992,14 @@ function validateMqttPublishWaypointArgs(args: unknown): void { validateOptionalPskBase64(a.pskBase64, 'mqtt:publishWaypoint'); } -// Enable Web Serial (experimental) -app.commandLine.appendSwitch('enable-blink-features', 'Serial'); - -// Enable Web Bluetooth on Linux (experimental - required for BLE on Linux) +// Enable Web Serial; on Linux also enable Web Bluetooth at the process level +// (per-webContents enableBlinkFeatures is not enough — Chromium gates WebBluetooth behind this switch). if (process.platform === 'linux') { + app.commandLine.appendSwitch('enable-blink-features', 'Serial,WebBluetooth'); + app.commandLine.appendSwitch('enable-features', 'WebBluetooth'); app.commandLine.appendSwitch('enable-experimental-web-platform-features'); +} else { + app.commandLine.appendSwitch('enable-blink-features', 'Serial'); } // ─── Icon Path Helper ────────────────────────────────────────────── @@ -1738,44 +1745,34 @@ function createWindow() { // On Linux, Electron does not show a native Bluetooth chooser. Instead it fires // select-bluetooth-device on the webContents. Without a handler the request is // immediately cancelled ("User cancelled the requestDevice() chooser."). - // We intercept, forward the device list to the renderer, and resolve the callback - // when the user picks a device (or cancels) via IPC. + // Chromium multi-fires this event with a new callback each time — retain the first + // via linuxWebBluetoothDeviceSelection and merge device lists (do not overwrite). mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => { event.preventDefault(); - const isNewRequest = !pendingBluetoothCallback; - pendingBluetoothCallback = callback; + const { isNewRequest, devices, generation } = + linuxWebBluetoothDeviceSelection.beginOrMergeDiscovery(deviceList, callback); if (isNewRequest) { - // MeshCore Linux may need bluetoothctl pairing + PIN before resolving requestDevice(); - // 60s was too short and left pendingBluetoothCallback null so selectBluetoothDevice was ignored. - const selectionStaleMs = 300_000; - setTimeout(() => { - if (pendingBluetoothCallback === callback) { + // 60s was too short and left the session empty so selectBluetoothDevice was ignored. + linuxWebBluetoothDeviceSelection.armStaleTimeout( + BLUETOOTH_DEVICE_SELECTION_TIMEOUT_MS, + () => { console.warn( - `[IPC] Bluetooth device selection stale after ${selectionStaleMs / 1000}s — auto-cancelling`, + `[IPC] Bluetooth device selection stale after ${BLUETOOTH_DEVICE_SELECTION_TIMEOUT_MS / MS_PER_SECOND}s — auto-cancelling`, ); - pendingBluetoothCallback(''); - pendingBluetoothCallback = null; - lastBluetoothDeviceIds.clear(); - } - }, selectionStaleMs); + }, + ); } console.debug(`[IPC] select-bluetooth-device: ${deviceList.length} device(s) found`); - lastBluetoothDeviceIds = new Set(deviceList.map((d) => d.deviceId)); if (!mainWindow || mainWindow.isDestroyed()) { console.warn('[IPC] select-bluetooth-device: mainWindow unavailable — cancelling selection'); - pendingBluetoothCallback?.(''); - pendingBluetoothCallback = null; - lastBluetoothDeviceIds.clear(); + linuxWebBluetoothDeviceSelection.cancelSelection(); return; } - mainWindow.webContents.send( - 'bluetooth-devices-discovered', - deviceList.map((d) => ({ deviceId: d.deviceId, deviceName: d.deviceName })), - ); + mainWindow.webContents.send('bluetooth-devices-discovered', devices, generation); }); // ─── Web Bluetooth: Pairing Handler (Linux) ─────────────────────────── @@ -2077,30 +2074,36 @@ ipcMain.on('serial-port-cancelled', () => { // ─── IPC: Bluetooth device selected by user (Linux Web Bluetooth) ──── ipcMain.on('bluetooth-device-selected', (_event, deviceId: unknown) => { - if (!pendingBluetoothCallback) { + if (!linuxWebBluetoothDeviceSelection.hasPendingSelection()) { console.warn( '[IPC] bluetooth-device-selected: no pending selection (ignored — may have timed out or already resolved)', ); return; } const id = typeof deviceId === 'string' ? deviceId : ''; - if (id !== '' && !lastBluetoothDeviceIds.has(id)) { + if (id !== '' && !linuxWebBluetoothDeviceSelection.knownDeviceIds().has(id)) { console.warn('[IPC] bluetooth-device-selected: ignoring unknown deviceId'); return; } console.debug('[IPC] bluetooth-device-selected:', sanitizeLogMessage(id || '(cancelled)')); - pendingBluetoothCallback(id); - pendingBluetoothCallback = null; - lastBluetoothDeviceIds.clear(); + if (!linuxWebBluetoothDeviceSelection.resolveSelection(id)) { + console.warn('[IPC] bluetooth-device-selected: resolve ignored'); + } }); // ─── IPC: Cancel Bluetooth selection ──────────────────────────────── -ipcMain.on('bluetooth-device-cancelled', () => { - if (pendingBluetoothCallback) { - pendingBluetoothCallback(''); // Empty string cancels the request - pendingBluetoothCallback = null; +// Optional generation: when provided, ignore delayed cancels from an earlier chooser. +// When omitted, force-cancel (pre-connect cleanup / legacy callers). +ipcMain.on('bluetooth-device-cancelled', (_event, generation: unknown) => { + if (typeof generation === 'number' && Number.isFinite(generation)) { + if (!linuxWebBluetoothDeviceSelection.cancelIfGeneration(generation)) { + console.debug( + '[IPC] bluetooth-device-cancelled: generation mismatch or no pending — ignored', + ); + } + return; } - lastBluetoothDeviceIds.clear(); + linuxWebBluetoothDeviceSelection.cancelSelection(); }); // ─── IPC: Unpair Bluetooth device (Linux only — bluetoothctl remove) ── @@ -2153,11 +2156,9 @@ ipcMain.handle('bluetooth-unpair', async (event, macAddress: unknown) => { if (settled) return; settled = true; clearTimeout(timer); - console.error( - '[IPC] bluetooth-unpair error:', - sanitizeLogMessage(err?.message ?? String(err)), - ); - reject(err); + const msg = formatBluetoothctlSpawnError(err); + console.error('[IPC] bluetooth-unpair error:', sanitizeLogMessage(msg)); + reject(new Error(msg)); }); }); }); @@ -2198,11 +2199,9 @@ ipcMain.handle('bluetooth-start-scan', async (event) => { if (settled) return; settled = true; clearTimeout(timer); - console.warn( - '[IPC] bluetooth-start-scan error:', - sanitizeLogMessage(err?.message ?? String(err)), - ); - reject(err); + const msg = formatBluetoothctlSpawnError(err); + console.warn('[IPC] bluetooth-start-scan error:', sanitizeLogMessage(msg)); + reject(new Error(msg)); }); }); }); @@ -2432,8 +2431,11 @@ ipcMain.handle('bluetooth-pair', async (event, macAddress: unknown, pin: unknown }); proc.on('error', (err) => { if (settled) return; - console.warn('[IPC] bluetooth-pair error:', sanitizeLogMessage(err?.message ?? String(err))); - finishReject(err instanceof Error ? err : new Error(String(err))); + console.warn( + '[IPC] bluetooth-pair error:', + sanitizeLogMessage(formatBluetoothctlSpawnError(err)), + ); + finishReject(new Error(formatBluetoothctlSpawnError(err))); }); }); }); @@ -2470,11 +2472,9 @@ ipcMain.handle('bluetooth-connect', async (event, macAddress: unknown) => { } }); proc.on('error', (err) => { - console.warn( - '[IPC] bluetooth-connect error:', - sanitizeLogMessage(err?.message ?? String(err)), - ); - reject(err); + const msg = formatBluetoothctlSpawnError(err); + console.warn('[IPC] bluetooth-connect error:', sanitizeLogMessage(msg)); + reject(new Error(msg)); }); }); }); @@ -2556,7 +2556,7 @@ ipcMain.handle('bluetooth-get-info', async (event, macAddress: unknown) => { finish(output); }); proc.on('error', (err) => { - const msg = err?.message ?? String(err); + const msg = formatBluetoothctlSpawnError(err); finish(msg); }); }); @@ -6644,11 +6644,9 @@ void app app.on('before-quit', (event) => { // Clean up any pending Bluetooth device selection to prevent callback leak - if (pendingBluetoothCallback) { + if (linuxWebBluetoothDeviceSelection.hasPendingSelection()) { console.debug('[main] before-quit: cleaning up pending Bluetooth callback'); - pendingBluetoothCallback(''); - pendingBluetoothCallback = null; - lastBluetoothDeviceIds.clear(); + linuxWebBluetoothDeviceSelection.cancelSelection(); } if (shutdownDone) { @@ -6759,11 +6757,9 @@ app.on('will-quit', (event) => { app.on('window-all-closed', () => { // Clean up any pending Bluetooth device selection to prevent callback leak - if (pendingBluetoothCallback) { + if (linuxWebBluetoothDeviceSelection.hasPendingSelection()) { console.debug('[main] window-all-closed: cleaning up pending Bluetooth callback'); - pendingBluetoothCallback(''); - pendingBluetoothCallback = null; - lastBluetoothDeviceIds.clear(); + linuxWebBluetoothDeviceSelection.cancelSelection(); } const hasConnection = isConnected || isAnyMqttConnected(); // On macOS: quit when user chose Quit, or when there's no connection (window closed with nothing to keep running for) diff --git a/src/main/index.window-lifecycle.test.ts b/src/main/index.window-lifecycle.test.ts index 921816aa7..8810774cf 100644 --- a/src/main/index.window-lifecycle.test.ts +++ b/src/main/index.window-lifecycle.test.ts @@ -69,6 +69,29 @@ describe('navigation and window-open security', () => { }); }); +describe('Linux Web Bluetooth device selection', () => { + it('imports and uses linuxWebBluetoothDeviceSelection for retain-first multi-fire', () => { + expect(INDEX_SOURCE).toContain("from './linuxWebBluetoothDeviceSelection'"); + expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.beginOrMergeDiscovery'); + expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.resolveSelection'); + expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.cancelSelection'); + expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.armStaleTimeout'); + expect(INDEX_SOURCE).toContain('linuxWebBluetoothDeviceSelection.cancelIfGeneration'); + // Must not overwrite pending callback on every select-bluetooth-device event + const handlerIdx = INDEX_SOURCE.indexOf("on('select-bluetooth-device'"); + expect(handlerIdx).toBeGreaterThan(-1); + const body = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1200); + expect(body).toContain('beginOrMergeDiscovery'); + expect(body).toContain('armStaleTimeout'); + expect(body).not.toMatch(/pendingBluetoothCallback\s*=\s*callback/); + }); + + it('enables WebBluetooth blink features on Linux', () => { + expect(INDEX_SOURCE).toContain("'Serial,WebBluetooth'"); + expect(INDEX_SOURCE).toContain("appendSwitch('enable-features', 'WebBluetooth')"); + }); +}); + // ─── Session permission handlers ───────────────────────────────────────────── describe('session permission handlers', () => { diff --git a/src/main/linuxWebBluetoothDeviceSelection.test.ts b/src/main/linuxWebBluetoothDeviceSelection.test.ts new file mode 100644 index 000000000..0972865b6 --- /dev/null +++ b/src/main/linuxWebBluetoothDeviceSelection.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + BLUETOOTHCTL_NOT_FOUND_MESSAGE, + formatBluetoothctlSpawnError, + LinuxWebBluetoothDeviceSelection, +} from './linuxWebBluetoothDeviceSelection'; + +describe('LinuxWebBluetoothDeviceSelection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores the first callback and seeds the device map', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const result = session.beginOrMergeDiscovery([{ deviceId: 'aa:bb', deviceName: 'Radio A' }], a); + expect(result.isNewRequest).toBe(true); + expect(result.generation).toBe(1); + expect(result.devices).toEqual([{ deviceId: 'aa:bb', deviceName: 'Radio A' }]); + expect(session.hasPendingSelection()).toBe(true); + expect(session.knownDeviceIds().has('aa:bb')).toBe(true); + }); + + it('retains the first callback on multi-fire and merges devices', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const b = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb', deviceName: 'A' }], a); + const second = session.beginOrMergeDiscovery([{ deviceId: 'cc:dd', deviceName: 'B' }], b); + expect(second.isNewRequest).toBe(false); + expect(second.generation).toBe(1); + expect(second.devices).toEqual([ + { deviceId: 'aa:bb', deviceName: 'A' }, + { deviceId: 'cc:dd', deviceName: 'B' }, + ]); + expect(session.resolveSelection('cc:dd')).toBe(true); + expect(a).toHaveBeenCalledTimes(1); + expect(a).toHaveBeenCalledWith('cc:dd'); + expect(b).not.toHaveBeenCalled(); + }); + + it('resolve with a known id calls the first callback once and clears', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], a); + expect(session.resolveSelection('aa:bb')).toBe(true); + expect(a).toHaveBeenCalledWith('aa:bb'); + expect(session.hasPendingSelection()).toBe(false); + expect(session.knownDeviceIds().size).toBe(0); + }); + + it('cancel calls the first callback with empty string and clears', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], a); + expect(session.cancelSelection()).toBe(true); + expect(a).toHaveBeenCalledWith(''); + expect(session.hasPendingSelection()).toBe(false); + }); + + it('starts a fresh session after clear', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const c = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], a); + session.cancelSelection(); + const next = session.beginOrMergeDiscovery([{ deviceId: 'ee:ff', deviceName: 'C' }], c); + expect(next.isNewRequest).toBe(true); + expect(next.generation).toBe(2); + expect(session.resolveSelection('ee:ff')).toBe(true); + expect(c).toHaveBeenCalledWith('ee:ff'); + expect(a).toHaveBeenCalledTimes(1); + expect(a).toHaveBeenCalledWith(''); + }); + + it('ignores unknown deviceId without clearing', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], a); + expect(session.resolveSelection('zz:zz')).toBe(false); + expect(a).not.toHaveBeenCalled(); + expect(session.hasPendingSelection()).toBe(true); + expect(session.resolveSelection('aa:bb')).toBe(true); + expect(a).toHaveBeenCalledWith('aa:bb'); + }); + + it('retains the first callback when a follow-up event has an empty device list', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const b = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb', deviceName: 'A' }], a); + const empty = session.beginOrMergeDiscovery([], b); + expect(empty.isNewRequest).toBe(false); + expect(empty.devices).toEqual([{ deviceId: 'aa:bb', deviceName: 'A' }]); + expect(session.cancelIfCallback(a)).toBe(true); + expect(a).toHaveBeenCalledWith(''); + expect(b).not.toHaveBeenCalled(); + }); + + it('cancelIfCallback only cancels when the retained callback matches', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const b = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], a); + expect(session.cancelIfCallback(b)).toBe(false); + expect(session.hasPendingSelection()).toBe(true); + expect(session.cancelIfCallback(a)).toBe(true); + expect(a).toHaveBeenCalledWith(''); + }); + + it('ignores a delayed cancel from an earlier chooser after a later Connect session starts', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const first = vi.fn(); + const second = vi.fn(); + const firstSession = session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], first); + expect(firstSession.generation).toBe(1); + + // Simulate handleConnect: cancel prior generation, then a new requestDevice() chooser. + expect(session.cancelIfGeneration(1)).toBe(true); + expect(first).toHaveBeenCalledWith(''); + + const next = session.beginOrMergeDiscovery([{ deviceId: 'cc:dd' }], second); + expect(next.generation).toBe(2); + expect(session.hasPendingSelection()).toBe(true); + + // Delayed cancel from the earlier Cancel / Connect still carries generation 1. + expect(session.cancelIfGeneration(1)).toBe(false); + expect(second).not.toHaveBeenCalled(); + expect(session.hasPendingSelection()).toBe(true); + expect(session.currentGeneration()).toBe(2); + }); + + it('armStaleTimeout auto-cancels and clears; resolve clears the timer without firing', () => { + vi.useFakeTimers(); + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const onStale = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], a); + session.armStaleTimeout(300_000, onStale); + expect(session.resolveSelection('aa:bb')).toBe(true); + vi.advanceTimersByTime(300_000); + expect(onStale).not.toHaveBeenCalled(); + expect(a).toHaveBeenCalledTimes(1); + expect(a).toHaveBeenCalledWith('aa:bb'); + }); + + it('armStaleTimeout fires cancel only for the active generation', () => { + vi.useFakeTimers(); + const session = new LinuxWebBluetoothDeviceSelection(); + const first = vi.fn(); + const second = vi.fn(); + const onStale = vi.fn(); + session.beginOrMergeDiscovery([{ deviceId: 'aa:bb' }], first); + session.armStaleTimeout(300_000, onStale); + session.cancelSelection(); + session.beginOrMergeDiscovery([{ deviceId: 'cc:dd' }], second); + session.armStaleTimeout(300_000, onStale); + vi.advanceTimersByTime(300_000); + expect(onStale).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenCalledWith(''); + expect(second).toHaveBeenCalledWith(''); + expect(session.hasPendingSelection()).toBe(false); + }); + + it('defaults missing device names to Unknown Device', () => { + const session = new LinuxWebBluetoothDeviceSelection(); + const a = vi.fn(); + const result = session.beginOrMergeDiscovery([{ deviceId: 'aa:bb', deviceName: null }], a); + expect(result.devices[0]?.deviceName).toBe('Unknown Device'); + }); +}); + +describe('formatBluetoothctlSpawnError', () => { + it('maps ENOENT to bluetoothctl not found', () => { + const err = Object.assign(new Error('spawn bluetoothctl ENOENT'), { code: 'ENOENT' }); + expect(formatBluetoothctlSpawnError(err)).toBe(BLUETOOTHCTL_NOT_FOUND_MESSAGE); + }); + + it('maps ENOENT-like messages without code', () => { + expect(formatBluetoothctlSpawnError(new Error('spawn bluetoothctl ENOENT'))).toBe( + BLUETOOTHCTL_NOT_FOUND_MESSAGE, + ); + }); + + it('passes through other errors', () => { + expect(formatBluetoothctlSpawnError(new Error('timed out'))).toBe('timed out'); + }); +}); diff --git a/src/main/linuxWebBluetoothDeviceSelection.ts b/src/main/linuxWebBluetoothDeviceSelection.ts new file mode 100644 index 000000000..7540ba74d --- /dev/null +++ b/src/main/linuxWebBluetoothDeviceSelection.ts @@ -0,0 +1,165 @@ +/** + * Linux Web Bluetooth device-selection session for Electron's select-bluetooth-device. + * + * Chromium fires the event repeatedly during discovery with a *new* callback each time. + * Overwriting the stored callback cancels the in-flight requestDevice() with + * "User cancelled the requestDevice() chooser." Retain the first callback and merge + * device lists until the user selects, cancels, or the session is cleared. + * + * Each new chooser bumps `generation` so a delayed cancel from an earlier Connect + * cannot tear down a later chooser. Stale-selection timers are owned here and cleared + * on resolve / cancel / shutdown. + */ + +export interface LinuxWebBluetoothDiscoveredDevice { + deviceId: string; + deviceName: string; +} + +export type LinuxWebBluetoothSelectCallback = (deviceId: string) => void; + +export class LinuxWebBluetoothDeviceSelection { + private pendingCallback: LinuxWebBluetoothSelectCallback | null = null; + private readonly devices = new Map(); + private generation = 0; + private selectionTimer: ReturnType | null = null; + + hasPendingSelection(): boolean { + return this.pendingCallback !== null; + } + + /** Monotonic chooser generation; 0 means no session has started yet. */ + currentGeneration(): number { + return this.generation; + } + + /** Device ids allowed for resolveSelection (accumulated this session). */ + knownDeviceIds(): ReadonlySet { + return new Set(this.devices.keys()); + } + + /** + * Start a session on the first event; on later events keep the first callback and merge devices. + * Returns the accumulated device list for the renderer picker. + */ + beginOrMergeDiscovery( + deviceList: readonly { deviceId: string; deviceName?: string | null }[], + callback: LinuxWebBluetoothSelectCallback, + ): { isNewRequest: boolean; devices: LinuxWebBluetoothDiscoveredDevice[]; generation: number } { + const isNewRequest = this.pendingCallback === null; + if (isNewRequest) { + this.clearTimer(); + this.generation += 1; + this.pendingCallback = callback; + this.devices.clear(); + } + for (const d of deviceList) { + const deviceId = d.deviceId; + if (!deviceId) continue; + this.devices.set(deviceId, { + deviceId, + deviceName: d.deviceName || 'Unknown Device', + }); + } + return { + isNewRequest, + devices: Array.from(this.devices.values()), + generation: this.generation, + }; + } + + /** + * Arm (or replace) the stale-selection auto-cancel timer for the current session. + * Cleared automatically on resolve / cancel / clear. + */ + armStaleTimeout(timeoutMs: number, onStale: () => void): void { + this.clearTimer(); + const callback = this.pendingCallback; + const generation = this.generation; + if (!callback) return; + this.selectionTimer = setTimeout(() => { + this.selectionTimer = null; + if (this.generation !== generation || this.pendingCallback !== callback) return; + if (this.cancelSelection()) { + onStale(); + } + }, timeoutMs); + } + + /** + * Resolve with a known device id (or empty string to cancel). + * Unknown non-empty ids are ignored (session stays open). + * @returns true if the pending callback was invoked + */ + resolveSelection(deviceId: string): boolean { + if (!this.pendingCallback) return false; + if (deviceId !== '' && !this.devices.has(deviceId)) return false; + const cb = this.pendingCallback; + this.clear(); + cb(deviceId); + return true; + } + + /** Cancel the pending requestDevice() chooser (callback with empty string). */ + cancelSelection(): boolean { + if (!this.pendingCallback) return false; + const cb = this.pendingCallback; + this.clear(); + cb(''); + return true; + } + + /** + * Cancel only if `generation` matches the active chooser (ignores delayed cancels). + */ + cancelIfGeneration(generation: number): boolean { + if (!this.pendingCallback) return false; + if (this.generation !== generation) return false; + return this.cancelSelection(); + } + + /** + * Auto-cancel only if `callback` is still the retained first callback (stale-timeout guard). + */ + cancelIfCallback(callback: LinuxWebBluetoothSelectCallback): boolean { + if (this.pendingCallback !== callback) return false; + return this.cancelSelection(); + } + + clear(): void { + this.clearTimer(); + this.pendingCallback = null; + this.devices.clear(); + } + + private clearTimer(): void { + if (this.selectionTimer) { + clearTimeout(this.selectionTimer); + this.selectionTimer = null; + } + } +} + +/** Process-wide session used by main-process Web Bluetooth IPC. */ +export const linuxWebBluetoothDeviceSelection = new LinuxWebBluetoothDeviceSelection(); + +/** Stable message for spawn ENOENT when bluetoothctl is missing (Flatpak / minimal hosts). */ +export const BLUETOOTHCTL_NOT_FOUND_MESSAGE = 'bluetoothctl not found'; + +export function formatBluetoothctlSpawnError(err: unknown): string { + if ( + err && + typeof err === 'object' && + 'code' in err && + (err as { code?: string }).code === 'ENOENT' + ) { + return BLUETOOTHCTL_NOT_FOUND_MESSAGE; + } + if (err instanceof Error) { + if (/ENOENT|spawn bluetoothctl/i.test(err.message)) { + return BLUETOOTHCTL_NOT_FOUND_MESSAGE; + } + return err.message; + } + return String(err); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a4bc93d3..534ff0b16 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -717,9 +717,11 @@ contextBridge.exposeInMainWorld('electronAPI', { // ─── Bluetooth device selection (Linux Web Bluetooth) ────────────── // Main process intercepts select-bluetooth-device and sends the device // list here. Renderer shows a picker, then calls selectBluetoothDevice. - onBluetoothDevicesDiscovered: (callback: (devices: NobleBleDevice[]) => void) => { - const handler = (_event: unknown, devices: NobleBleDevice[]) => { - callback(devices); + onBluetoothDevicesDiscovered: ( + callback: (devices: NobleBleDevice[], generation?: number) => void, + ) => { + const handler = (_event: unknown, devices: NobleBleDevice[], generation?: number) => { + callback(devices, generation); }; ipcRenderer.on('bluetooth-devices-discovered', handler); return () => { @@ -731,7 +733,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.send('bluetooth-device-selected', deviceId); }, - cancelBluetoothSelection: () => { + cancelBluetoothSelection: (generation?: number | null) => { + if (typeof generation === 'number' && Number.isFinite(generation)) { + ipcRenderer.send('bluetooth-device-cancelled', generation); + return; + } ipcRenderer.send('bluetooth-device-cancelled'); }, diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index c9642cd27..d1b665036 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -635,6 +635,65 @@ describe('ConnectionPanel Linux BLE path', () => { expect(onConnect).toHaveBeenCalledWith('ble', undefined); expect(window.electronAPI.startNobleBleScanning).not.toHaveBeenCalled(); + expect(window.electronAPI.cancelBluetoothSelection).toHaveBeenCalled(); + const cancelOrder = vi.mocked(window.electronAPI.cancelBluetoothSelection).mock + .invocationCallOrder[0]; + const connectOrder = onConnect.mock.invocationCallOrder[0]; + expect(cancelOrder).toBeDefined(); + expect(connectOrder).toBeDefined(); + expect(cancelOrder).toBeLessThan(connectOrder); + userAgentSpy.mockRestore(); + }); + + it('passes Linux BLE chooser generation to cancelBluetoothSelection on Cancel', async () => { + const user = userEvent.setup(); + vi.mocked(window.electronAPI.cancelBluetoothSelection).mockClear(); + const userAgentSpy = vi.spyOn(window.navigator, 'userAgent', 'get'); + userAgentSpy.mockReturnValue( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36', + ); + + const discovered = { + cb: null as + null | ((devices: { deviceId: string; deviceName: string }[], generation?: number) => void), + }; + vi.mocked(window.electronAPI.onBluetoothDevicesDiscovered).mockImplementation((cb) => { + discovered.cb = cb; + return () => {}; + }); + + const onConnect = vi.fn().mockImplementation( + () => + new Promise(() => { + /* leave connecting so Cancel stays available */ + }), + ); + + render( + , + ); + + const radioCard = screen.getByText('Radio Connection').closest('.bg-deep-black'); + expect(radioCard).toBeTruthy(); + await user.click(within(radioCard as HTMLElement).getByRole('button', { name: 'Connect' })); + expect(onConnect).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(discovered.cb).toBeTruthy(); + }); + discovered.cb?.([{ deviceId: 'aa:bb:cc:dd:ee:ff', deviceName: 'Node' }], 3); + + const cancelBtn = await screen.findByRole('button', { name: /^Cancel$/i }); + await user.click(cancelBtn); + + expect(window.electronAPI.cancelBluetoothSelection).toHaveBeenCalledWith(3); userAgentSpy.mockRestore(); }); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 98779ef2d..9aeeb5466 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -700,6 +700,8 @@ export default function ConnectionPanel({ const pendingMeshcoreLinuxWbMacRef = useRef(null); /** Linux Web Bluetooth: after the user picks a device, discovery must not reopen the embedded picker. */ const bleLinuxPickerSelectionResolvedRef = useRef(false); + /** Linux Web Bluetooth chooser generation from main — scopes cancelBluetoothSelection. */ + const linuxBleChooserGenerationRef = useRef(null); /** MeshCore Linux reconnect: dedupe concurrent bluetoothGetInfo checks from repeated discovery events. */ const meshcoreLinuxReconnectPairingCheckRef = useRef(false); const lastConnectionBleDeviceNameFallbackRef = useRef(lastConnection?.bleDeviceName); @@ -854,7 +856,10 @@ export default function ConnectionPanel({ // Listen for Bluetooth devices discovered by main process (Linux Web Bluetooth) useEffect(() => { - return window.electronAPI.onBluetoothDevicesDiscovered((devices) => { + return window.electronAPI.onBluetoothDevicesDiscovered((devices, generation) => { + if (typeof generation === 'number' && Number.isFinite(generation)) { + linuxBleChooserGenerationRef.current = generation; + } setBleDevices(devices); const lastId = lastConnectionRef.current?.bleDeviceId ?? loadLastBleDevice(protocol); if ( @@ -1082,7 +1087,9 @@ export default function ConnectionPanel({ if (pendingMeshcoreLinuxWbMacRef.current) { pendingMeshcoreLinuxWbMacRef.current = null; bleLinuxPickerSelectionResolvedRef.current = false; - window.electronAPI.cancelBluetoothSelection(); + const generation = linuxBleChooserGenerationRef.current; + linuxBleChooserGenerationRef.current = null; + window.electronAPI.cancelBluetoothSelection(generation); setShowPinPrompt(false); setPinInputValue(''); setConnecting(false); @@ -1145,6 +1152,15 @@ export default function ConnectionPanel({ // Same-tick IPC: select-bluetooth-device can fire before React commits connectionType; // discovery uses connectionTypeRef for shouldShowEmbeddedPicker. connectionTypeRef.current = 'ble'; + // Clear any stale Chromium chooser session before a new requestDevice(). + // Pass the prior generation when known so a delayed cancel cannot hit the next chooser; + // omit generation only when we have no tracked session (force-clear orphans). + const priorGeneration = linuxBleChooserGenerationRef.current; + linuxBleChooserGenerationRef.current = null; + window.electronAPI.cancelBluetoothSelection(priorGeneration); + pendingMeshcoreLinuxWbMacRef.current = null; + bleLinuxPickerSelectionResolvedRef.current = false; + setShowBlePicker(false); try { console.debug('[ConnectionPanel] handleConnect calling onConnect'); await onConnect('ble', undefined); @@ -1233,7 +1249,9 @@ export default function ConnectionPanel({ if (isLinux) { if (showBlePicker || pendingMeshcoreLinuxWbMacRef.current) { // Cancel in-flight requestDevice() (picker or MeshCore pre-connect PIN gate) - window.electronAPI.cancelBluetoothSelection(); + const generation = linuxBleChooserGenerationRef.current; + linuxBleChooserGenerationRef.current = null; + window.electronAPI.cancelBluetoothSelection(generation); } pendingMeshcoreLinuxWbMacRef.current = null; setShowPinPrompt(false); diff --git a/src/renderer/lib/connectionPanelErrorHumanize.test.ts b/src/renderer/lib/connectionPanelErrorHumanize.test.ts index 154432a16..c4d76b3a0 100644 --- a/src/renderer/lib/connectionPanelErrorHumanize.test.ts +++ b/src/renderer/lib/connectionPanelErrorHumanize.test.ts @@ -243,6 +243,20 @@ describe('humanizeBleError', () => { expect(result).toContain('macWakeRecoveryHint'); expect(result.split('macWakeRecoveryHint').length - 1).toBe(1); }); + + it('humanizes requestDevice chooser cancel with a non-empty hint', () => { + mockPlatform('linux'); + const result = humanizeBleError(new Error('User cancelled the requestDevice() chooser.'), t); + expect(result).not.toBe(''); + expect(result).toContain('chooserCancelledHint'); + }); + + it('humanizes missing bluetoothctl with a non-empty hint', () => { + mockPlatform('linux'); + const result = humanizeBleError(new Error('bluetoothctl not found'), t); + expect(result).not.toBe(''); + expect(result).toContain('bluetoothctlMissingHint'); + }); }); describe('humanizeReticulumSidecarError', () => { diff --git a/src/renderer/lib/connectionPanelErrorHumanize.ts b/src/renderer/lib/connectionPanelErrorHumanize.ts index 6941a2de4..accb1f843 100644 --- a/src/renderer/lib/connectionPanelErrorHumanize.ts +++ b/src/renderer/lib/connectionPanelErrorHumanize.ts @@ -160,6 +160,18 @@ export function humanizeBleError(err: unknown, t: TFunction): string { : t('connectionPanel.humanize.ble.adapterGenericHint'); return t('connectionPanel.humanize.prefixedHint', { message: msg, hint }); } + if (/User cancelled the requestDevice\(\) chooser/i.test(msg)) { + return t('connectionPanel.humanize.prefixedHint', { + message: msg, + hint: t('connectionPanel.humanize.ble.chooserCancelledHint'), + }); + } + if (/bluetoothctl not found/i.test(msg)) { + return t('connectionPanel.humanize.prefixedHint', { + message: msg, + hint: t('connectionPanel.humanize.ble.bluetoothctlMissingHint'), + }); + } if (msg.includes('SecurityError') || msg.includes('not allowed to access')) { return t('connectionPanel.humanize.prefixedHint', { message: msg, diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 37c03e7b9..32841d52a 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -866,7 +866,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "Druhý protokol se může stále připojovat přes Bluetooth. Počkejte několik sekund a zkuste to znovu, nebo přepněte na kartu druhého protokolu a zkontrolujte jeho stav připojení.", "scanBusy": "Probíhá další skenování Bluetooth. Zkuste to znovu za chvíli.", - "sameDeviceConflict": "Toto zařízení Bluetooth je již připojeno k {{owner}}." + "sameDeviceConflict": "Toto zařízení Bluetooth je již připojeno k {{owner}}.", + "chooserCancelledHint": "Pokud jste to nezrušili, ujistěte se, že je Bluetooth zapnuto (systemctl status bluetooth), potom znovu klepněte na Připojit. Na Flatpak přeinstalujte sestavení s povoleným Bluetooth nebo použijte AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Nástroje pro párování OS Bluetooth nejsou v tomto prostředí dostupné (běžné ve Flatpak). Použijte oficiální AppImage, .deb nebo .rpm, nebo spárujte rádio s hostitelským bluetoothctl a poté znovu zkuste Connect." } }, "error": { diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index eafa1a885..cbaf173cf 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -863,7 +863,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "Das andere Protokoll verbindet sich möglicherweise immer noch über Bluetooth. Warten Sie ein paar Sekunden und versuchen Sie es erneut, oder wechseln Sie zur anderen Protokollregisterkarte, um den Verbindungsstatus zu überprüfen.", "scanBusy": "Ein weiterer Bluetooth-Scan wird durchgeführt. Versuchen Sie es gleich noch einmal.", - "sameDeviceConflict": "Dieses Bluetooth-Gerät ist bereits mit {{owner}} verbunden." + "sameDeviceConflict": "Dieses Bluetooth-Gerät ist bereits mit {{owner}} verbunden.", + "chooserCancelledHint": "Wenn Sie nicht abgebrochen haben, stellen Sie sicher, dass Bluetooth aktiviert ist (systemctl status bluetooth), und tippen Sie dann erneut auf „Verbinden“. Installieren Sie auf Flatpak einen Build mit erlaubtem Bluetooth neu oder verwenden Sie AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "OS-Bluetooth-Pairing-Tools sind in dieser Umgebung nicht verfügbar (üblich in Flatpak). Verwenden Sie das offizielle AppImage, .deb oder .rpm, oder koppeln Sie das Radio mit dem Host-Bluetoothctl und versuchen Sie dann erneut, Connect durchzuführen." } }, "error": { diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 24287d448..61b31badf 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1255,7 +1255,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "The other protocol may still be connecting over Bluetooth. Wait a few seconds and try again, or switch to the other protocol tab to check its connection status.", "scanBusy": "Another Bluetooth scan is in progress. Try again in a moment.", - "sameDeviceConflict": "This Bluetooth device is already connected to {{owner}}." + "sameDeviceConflict": "This Bluetooth device is already connected to {{owner}}.", + "chooserCancelledHint": " If you did not cancel, ensure Bluetooth is on (systemctl status bluetooth), then tap Connect again. On Flatpak, reinstall a build with Bluetooth allowed, or use the AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": " OS Bluetooth pairing tools are unavailable in this environment (common in Flatpak). Use the official AppImage, .deb, or .rpm, or pair the radio with host bluetoothctl, then retry Connect." } }, "error": { diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index caebb1ea1..f33bb35cd 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "Es posible que el otro protocolo aún se esté conectando a través de Bluetooth. Espere unos segundos e inténtelo de nuevo, o cambie a la otra pestaña de protocolo para comprobar su estado de conexión.", "scanBusy": "Se está realizando otra exploración de Bluetooth. Inténtalo de nuevo en un momento.", - "sameDeviceConflict": "Este dispositivo Bluetooth ya está conectado a {{owner}}." + "sameDeviceConflict": "Este dispositivo Bluetooth ya está conectado a {{owner}}.", + "chooserCancelledHint": "Si no canceló, asegúrese de que Bluetooth esté activado (estado de systemctl bluetooth), luego toque Conectar nuevamente. En Flatpak, reinstale una compilación con Bluetooth permitido o use AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Las herramientas de emparejamiento Bluetooth del sistema operativo no están disponibles en este entorno (común en Flatpak). Utilice la AppImage, .deb o .rpm oficial, o empareje la radio con el host bluetoothctl y luego vuelva a intentar conectarse." } }, "error": { diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 91cdd051f..9f6245761 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -864,7 +864,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "L'autre protocole peut toujours se connecter via Bluetooth. Attendez quelques secondes et réessayez, ou passez à l'autre onglet de protocole pour vérifier son état de connexion.", "scanBusy": "Une autre analyse Bluetooth est en cours. Réessayez dans un instant.", - "sameDeviceConflict": "Cet appareil Bluetooth est déjà connecté à {{owner}}." + "sameDeviceConflict": "Cet appareil Bluetooth est déjà connecté à {{owner}}.", + "chooserCancelledHint": "Si vous n'avez pas annulé, assurez-vous que Bluetooth est activé (état du système Bluetooth), puis appuyez à nouveau sur Connecter. Sur Flatpak, réinstallez une version avec Bluetooth autorisé ou utilisez AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Les outils de couplage Bluetooth du système d'exploitation ne sont pas disponibles dans cet environnement (courant dans Flatpak). Utilisez l'AppImage officielle, .deb ou .rpm, ou associez la radio à l'hôte bluetoothctl, puis réessayez de vous connecter." } }, "error": { diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 91304c0df..f3024c290 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "Setelah tidur, keluar dari mesh-client (Cmd+Q), matikan dan nyalakan Bluetooth di Pengaturan Sistem, tunggu beberapa detik, lalu Hubungkan lagi. Dengan dua radio, hubungkan MeshCore sebelum Meshtastic.", "dualProtocolContentionHint": "Protokol lain mungkin masih terhubung melalui Bluetooth. Tunggu beberapa detik dan coba lagi, atau beralih ke tab protokol lain untuk memeriksa status koneksinya.", "scanBusy": "Pemindaian Bluetooth lainnya sedang berlangsung. Coba lagi sebentar lagi.", - "sameDeviceConflict": "Perangkat Bluetooth ini sudah terhubung ke {{owner}}." + "sameDeviceConflict": "Perangkat Bluetooth ini sudah terhubung ke {{owner}}.", + "chooserCancelledHint": "Jika Anda tidak membatalkan, pastikan Bluetooth aktif (status sistemctl bluetooth), lalu ketuk Hubungkan lagi. Di Flatpak, instal ulang build dengan Bluetooth yang diizinkan, atau gunakan AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Alat pemasangan OS Bluetooth tidak tersedia di lingkungan ini (umum di Flatpak). Gunakan AppImage resmi, .deb, atau .rpm, atau pasangkan radio dengan host bluetoothctl, lalu coba lagi Hubungkan." } }, "error": { diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index d24f63d03..3dbc1f10e 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -863,7 +863,9 @@ "macWakeRecoveryHint": "Dopo il sonno, uscire da mesh-client (Cmd+Q), attivare e disattivare il Bluetooth in Impostazioni di sistema, attendere alcuni secondi, quindi riconnettersi. Con due radio, collega MeshCore prima di Meshtastic.", "dualProtocolContentionHint": "L'altro protocollo potrebbe essere ancora connesso tramite Bluetooth. Attendi qualche secondo e riprova, oppure passa all'altra scheda del protocollo per verificare lo stato della connessione.", "scanBusy": "È in corso un'altra scansione Bluetooth. Riprova tra un attimo.", - "sameDeviceConflict": "Questo dispositivo Bluetooth è già connesso a {{owner}}." + "sameDeviceConflict": "Questo dispositivo Bluetooth è già connesso a {{owner}}.", + "chooserCancelledHint": "Se non hai annullato, assicurati che il Bluetooth sia attivo (systemctl status bluetooth), quindi tocca di nuovo Connetti. Su Flatpak, reinstallare una build con Bluetooth consentito o utilizzare AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Gli strumenti di accoppiamento Bluetooth del sistema operativo non sono disponibili in questo ambiente (comuni in Flatpak). Utilizza l'AppImage ufficiale, .deb o .rpm oppure accoppia la radio con l'host bluetoothctl, quindi riprova a connetterti." } }, "error": { diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 42aa62cb8..5257d6bd8 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "他のプロトコルはまだBluetooth経由で接続している可能性があります。数秒待ってからもう一度お試しいただくか、他のプロトコルタブに切り替えて接続ステータスを確認してください。", "scanBusy": "別の Bluetooth スキャンが進行中です。しばらくしてからもう一度試してください。", - "sameDeviceConflict": "この Bluetooth デバイスはすでに {{owner}} に接続されています。" + "sameDeviceConflict": "この Bluetooth デバイスはすでに {{owner}} に接続されています。", + "chooserCancelledHint": "キャンセルしなかった場合は、Bluetooth がオンになっていることを確認し (systemctl status bluetooth)、もう一度「接続」をタップします。 Flatpak では、Bluetooth を許可してビルドを再インストールするか、AppImage/.deb/.rpm を使用します。", + "bluetoothctlMissingHint": "この環境では、OS Bluetooth ペアリング ツールを利用できません (Flatpak で一般的)。公式の AppImage、.deb、または .rpm を使用するか、無線をホスト bluetoothctl とペアリングしてから、接続を再試行します。" } }, "error": { diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 95decf1ce..82ca2bf01 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "절전 모드가 끝나면 mesh-client (Cmd + Q) 를 종료하고 시스템 설정에서 블루투스를 껐다 켜고 몇 초 기다린 다음 다시 연결하십시오. 두 개의 라디오를 사용하여 Meshtastic 전에 MeshCore를 연결합니다.", "dualProtocolContentionHint": "다른 프로토콜은 여전히 블루투스를 통해 연결될 수 있습니다. 몇 초 후 다시 시도하거나 다른 프로토콜 탭으로 전환하여 연결 상태를 확인하세요.", "scanBusy": "다른 Bluetooth 검색이 진행 중입니다. 잠시 후에 다시 시도해 보세요.", - "sameDeviceConflict": "이 블루투스 장치는 이미 {{owner}}에 연결되어 있습니다." + "sameDeviceConflict": "이 블루투스 장치는 이미 {{owner}}에 연결되어 있습니다.", + "chooserCancelledHint": "취소하지 않은 경우 Bluetooth가 켜져 있는지(systemctl status bluetooth) 확인한 다음 연결을 다시 탭하세요. Flatpak에서는 Bluetooth가 허용된 빌드를 다시 설치하거나 AppImage/.deb/.rpm을 사용하세요.", + "bluetoothctlMissingHint": "이 환경에서는 OS Bluetooth 페어링 도구를 사용할 수 없습니다(Flatpak에서 일반적임). 공식 AppImage, .deb 또는 .rpm을 사용하거나 라디오를 호스트 bluetoothctl과 페어링한 다음 연결을 다시 시도하세요." } }, "error": { diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 75d9e7b0a..fab66a0f4 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -864,7 +864,9 @@ "macWakeRecoveryHint": "Sluit na de slaapstand de mesh-client (Cmd+Q), schakel Bluetooth uit en in in Systeeminstellingen, wacht een paar seconden en maak vervolgens opnieuw verbinding. Verbind MeshCore met twee radio's vóór Meshtastic.", "dualProtocolContentionHint": "Het andere protocol maakt mogelijk nog steeds verbinding via Bluetooth. Wacht een paar seconden en probeer het opnieuw, of schakel over naar het andere protocoltabblad om de verbindingsstatus te controleren.", "scanBusy": "Er wordt een nieuwe Bluetooth-scan uitgevoerd. Probeer het zo nog eens.", - "sameDeviceConflict": "Dit Bluetooth-apparaat is al verbonden met {{owner}}." + "sameDeviceConflict": "Dit Bluetooth-apparaat is al verbonden met {{owner}}.", + "chooserCancelledHint": "Als u niet hebt geannuleerd, zorg er dan voor dat Bluetooth is ingeschakeld (systemctl-status bluetooth) en tik vervolgens opnieuw op Verbinden. Installeer op Flatpak een build opnieuw waarbij Bluetooth is toegestaan, of gebruik AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "OS Bluetooth-koppelingstools zijn niet beschikbaar in deze omgeving (gebruikelijk in Flatpak). Gebruik de officiële AppImage, .deb of .rpm, of koppel de radio met host bluetoothctl en probeer Connect opnieuw." } }, "error": { diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index ce1abe4da..7bb6c4b72 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -867,7 +867,9 @@ "macWakeRecoveryHint": "Po uśpieniu zamknij mesh-client (Cmd+Q), wyłącz i włącz Bluetooth w ustawieniach systemu, odczekaj kilka sekund, a następnie połącz się ponownie. Za pomocą dwóch radiotelefonów podłącz MeshCore przed Meshtastic.", "dualProtocolContentionHint": "Drugi protokół może nadal łączyć się przez Bluetooth. Odczekaj kilka sekund i spróbuj ponownie lub przejdź do karty innego protokołu, aby sprawdzić stan połączenia.", "scanBusy": "Trwa kolejne skanowanie Bluetooth. Spróbuj ponownie za chwilę.", - "sameDeviceConflict": "To urządzenie Bluetooth jest już połączone z {{owner}}." + "sameDeviceConflict": "To urządzenie Bluetooth jest już połączone z {{owner}}.", + "chooserCancelledHint": "Jeśli nie anulowałeś, upewnij się, że Bluetooth jest włączony (systemctl status bluetooth), a następnie ponownie dotknij Połącz. W Flatpak zainstaluj ponownie kompilację z zezwoleniem na Bluetooth lub użyj AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Narzędzia do parowania systemu operacyjnego Bluetooth są niedostępne w tym środowisku (powszechne w Flatpak). Użyj oficjalnego AppImage, .deb lub .rpm lub sparuj radio z hostem bluetoothctl, a następnie spróbuj połączyć się ponownie." } }, "error": { diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 536a5f59d..bf9bf2941 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "Após a suspensão, saia do mesh-client (Cmd+Q), desligue e ligue o Bluetooth nas Configurações do Sistema, aguarde alguns segundos e conecte-se novamente. Com dois rádios, conecte o MeshCore antes do Meshtastic.", "dualProtocolContentionHint": "O outro protocolo ainda pode estar se conectando via Bluetooth. Aguarde alguns segundos e tente novamente ou mude para a outra guia de protocolo para verificar o status da conexão.", "scanBusy": "Outra varredura de Bluetooth está em andamento. Tente novamente em alguns instantes.", - "sameDeviceConflict": "Este dispositivo Bluetooth já está conectado a {{owner}}." + "sameDeviceConflict": "Este dispositivo Bluetooth já está conectado a {{owner}}.", + "chooserCancelledHint": "Se você não cancelou, certifique-se de que o Bluetooth esteja ativado (systemctl status bluetooth) e toque em Conectar novamente. No Flatpak, reinstale uma compilação com Bluetooth permitido ou use AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "As ferramentas de emparelhamento Bluetooth do sistema operacional não estão disponíveis neste ambiente (comum no Flatpak). Use o AppImage, .deb ou .rpm oficial ou emparelhe o rádio com o host bluetoothctl e tente novamente o Connect." } }, "error": { diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index dcca8baaa..1da7b5525 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -867,7 +867,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "Другой протокол все еще может подключаться по Bluetooth. Подождите несколько секунд и повторите попытку или перейдите на вкладку другого протокола, чтобы проверить статус подключения.", "scanBusy": "Выполняется еще одно сканирование Bluetooth. Повторите попытку через минуту.", - "sameDeviceConflict": "Это устройство Bluetooth уже подключено к {{owner}}." + "sameDeviceConflict": "Это устройство Bluetooth уже подключено к {{owner}}.", + "chooserCancelledHint": "Если вы не отменили отмену, убедитесь, что Bluetooth включен (состояние systemctl bluetooth), затем снова нажмите «Подключиться». В Flatpak переустановите сборку с разрешенным Bluetooth или используйте AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Инструменты сопряжения Bluetooth с ОС недоступны в этой среде (распространены в Flatpak). Используйте официальный AppImage, .deb или .rpm или подключите радиомодуль к хосту bluetoothctl, а затем повторите попытку подключения." } }, "error": { diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 1edf7fb49..57e57338a 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "Uykudan sonra mesh-client'ten çıkın (Cmd+Q), Sistem Ayarlarında Bluetooth'u kapatıp açın, birkaç saniye bekleyin ve ardından tekrar bağlanın. İki radyoyla MeshCore'u Meshtastic'ten önce bağlayın.", "dualProtocolContentionHint": "Diğer protokol hala Bluetooth üzerinden bağlanıyor olabilir. Birkaç saniye bekleyin ve tekrar deneyin veya bağlantı durumunu kontrol etmek için diğer protokol sekmesine geçin.", "scanBusy": "Başka bir Bluetooth taraması sürüyor. Birazdan tekrar deneyin.", - "sameDeviceConflict": "Bu Bluetooth cihazı zaten {{owner}}'a bağlı." + "sameDeviceConflict": "Bu Bluetooth cihazı zaten {{owner}}'a bağlı.", + "chooserCancelledHint": "İptal etmediyseniz Bluetooth'un açık olduğundan emin olun (systemctl durumu bluetooth) ve ardından tekrar Bağlan'a dokunun. Flatpak'te, Bluetooth'a izin verilen bir yapıyı yeniden yükleyin veya AppImage/.deb/.rpm dosyasını kullanın.", + "bluetoothctlMissingHint": "İşletim Sistemi Bluetooth eşleştirme araçları bu ortamda kullanılamaz (Flatpak'ta yaygındır). Resmi AppImage, .deb veya .rpm'yi kullanın veya radyoyu ana bilgisayar bluetoothctl ile eşleştirin ve ardından Bağlanmayı yeniden deneyin." } }, "error": { diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 79a42b9eb..e70c0ae3d 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -867,7 +867,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "Можливо, інший протокол все ще з'єднується через Bluetooth. Зачекайте кілька секунд і повторіть спробу або перейдіть на вкладку іншого протоколу, щоб перевірити стан з'єднання.", "scanBusy": "Виконується ще одне сканування Bluetooth. Повторіть спробу за мить.", - "sameDeviceConflict": "Цей пристрій Bluetooth уже підключено до {{owner}}." + "sameDeviceConflict": "Цей пристрій Bluetooth уже підключено до {{owner}}.", + "chooserCancelledHint": "Якщо ви не скасовували, переконайтеся, що Bluetooth увімкнено (systemctl status bluetooth), а потім знову торкніться Connect. На Flatpak перевстановіть збірку з дозволом Bluetooth або скористайтеся AppImage/.deb/.rpm.", + "bluetoothctlMissingHint": "Інструменти сполучення Bluetooth ОС недоступні в цьому середовищі (звичайне у Flatpak). Скористайтеся офіційним AppImage, .deb або .rpm або з’єднайте радіостанцію з хостом bluetoothctl, а потім повторіть спробу Підключитися." } }, "error": { diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 7f1e5960c..a7b957d06 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -865,7 +865,9 @@ "macWakeRecoveryHint": "After sleep, quit mesh-client (Cmd+Q), toggle Bluetooth off and on in System Settings, wait a few seconds, then Connect again. With two radios, connect MeshCore before Meshtastic.", "dualProtocolContentionHint": "另一个协议可能仍在通过蓝牙连接。请等待几秒钟,然后重试,或切换到其他协议选项卡以检查其连接状态。", "scanBusy": "另一次蓝牙扫描正在进行中。稍后再试一次。", - "sameDeviceConflict": "该蓝牙设备已连接到 {{owner}}。" + "sameDeviceConflict": "该蓝牙设备已连接到 {{owner}}。", + "chooserCancelledHint": "如果您没有取消,请确保蓝牙已打开(systemctl status bluetooth),然后再次点击“连接”。在 Flatpak 上,重新安装允许蓝牙的版本,或使用 AppImage/.deb/.rpm。", + "bluetoothctlMissingHint": "操作系统蓝牙配对工具在此环境中不可用(在 Flatpak 中常见)。使用官方 AppImage、.deb 或 .rpm,或将无线电与主机 bluetoothctl 配对,然后重试连接。" } }, "error": { diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 2e15177b5..98f82aa9f 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -811,9 +811,12 @@ export interface ElectronAPI { cancelSerialSelection: () => void; // ─── Bluetooth device selection (Linux Web Bluetooth) ──────────────────────── - onBluetoothDevicesDiscovered: (callback: (devices: NobleBleDevice[]) => void) => () => void; + onBluetoothDevicesDiscovered: ( + callback: (devices: NobleBleDevice[], generation?: number) => void, + ) => () => void; selectBluetoothDevice: (deviceId: string) => void; - cancelBluetoothSelection: () => void; + /** Pass the chooser generation from onBluetoothDevicesDiscovered to ignore stale cancels. */ + cancelBluetoothSelection: (generation?: number | null) => void; // ─── Bluetooth pairing (Linux) ────────────────────────────────────────────── bluetoothUnpair: (macAddress: string) => Promise;