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
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

import { useT } from '../../../lib/i18n/I18nContext';
import { triggerSentryTestEvent } from '../../../services/analytics';
import { useAppSelector } from '../../../store/hooks';
import { APP_ENVIRONMENT } from '../../../utils/config';
import { isTauri } from '../../../utils/tauriCommands/common';
// `safeInvoke` (aliased to `invoke`) converts the CEF
// `window.ipc.postMessage` synchronous throw — Sentry TAURI-REACT-7 /
// TAURI-REACT-6 — into a rejected Promise so the existing `.catch(...)` /
// try/catch handlers see it as a normal IPC failure.
import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/common';
import { resetWalkthrough } from '../../walkthrough/AppWalkthrough';
import SettingsHeader from '../components/SettingsHeader';
import SettingsMenuItem from '../components/SettingsMenuItem';
Expand Down
13 changes: 10 additions & 3 deletions app/src/components/settings/panels/McpServerPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ import { renderWithProviders } from '../../../test/test-utils';

const hoisted = vi.hoisted(() => ({ invoke: vi.fn(), isTauri: vi.fn(() => true) }));

vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invoke }));

vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: hoisted.isTauri }));
// McpServerPanel imports `invoke` from `tauriCommands/common` (aliased from
// `safeInvoke` — see OPENHUMAN-TAURI-REACT-7 / TAURI-REACT-6). Route
// `hoisted.invoke` through `safeInvoke` so assertions on
// `hoisted.invoke.toHaveBeenCalledWith(...)` work unchanged. The
// `@tauri-apps/api/core` mock is omitted because `safeInvoke` shadows it
// for all panel call sites.
vi.mock('../../../utils/tauriCommands/common', () => ({
isTauri: hoisted.isTauri,
safeInvoke: (...args: unknown[]) => hoisted.invoke(...args),
}));

vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
Expand Down
7 changes: 5 additions & 2 deletions app/src/components/settings/panels/McpServerPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { invoke } from '@tauri-apps/api/core';
import debug from 'debug';
import { useEffect, useState } from 'react';

import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri } from '../../../utils/tauriCommands/common';
// `safeInvoke` (aliased to `invoke`) converts the CEF
// `window.ipc.postMessage` synchronous throw — Sentry TAURI-REACT-7 /
// TAURI-REACT-6 — into a rejected Promise that the existing try/catch sees
// as a regular IPC failure.
import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/common';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';

Expand Down
7 changes: 5 additions & 2 deletions app/src/lib/nativeNotifications/tauriBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
import debug from 'debug';

import { isTauri } from '../../utils/tauriCommands/common';
// `safeInvoke` (aliased to `invoke`) replaces bare
// `@tauri-apps/api/core::invoke` so the CEF `window.ipc.postMessage`
// synchronous throw (Sentry TAURI-REACT-7 / TAURI-REACT-6) is converted into
// a rejected Promise that the existing try/catch chains already handle.
import { safeInvoke as invoke, isTauri } from '../../utils/tauriCommands/common';

const log = debug('native-notifications:bridge');
const errLog = debug('native-notifications:bridge:error');
Expand Down
8 changes: 7 additions & 1 deletion app/src/overlay/OverlayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
*
* There is **no** demo loop — the overlay is entirely event-driven.
*/
import { invoke } from '@tauri-apps/api/core';
import {
currentMonitor,
getCurrentWindow,
Expand All @@ -37,6 +36,13 @@ import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
import { useT } from '../lib/i18n/I18nContext';
import { callCoreRpc, getCoreHttpBaseUrl } from '../services/coreRpcClient';
import { connectCoreSocket } from '../services/coreSocket';
// `safeInvoke` (aliased to `invoke`) converts the CEF
// `window.ipc.postMessage` synchronous throw — Sentry TAURI-REACT-7 /
// TAURI-REACT-6 — into a rejected Promise so the existing `.catch(...)`
// handler sees it as a normal IPC failure. The overlay window is the most
// at-risk surface here because it boots into its own WebView where the
// CEF IPC bridge can briefly be unwired.
import { safeInvoke as invoke } from '../utils/tauriCommands/common';

const OVERLAY_IDLE_WIDTH = 50;
const OVERLAY_IDLE_HEIGHT = 50;
Expand Down
10 changes: 9 additions & 1 deletion app/src/services/__tests__/coreProcessControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock, isTauri: vi.fn(() =
// coreIsTauri() from @tauri-apps/api/core. The default mock returns false
// (non-Tauri env); tests that need the Tauri-path branch override it
// inline.
//
// `safeInvoke` is the migration shim for the CEF IPC sync-throw
// (OPENHUMAN-TAURI-REACT-7 / TAURI-REACT-6). In production it routes through
// `coreInvoke`; for unit tests we keep `invokeMock` as the seam so the
// existing assertions on call args / order keep working unchanged.
const isTauriMock = vi.fn(() => false);
vi.mock('../../utils/tauriCommands/common', () => ({ isTauri: isTauriMock }));
vi.mock('../../utils/tauriCommands/common', () => ({
isTauri: isTauriMock,
safeInvoke: (...args: unknown[]) => invokeMock(...args),
}));

vi.mock('../coreRpcClient', () => ({ clearCoreRpcTokenCache: clearCoreRpcTokenCacheMock }));

Expand Down
8 changes: 5 additions & 3 deletions app/src/services/coreProcessControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
* is stuck. Outside Tauri (web preview / Vitest harness) this is a no-op
* that returns a friendly error string.
*/
import { invoke } from '@tauri-apps/api/core';

import { isTauri } from '../utils/tauriCommands/common';
// `safeInvoke` (in place of `@tauri-apps/api/core::invoke`) converts the CEF
// `window.ipc.postMessage` synchronous throw — Sentry TAURI-REACT-7 /
// TAURI-REACT-6 — into a rejected Promise so the caller's `await` /
// `.catch(...)` can handle it instead of bubbling as an unhandled rejection.
import { safeInvoke as invoke, isTauri } from '../utils/tauriCommands/common';
import { clearCoreRpcTokenCache } from './coreRpcClient';

export async function restartCoreProcess(): Promise<void> {
Expand Down
7 changes: 5 additions & 2 deletions app/src/services/webviewAccountService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/react';
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import debug from 'debug';
import { z } from 'zod';
Expand All @@ -15,7 +14,11 @@ import {
import { addIntegrationNotification } from '../store/notificationSlice';
import { fetchRespondQueue } from '../store/providerSurfaceSlice';
import type { AccountProvider, IngestedMessage } from '../types/accounts';
import { isTauri } from '../utils/tauriCommands/common';
// `safeInvoke` replaces bare `@tauri-apps/api/core::invoke` so the CEF
// `window.ipc.postMessage` synchronous throw (Sentry TAURI-REACT-7 /
// TAURI-REACT-6) is converted into a rejected Promise rather than an
// unhandled rejection. Existing `.catch(...)` chains keep working unchanged.
import { safeInvoke as invoke, isTauri } from '../utils/tauriCommands/common';
import { openhumanGetMeetSettings } from '../utils/tauriCommands/config';
import { trackEvent } from './analytics';
import { threadApi } from './api/threadApi';
Expand Down
9 changes: 6 additions & 3 deletions app/src/utils/tauriCommands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/**
* Authentication commands.
*/
import { invoke } from '@tauri-apps/api/core';

import { callCoreRpc } from '../../services/coreRpcClient';
import { CommandResponse, isTauri } from './common';
// `safeInvoke` (aliased to `invoke`) replaces bare
// `@tauri-apps/api/core::invoke` so the CEF `window.ipc.postMessage`
// synchronous throw (Sentry TAURI-REACT-7 / TAURI-REACT-6) surfaces as a
// rejected Promise. `exchangeToken` runs early in the auth flow where the
// CEF bridge can still be unwired, so this matters most.
import { type CommandResponse, safeInvoke as invoke, isTauri } from './common';

/**
* Exchange a login token for a session token
Expand Down
126 changes: 124 additions & 2 deletions app/src/utils/tauriCommands/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { isTauri, parseServiceCliOutput } from './common';
import { IpcUnavailableError, isTauri, parseServiceCliOutput, safeInvoke } from './common';

const coreIsTauriMock = vi.fn();
const coreInvokeMock = vi.fn();

vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => coreIsTauriMock() }));
vi.mock('@tauri-apps/api/core', () => ({
isTauri: () => coreIsTauriMock(),
// Forward only the args that `safeInvoke` actually passed so arity-strict
// expectations (`toHaveBeenCalledWith(cmd)` / `toHaveBeenCalledWith(cmd,
// args)`) match the wrapper's contract. Spreading `arguments` would invent
// a trailing `undefined`; using rest preserves the original arity.
invoke: (...args: unknown[]) => coreInvokeMock(...args),
}));

describe('isTauri (tauriCommands/common)', () => {
// We mutate `window` to simulate Tauri-runtime bootstrap state across cases.
Expand Down Expand Up @@ -151,3 +159,117 @@ describe('parseServiceCliOutput (tauriCommands/common)', () => {
);
});
});

// `safeInvoke` is the migration target for every IPC call site that today
// hands a bare `invoke(...)` Promise to `.catch(noop)` or to a try/catch.
// Under CEF the underlying `coreInvoke` can throw **synchronously** when the
// vendored `ipc-protocol.js` fallback path runs into the unwired
// `window.ipc.postMessage` (see OPENHUMAN-TAURI-REACT-7 / TAURI-REACT-6). The
// sync throw escapes the surrounding Promise body and lands on
// `onunhandledrejection`, where Sentry captures it as noisy `Non-Error
// promise rejection` events. `safeInvoke` must convert that into a rejected
// Promise tagged with `IpcUnavailableError`.
describe('safeInvoke (tauriCommands/common)', () => {
beforeEach(() => {
coreInvokeMock.mockReset();
});

it('returns the resolved value when the underlying invoke resolves', async () => {
coreInvokeMock.mockResolvedValue('ok');

await expect(safeInvoke<string>('greet')).resolves.toBe('ok');
// Wrapper forwards only the args the caller passed (preserves arity for
// strict-match test mocks like `tauriBridge.test.ts`).
expect(coreInvokeMock).toHaveBeenCalledWith('greet');
});

it('forwards args (and only args) when called with two arguments', async () => {
coreInvokeMock.mockResolvedValue(42);

await safeInvoke<number>('doStuff', { foo: 1 });

expect(coreInvokeMock).toHaveBeenCalledWith('doStuff', { foo: 1 });
});

it('forwards args and options when called with three arguments', async () => {
coreInvokeMock.mockResolvedValue(42);

await safeInvoke<number>('doStuff', { foo: 1 }, { headers: { 'X-Test': '1' } });

expect(coreInvokeMock).toHaveBeenCalledWith(
'doStuff',
{ foo: 1 },
{ headers: { 'X-Test': '1' } }
);
});

it('rejects with the original error when the underlying invoke returns a rejected promise (no sync throw)', async () => {
coreInvokeMock.mockRejectedValue(new Error('command failed'));

await expect(safeInvoke<string>('whatever')).rejects.toThrow('command failed');
});

// The OPENHUMAN-TAURI-REACT-7 / TAURI-REACT-6 regression: a sync `TypeError`
// thrown inside the IPC fallback (vendored `ipc-protocol.js:84`) escapes the
// Promise body of `coreInvoke` if the call site doesn't wrap it. `safeInvoke`
// must catch that throw and surface it as a *rejected* Promise instead, so
// existing `.catch(...)` chains keep working and Sentry stops capturing the
// raw TypeError as an unhandled rejection.
it('converts a synchronous coreInvoke throw into a rejected Promise', async () => {
coreInvokeMock.mockImplementation(() => {
throw new Error('something went wrong sync');
});

const promise = safeInvoke<string>('willThrow');
// The wrapper must return a Promise *object* even though the underlying
// call threw synchronously. `expect(...).rejects` would fail with a
// confusing message if the wrapper itself re-threw.
expect(promise).toBeInstanceOf(Promise);
await expect(promise).rejects.toThrow('something went wrong sync');
});

it('wraps the CEF "postMessage of undefined" TypeError in IpcUnavailableError', async () => {
const cefThrow = new TypeError("Cannot read properties of undefined (reading 'postMessage')");
coreInvokeMock.mockImplementation(() => {
throw cefThrow;
});

const err = await safeInvoke<void>('webview_account_hide').catch((e: unknown) => e);

expect(err).toBeInstanceOf(IpcUnavailableError);
const typed = err as IpcUnavailableError;
expect(typed.name).toBe('IpcUnavailableError');
expect(typed.cmd).toBe('webview_account_hide');
expect(typed.cause).toBe(cefThrow);
expect(typed.message).toContain('webview_account_hide');
expect(typed.message).toContain('postMessage');
});

it('does NOT wrap unrelated TypeErrors that do not mention postMessage', async () => {
const otherTypeError = new TypeError('something else entirely');
coreInvokeMock.mockImplementation(() => {
throw otherTypeError;
});

const err = await safeInvoke<void>('some_cmd').catch((e: unknown) => e);

// Pass through verbatim — existing message-based classifiers (e.g.
// `classifyWebviewAccountError`) must keep seeing the original error.
expect(err).toBe(otherTypeError);
expect(err).not.toBeInstanceOf(IpcUnavailableError);
});

it('also rejects with IpcUnavailableError when the failure mode arrives via the promise (mid-session fallback)', async () => {
// The fallback path can also surface the same TypeError via the rejected
// Promise pathway (when CEF eventually wires the bridge object, the call
// proceeds far enough to construct the Promise but still fails on the
// missing `postMessage`). Same classifier must handle both shapes.
const cefThrow = new TypeError("Cannot read properties of undefined (reading 'postMessage')");
coreInvokeMock.mockRejectedValue(cefThrow);

const err = await safeInvoke<void>('webview_account_reveal').catch((e: unknown) => e);

expect(err).toBeInstanceOf(IpcUnavailableError);
expect((err as IpcUnavailableError).cmd).toBe('webview_account_reveal');
});
});
Loading
Loading