Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MAC>`. 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`:
Expand Down
1 change: 1 addition & 0 deletions org.coloradomesh.MeshClient.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ build-options:

finish-args:
- --device=all
- --allow=bluetooth
- --share=network
- --share=ipc
- --socket=x11
Expand Down
8 changes: 8 additions & 0 deletions scripts/check-flatpak.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
28 changes: 25 additions & 3 deletions src/main/index.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\)/);
});
});

Expand Down
130 changes: 63 additions & 67 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -442,9 +446,10 @@ function clearPendingSerialSelectionTimer(): void {
// (empty string always allowed = cancel). Prevents arbitrary id injection from a compromised renderer.
let lastSerialPortIds = new Set<string>();

// Pending Web Bluetooth callback (Linux only — select-bluetooth-device on webContents)
let pendingBluetoothCallback: ((deviceId: string) => void) | null = null;
let lastBluetoothDeviceIds = new Set<string>();
// 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
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -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);
},
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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) ───────────────────────────
Expand Down Expand Up @@ -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) ──
Expand Down Expand Up @@ -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));
});
});
});
Expand Down Expand Up @@ -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));
});
});
});
Expand Down Expand Up @@ -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)));
});
});
});
Expand Down Expand Up @@ -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));
});
});
});
Expand Down Expand Up @@ -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);
});
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions src/main/index.window-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading