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
1 change: 1 addition & 0 deletions src/main/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ describe('app_settings table + message retention defaults (schema sync)', () =>
expect(INDEX_SOURCE).toContain('meshcoreMessageRetentionEnabled');
expect(INDEX_SOURCE).toContain('meshcoreMessageRetentionCount');
expect(INDEX_SOURCE).toContain('reduceMotion');
expect(INDEX_SOURCE).toContain('use24HourTime');
expect(INDEX_SOURCE).toContain('meshcoreRoomSync:');
expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:');
expect(INDEX_SOURCE).toContain('meshcoreRoomCredential:');
Expand Down
5 changes: 5 additions & 0 deletions src/main/index.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ describe('Persistent app settings IPC (source contract)', () => {
expect(INDEX_SOURCE).toMatch(/key not allowed/);
expect(INDEX_SOURCE).toContain("'meshtasticLastRfSelfNodeId'");
expect(INDEX_SOURCE).toContain("'meshcoreLastSelfNodeId'");
expect(INDEX_SOURCE).toContain("'use24HourTime'");
expect(INDEX_SOURCE).toContain('meshtasticRemoteAdminKey:');
expect(INDEX_SOURCE).toContain('meshcoreRoomSync:');
expect(INDEX_SOURCE).toContain('meshcoreRoomLastPost:');
Expand Down Expand Up @@ -309,6 +310,10 @@ describe('Reticulum sidecar IPC handlers (source contract)', () => {
expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:getStatus'");
expect(RETICULUM_HANDLERS_SOURCE).toContain("'reticulum:syncInterfaceIssueScope'");
expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyGet'");
expect(RETICULUM_HANDLERS_SOURCE).toContain('settleReticulumProxyFailure');
expect(RETICULUM_HANDLERS_SOURCE).toContain('reticulumProxyIpcErrorEnvelope');
expect(PRELOAD_SOURCE).toContain('unwrapReticulumProxy');
expect(PRELOAD_SOURCE).toContain('throwIfReticulumProxyIpcError');
expect(PRELOAD_SOURCE).toContain("'/api/v1/rrc/hubs'");
expect(PRELOAD_SOURCE).toContain('rrc:');
expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPost'");
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3524,6 +3524,7 @@ const APP_SETTINGS_ALLOWED_KEYS: ReadonlySet<string> = new Set([
'meshcoreLastSelfNodeId',
'storeForwardAutoFetchHistory',
'reduceMotion',
'use24HourTime',
'alwaysShowMessageActions',
'reticulumAutostart',
'reticulumRmapAnnounceIntervalMin',
Expand Down
49 changes: 46 additions & 3 deletions src/main/ipc/reticulum-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,54 @@ describe('registerReticulumIpcHandlers', () => {
expect(ensureManager).not.toHaveBeenCalled();
});

it('proxyGet rethrows failures from the manager', async () => {
manager.proxyGet.mockRejectedValueOnce(new Error('sidecar not running'));
it('proxyGet returns a soft-failure envelope for expected sidecar-down races', async () => {
manager.proxyGet.mockRejectedValueOnce(new Error('Reticulum sidecar is not running'));
const result = await handlers.get('reticulum:proxyGet')?.(event, '/api/v1/diagnostics');
expect(result).toEqual({
__reticulumProxyError: true,
message: 'Reticulum sidecar is not running',
});
});

it('proxyGet rethrows unexpected manager failures', async () => {
manager.proxyGet.mockRejectedValueOnce(new Error('EACCES permission denied'));
await expect(
handlers.get('reticulum:proxyGet')?.(event, '/api/v1/diagnostics'),
).rejects.toThrow('sidecar not running');
).rejects.toThrow('EACCES permission denied');
});

it.each([
['proxyPost', '/api/v1/lxmf/send', { text: 'hi' }] as const,
['proxyPut', '/api/v1/interfaces/tcp', { enabled: true }] as const,
['proxyDelete', '/api/v1/interfaces/tcp', undefined] as const,
])(
'%s returns a soft-failure envelope for expected sidecar-down races',
async (method, path, body) => {
manager[method].mockRejectedValueOnce(new Error('Reticulum sidecar is not running'));
const channel = `reticulum:${method}` as const;
const result =
body === undefined
? await handlers.get(channel)?.(event, path)
: await handlers.get(channel)?.(event, path, body);
expect(result).toEqual({
__reticulumProxyError: true,
message: 'Reticulum sidecar is not running',
});
},
);

it.each([
['proxyPost', '/api/v1/lxmf/send', { text: 'hi' }] as const,
['proxyPut', '/api/v1/interfaces/tcp', { enabled: true }] as const,
['proxyDelete', '/api/v1/interfaces/tcp', undefined] as const,
])('%s rethrows unexpected manager failures', async (method, path, body) => {
manager[method].mockRejectedValueOnce(new Error('EACCES permission denied'));
const channel = `reticulum:${method}` as const;
const invoke =
body === undefined
? handlers.get(channel)?.(event, path)
: handlers.get(channel)?.(event, path, body);
await expect(invoke).rejects.toThrow('EACCES permission denied');
});

it('proxyPost forwards path and body to manager.proxyPost', async () => {
Expand Down
53 changes: 32 additions & 21 deletions src/main/ipc/reticulum-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type {
ReticulumSidecarStatus,
} from '../../shared/reticulum-types';
import { canonicalizeReticulumDestinationHash } from '../../shared/reticulumDestinationHash';
import {
isExpectedReticulumProxyError,
type ReticulumProxyIpcErrorEnvelope,
reticulumProxyIpcErrorEnvelope,
} from '../../shared/reticulumProxyIpcError';
import { MS_PER_MINUTE } from '../../shared/timeConstants';
import { createIpcRateLimiter } from '../ipcRateLimit';
import { sanitizeLogMessage } from '../log-service';
Expand Down Expand Up @@ -45,18 +50,6 @@ export interface ReticulumIpcDeps {
getMainWindow: () => BrowserWindow | null;
}

function isExpectedReticulumProxyError(message: string): boolean {
const lower = message.toLowerCase();
return (
lower.includes('not running') ||
message.includes('404') ||
lower.includes('fetch failed') ||
lower.includes('aborted') ||
lower.includes('timeout') ||
lower.includes('rate limit exceeded')
);
}

function parseReticulumStartOptions(opts: unknown): ReticulumSidecarStartOptions {
if (opts == null) return {};
if (typeof opts !== 'object' || Array.isArray(opts)) {
Expand All @@ -71,11 +64,29 @@ function parseReticulumStartOptions(opts: unknown): ReticulumSidecarStartOptions

function logReticulumProxyFailure(method: string, err: unknown, apiPath?: string): void {
const message = err instanceof Error ? err.message : String(err);
const log = isExpectedReticulumProxyError(message) ? console.debug : console.error;
const log = isExpectedReticulumProxyError(err) ? console.debug : console.error;
const pathSuffix = apiPath ? ` path=${apiPath}` : '';
log(`[ReticulumIPC] ${method} failed${pathSuffix}:`, sanitizeLogMessage(message));
}

/**
* Expected restart/transient failures: return an envelope (preload rethrows) so
* Electron does not emit `[error] Error occurred in handler for 'reticulum:proxy*'`.
* Unexpected failures still throw.
*/
function settleReticulumProxyFailure(
method: string,
err: unknown,
apiPath?: string,
): ReticulumProxyIpcErrorEnvelope {
logReticulumProxyFailure(method, err, apiPath);
const message = err instanceof Error ? err.message : String(err);
if (isExpectedReticulumProxyError(err)) {
return reticulumProxyIpcErrorEnvelope(sanitizeLogMessage(message));
}
throw err;
}

function assertProxyApiPath(apiPath: unknown): string {
if (typeof apiPath !== 'string') {
throw new Error('Reticulum proxy path must be a string');
Expand Down Expand Up @@ -175,8 +186,8 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void {
const m = ensureManager();
return await m.proxyGet(pathArg);
} catch (err) {
logReticulumProxyFailure('proxyGet', err, pathArg);
throw err;
// catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected
return settleReticulumProxyFailure('proxyGet', err, pathArg);
}
});

Expand All @@ -193,8 +204,8 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void {
const m = ensureManager();
return await m.proxyPost(pathArg, body);
} catch (err) {
logReticulumProxyFailure('proxyPost', err, pathArg);
throw err;
// catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected
return settleReticulumProxyFailure('proxyPost', err, pathArg);
}
});

Expand Down Expand Up @@ -223,8 +234,8 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void {
const m = ensureManager();
return await m.proxyPut(pathArg, body);
} catch (err) {
logReticulumProxyFailure('proxyPut', err, pathArg);
throw err;
// catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected
return settleReticulumProxyFailure('proxyPut', err, pathArg);
}
});

Expand All @@ -236,8 +247,8 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void {
const m = ensureManager();
return await m.proxyDelete(pathArg);
} catch (err) {
logReticulumProxyFailure('proxyDelete', err, pathArg);
throw err;
// catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected
return settleReticulumProxyFailure('proxyDelete', err, pathArg);
}
});

Expand Down
8 changes: 7 additions & 1 deletion src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', ()
it('caps shared proxy IPC at 300/min and treats rate-limit as expected', () => {
expect(HANDLERS_SOURCE).toMatch(/max:\s*300/);
expect(HANDLERS_SOURCE).toContain("label: 'reticulum:proxy'");
expect(HANDLERS_SOURCE).toContain("lower.includes('rate limit exceeded')");
expect(HANDLERS_SOURCE).toContain('isExpectedReticulumProxyError');
expect(HANDLERS_SOURCE).toContain("from '../../shared/reticulumProxyIpcError'");
const sharedSource = readFileSync(
join(__dirname, '../../shared/reticulumProxyIpcError.ts'),
'utf-8',
);
expect(sharedSource).toContain("lower.includes('rate limit exceeded')");
});

it('applies the shared proxy rate limit to picker-gated RNCP handlers', () => {
Expand Down
99 changes: 68 additions & 31 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ import type {
ReticulumSidecarStartOptions,
ReticulumSidecarStatus,
} from '../shared/reticulum-types';
import { throwIfReticulumProxyIpcError } from '../shared/reticulumProxyIpcError';
import type { TAKClientInfo, TAKServerStatus, TAKSettings } from '../shared/tak-types';

export type { NobleBleDevice, NobleBleSessionId, SerialPort };

/** Unwrap reticulum proxy soft-failure envelopes so renderer catch paths stay the same. */
async function unwrapReticulumProxy<T = unknown>(result: Promise<unknown>): Promise<T> {
return throwIfReticulumProxyIpcError(await result) as T;
}

contextBridge.exposeInMainWorld('electronAPI', {
// ─── Database operations ────────────────────────────────────────
db: {
Expand Down Expand Up @@ -1071,13 +1077,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
syncInterfaceIssueScope: (enabledInterfaceNames: string[]): Promise<ReticulumSidecarStatus> =>
ipcRenderer.invoke('reticulum:syncInterfaceIssueScope', enabledInterfaceNames),
proxyGet: (apiPath: string): Promise<unknown> =>
ipcRenderer.invoke('reticulum:proxyGet', apiPath),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', apiPath)),
proxyPost: (apiPath: string, body: unknown): Promise<unknown> =>
ipcRenderer.invoke('reticulum:proxyPost', apiPath, body),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', apiPath, body)),
proxyPut: (apiPath: string, body: unknown): Promise<unknown> =>
ipcRenderer.invoke('reticulum:proxyPut', apiPath, body),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPut', apiPath, body)),
proxyDelete: (apiPath: string): Promise<unknown> =>
ipcRenderer.invoke('reticulum:proxyDelete', apiPath),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyDelete', apiPath)),
factoryReset: (): Promise<unknown> => ipcRenderer.invoke('reticulum:factoryReset'),
readDefaultConfigFile: (): Promise<{ path: string | null; content: string | null }> =>
ipcRenderer.invoke('reticulum:readDefaultConfigFile'),
Expand Down Expand Up @@ -1105,63 +1111,89 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.off('reticulum:status', handler);
},
rrc: {
listHubs: () => ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rrc/hubs'),
listHubs: () =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rrc/hubs')),
upsertHub: (opts: { dest_hash: string; label?: string; favorited?: boolean }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/hubs', opts),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/hubs', opts)),
setFavorite: (destHash: string, favorited: boolean) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/hubs/favorite', {
dest_hash: destHash,
favorited,
}),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/hubs/favorite', {
dest_hash: destHash,
favorited,
}),
),
connect: (opts: { dest_hash: string; nickname?: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/connect', opts),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/connect', opts),
),
disconnect: (opts?: { dest_hash?: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/disconnect', opts ?? {}),
getStatus: () => ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rrc/status'),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/disconnect', opts ?? {}),
),
getStatus: () =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rrc/status')),
join: (opts: { hub_dest_hash: string; room: string; key?: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/join', opts),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/join', opts)),
part: (opts: { hub_dest_hash: string; room: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/part', opts),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/part', opts)),
send: (opts: {
hub_dest_hash: string;
room?: string;
body: string;
type?: string;
dst_hash?: string;
}) => ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/send', opts),
}) =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/send', opts)),
setNickname: (opts: { nickname: string; hub_dest_hash?: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/nick', opts),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rrc/nick', opts)),
getRooms: (hubDestHash?: string) => {
const q = hubDestHash?.trim()
? `?hub_dest_hash=${encodeURIComponent(hubDestHash.trim().toLowerCase())}`
: '';
return ipcRenderer.invoke('reticulum:proxyGet', `/api/v1/rrc/rooms${q}`);
return unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyGet', `/api/v1/rrc/rooms${q}`),
);
},
},
rnsh: {
connect: (opts: { destination_hash: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/connect', opts),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/connect', opts),
),
input: (opts: { session_id: string; data: string; encoding?: 'base64' }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/input', opts),
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/input', opts)),
resize: (opts: { session_id: string; rows?: number; cols?: number }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/resize', opts),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/resize', opts),
),
disconnect: (opts: { session_id: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/disconnect', opts),
getStatus: () => ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rnsh/status'),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rnsh/disconnect', opts),
),
getStatus: () =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rnsh/status')),
},
rncp: {
send: (opts: { destination_hash: string; path: string }) =>
ipcRenderer.invoke('reticulum:rncpSend', opts),
fetch: (opts: { destination_hash: string; remote_path: string; save_path?: string }) =>
ipcRenderer.invoke('reticulum:rncpFetch', opts),
cancel: (opts: { transfer_id: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/cancel', opts),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/cancel', opts),
),
accept: (opts: { transfer_id: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/accept', opts),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/accept', opts),
),
reject: (opts: { transfer_id: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/reject', opts),
getStatus: () => ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rncp/status'),
getListener: () => ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rncp/listener'),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/reject', opts),
),
getStatus: () =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rncp/status')),
getListener: () =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/rncp/listener')),
setListener: (opts: {
enabled: boolean;
save_dir?: string;
Expand All @@ -1172,7 +1204,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
blocked?: string[];
}) => ipcRenderer.invoke('reticulum:setRncpListener', opts),
announce: (): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/announce', {}),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/rncp/announce', {}),
),
showOpenFileDialog: (): Promise<{ canceled: boolean; path: string | null }> =>
ipcRenderer.invoke('reticulum:showRncpOpenFileDialog'),
showSaveDirectoryDialog: (): Promise<{ canceled: boolean; path: string | null }> =>
Expand All @@ -1182,8 +1216,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
remote: {
pathCapability: (opts: { destination_hash: string }) =>
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/remote/path-capability', opts),
getIdentity: () => ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/remote/identity'),
unwrapReticulumProxy(
ipcRenderer.invoke('reticulum:proxyPost', '/api/v1/remote/path-capability', opts),
),
getIdentity: () =>
unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', '/api/v1/remote/identity')),
},
},

Expand Down
Loading