diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 4f2eebc05..e1d62e34d 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -9,7 +9,6 @@ * Visual language follows ServiceBlockingGate.tsx (bg-stone-950/80 overlay, * bg-stone-900 panel, ocean-500 / coral-500 semantics). */ -import { isTauri } from '@tauri-apps/api/core'; import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -29,6 +28,7 @@ import { storeCoreToken, storeRpcUrl, } from '../../utils/configPersistence'; +import { isTauri } from '../../utils/tauriCommands/common'; const log = debug('boot-check'); const logError = debug('boot-check:error'); diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 983f475e0..1de97f75b 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -1,9 +1,10 @@ -import { invoke, isTauri } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; import { useEffect, useState } from 'react'; import { triggerSentryTestEvent } from '../../../services/analytics'; import { useAppSelector } from '../../../store/hooks'; import { APP_ENVIRONMENT } from '../../../utils/config'; +import { isTauri } from '../../../utils/tauriCommands/common'; import SettingsHeader from '../components/SettingsHeader'; import SettingsMenuItem from '../components/SettingsMenuItem'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; diff --git a/app/src/lib/nativeNotifications/tauriBridge.ts b/app/src/lib/nativeNotifications/tauriBridge.ts index 8bd201d17..266dbb2d7 100644 --- a/app/src/lib/nativeNotifications/tauriBridge.ts +++ b/app/src/lib/nativeNotifications/tauriBridge.ts @@ -1,6 +1,8 @@ -import { invoke, isTauri } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; import debug from 'debug'; +import { isTauri } from '../../utils/tauriCommands/common'; + const log = debug('native-notifications:bridge'); const errLog = debug('native-notifications:bridge:error'); diff --git a/app/src/lib/webviewNotifications/service.ts b/app/src/lib/webviewNotifications/service.ts index 98b69913b..21ffe3b02 100644 --- a/app/src/lib/webviewNotifications/service.ts +++ b/app/src/lib/webviewNotifications/service.ts @@ -1,4 +1,3 @@ -import { isTauri } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import debug from 'debug'; @@ -9,6 +8,7 @@ import { noteWebviewNotificationFired, } from '../../store/accountsSlice'; import { addIntegrationNotification } from '../../store/notificationSlice'; +import { isTauri } from '../../utils/tauriCommands/common'; import { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types'; const log = debug('webview-notifications'); diff --git a/app/src/main.tsx b/app/src/main.tsx index 51472bdd1..5b6d986e0 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,5 +1,4 @@ // IMPORTANT: Polyfills must be imported FIRST -import { isTauri as tauriRuntimeAvailable } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -16,6 +15,7 @@ import { primeActiveUserId } from './store/userScopedStorage'; import { APP_VERSION } from './utils/config'; import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener'; import { getActiveUserIdFromCore } from './utils/tauriCommands'; +import { isTauri as tauriRuntimeAvailable } from './utils/tauriCommands/common'; setStoreForApiClient(() => getCoreStateSnapshot().snapshot.sessionToken); @@ -23,7 +23,10 @@ setStoreForApiClient(() => getCoreStateSnapshot().snapshot.sessionToken); // that lives OUTSIDE Tauri's runtime (the vendored tauri-cef can't render // transparent windowed-mode browsers). That webview can't read a Tauri // window label, so the Rust shell appends `?window=mascot` to the URL it -// loads. Detect it before we touch any Tauri APIs. +// loads. Detect it via the URL param so we can skip `getCurrentWindow()` +// — which would either throw or trigger the CEF IPC-bootstrap gap that +// `tauriRuntimeAvailable()` (= the hardened `isTauri()`) now guards +// against by reading `window.__TAURI_INTERNALS__.invoke`. const urlWindowParam = (() => { try { return new URLSearchParams(window.location.search).get('window'); diff --git a/app/src/services/backendUrl.ts b/app/src/services/backendUrl.ts index c17084aed..61f75c336 100644 --- a/app/src/services/backendUrl.ts +++ b/app/src/services/backendUrl.ts @@ -1,6 +1,5 @@ -import { isTauri as coreIsTauri } from '@tauri-apps/api/core'; - import { BACKEND_URL } from '../utils/config'; +import { isTauri as coreIsTauri } from '../utils/tauriCommands/common'; import { callCoreRpc } from './coreRpcClient'; let resolvedBackendUrl: string | null = null; diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 0a27afe23..2e4ae79c3 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -1,10 +1,11 @@ -import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; import debug from 'debug'; import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory'; import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config'; import { getStoredCoreToken, peekStoredRpcUrl } from '../utils/configPersistence'; import { sanitizeError } from '../utils/sanitize'; +import { isTauri as coreIsTauri } from '../utils/tauriCommands/common'; import { normalizeRpcMethod } from './rpcMethods'; interface CoreRpcRelayRequest { diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index 1e9a2bcf0..69c0883ee 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -9,8 +9,9 @@ // // Splitting it this way keeps platform-specific window code in the shell // while the validation rules live (and are tested) in the core. -import { invoke, isTauri } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; +import { isTauri } from '../utils/tauriCommands/common'; import { callCoreRpc } from './coreRpcClient'; export type MeetJoinCallInput = { meetUrl: string; displayName: string }; diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index d4a6f24bd..0cb834559 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import { invoke, isTauri } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import debug from 'debug'; @@ -13,6 +13,7 @@ import { import { addIntegrationNotification } from '../store/notificationSlice'; import { fetchRespondQueue } from '../store/providerSurfaceSlice'; import type { AccountProvider, IngestedMessage } from '../types/accounts'; +import { isTauri } from '../utils/tauriCommands/common'; import { openhumanGetMeetSettings } from '../utils/tauriCommands/config'; import { trackEvent } from './analytics'; import { threadApi } from './api/threadApi'; @@ -25,6 +26,10 @@ const MEET_ORCHESTRATOR_MODEL = 'reasoning-v1'; const log = debug('webview-accounts'); const errLog = debug('webview-accounts:error'); +// Re-export the canonical Tauri guard so existing imports +// `import { isTauri } from '.../webviewAccountService'` keep working. +// The implementation lives in `utils/tauriCommands/common.ts` and accounts +// for the CEF IPC injection race (see comment there). export { isTauri }; /** diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 11741f527..758fcc159 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -98,6 +98,17 @@ if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = function () {}; } +// The hardened `isTauri()` (in `utils/tauriCommands/common.ts`) checks both +// `coreIsTauri()` and `window.__TAURI_INTERNALS__.invoke`. Many existing test +// files mock `@tauri-apps/api/core::isTauri` to `true` to exercise the +// Tauri branch; without a matching IPC handle on `window` they would now +// regress to the non-Tauri path. Seed a no-op handle once globally so the +// IPC-readiness check passes by default. Tests that *want* the CEF gap +// behaviour can `delete window.__TAURI_INTERNALS__` in a `beforeEach`. +( + window as unknown as { __TAURI_INTERNALS__: { invoke: () => Promise } } +).__TAURI_INTERNALS__ = { invoke: vi.fn(() => Promise.resolve()) }; + // Mock Tauri APIs (not available in test env) vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) })); @@ -223,6 +234,13 @@ if (!process.env.DEBUG_TESTS) { afterEach(() => { clearRequestLog(); cleanup(); + // Re-seed the IPC handle after any test that may have deleted it + // (e.g. tests exercising the CEF-gap branch of `isTauri()`). Without + // this, sibling tests in the same jsdom worker would silently regress + // to the non-Tauri path. Per graycyrus review on PR #1556. + ( + window as unknown as { __TAURI_INTERNALS__: { invoke: () => Promise } } + ).__TAURI_INTERNALS__ = { invoke: vi.fn(() => Promise.resolve()) }; }); afterAll(async () => { await stopMockServer(); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 3a17c7e7c..443732f7b 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/react'; -import { isTauri as coreIsTauri } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; @@ -14,6 +13,7 @@ import { BILLING_DASHBOARD_URL } from './links'; import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate'; import { openUrl } from './openUrl'; import { storeSession } from './tauriCommands'; +import { isTauri as coreIsTauri } from './tauriCommands/common'; const SESSION_TOKEN_UPDATED_EVENT = 'core-state:session-token-updated'; diff --git a/app/src/utils/oauthAppVersionGate.ts b/app/src/utils/oauthAppVersionGate.ts index c5c1d9b52..dff36d7c5 100644 --- a/app/src/utils/oauthAppVersionGate.ts +++ b/app/src/utils/oauthAppVersionGate.ts @@ -1,8 +1,8 @@ import { getVersion } from '@tauri-apps/api/app'; -import { isTauri } from '@tauri-apps/api/core'; import { LATEST_APP_DOWNLOAD_URL, MINIMUM_SUPPORTED_APP_VERSION } from './config'; import { isVersionAtLeast, parseSemverParts } from './semver'; +import { isTauri } from './tauriCommands/common'; export type OAuthAppVersionGateResult = | { ok: true } diff --git a/app/src/utils/openUrl.test.ts b/app/src/utils/openUrl.test.ts index 0cd927eb5..a071b7a2b 100644 --- a/app/src/utils/openUrl.test.ts +++ b/app/src/utils/openUrl.test.ts @@ -11,7 +11,7 @@ const isTauriMock = vi.fn(); const tauriOpenUrlMock = vi.fn(); const addBreadcrumbMock = vi.fn(); -vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => isTauriMock() })); +vi.mock('./tauriCommands/common', () => ({ isTauri: () => isTauriMock() })); vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: (url: string) => tauriOpenUrlMock(url) })); diff --git a/app/src/utils/openUrl.ts b/app/src/utils/openUrl.ts index 286d97c8f..04541f082 100644 --- a/app/src/utils/openUrl.ts +++ b/app/src/utils/openUrl.ts @@ -1,7 +1,8 @@ import * as Sentry from '@sentry/react'; -import { isTauri } from '@tauri-apps/api/core'; import { openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener'; +import { isTauri } from './tauriCommands/common'; + const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); /** diff --git a/app/src/utils/tauriCommands/common.test.ts b/app/src/utils/tauriCommands/common.test.ts new file mode 100644 index 000000000..f339a2d0b --- /dev/null +++ b/app/src/utils/tauriCommands/common.test.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for `isTauri()` — the canonical Tauri-runtime guard used across + * `app/src/`. Beyond delegating to `@tauri-apps/api/core::isTauri()`, this + * wrapper also confirms that the IPC transport (`window.__TAURI_INTERNALS__ + * .invoke`) is wired before reporting `true`. + * + * Why it matters: under CEF, `globalThis.isTauri` (which the underlying + * `coreIsTauri()` checks) is injected by the webview bootstrap BEFORE the + * `postMessage` IPC bridge is connected. An `invoke()` landing in that gap + * throws `TypeError: Cannot read properties of undefined (reading + * 'postMessage')` deep inside Tauri's `sendIpcMessage`, which surfaces as + * the OPENHUMAN-REACT-S Sentry issue (#1472 follow-up). All call sites that + * gate on `isTauri()` should now route through the non-Tauri branch during + * the gap instead of bursting into IPC. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isTauri } from './common'; + +const coreIsTauriMock = vi.fn(); + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => coreIsTauriMock() })); + +describe('isTauri (tauriCommands/common)', () => { + // We mutate `window` to simulate Tauri-runtime bootstrap state across cases. + // Stash + restore so other tests in the suite (which share the jsdom global) + // see a pristine window. + let originalInternals: unknown; + + beforeEach(() => { + coreIsTauriMock.mockReset(); + originalInternals = (window as unknown as { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + }); + + afterEach(() => { + if (originalInternals === undefined) { + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + } else { + (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = + originalInternals; + } + }); + + it('returns false when not running in Tauri at all (browser/Vitest)', () => { + coreIsTauriMock.mockReturnValue(false); + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + + expect(isTauri()).toBe(false); + }); + + it('returns true when both the runtime flag and the IPC `invoke` handle are present', () => { + coreIsTauriMock.mockReturnValue(true); + (window as unknown as { __TAURI_INTERNALS__?: { invoke: unknown } }).__TAURI_INTERNALS__ = { + invoke: () => Promise.resolve(), + }; + + expect(isTauri()).toBe(true); + }); + + // The OPENHUMAN-REACT-S regression: Tauri sets `globalThis.isTauri = true` + // (so the official check returns true) before CEF wires the IPC postMessage + // bridge. During that gap any unguarded `invoke(...)` blows up inside + // `sendIpcMessage` with the "Cannot read properties of undefined (reading + // 'postMessage')" TypeError. Our guard must short-circuit to `false` so + // call sites skip the IPC path instead of trusting the runtime flag alone. + it('returns false during the CEF gap when runtime flag is set but __TAURI_INTERNALS__ is missing', () => { + coreIsTauriMock.mockReturnValue(true); + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + + expect(isTauri()).toBe(false); + }); + + it('returns false during the partial-bootstrap gap when __TAURI_INTERNALS__ exists but `invoke` is not yet wired', () => { + coreIsTauriMock.mockReturnValue(true); + // Some CEF bootstrap stages set the object literal before the IPC handle + // is attached. Treat that as "not ready". + (window as unknown as { __TAURI_INTERNALS__?: Record }).__TAURI_INTERNALS__ = + {}; + + expect(isTauri()).toBe(false); + }); + + it('returns false when __TAURI_INTERNALS__.invoke is present but not a function', () => { + coreIsTauriMock.mockReturnValue(true); + (window as unknown as { __TAURI_INTERNALS__?: { invoke: unknown } }).__TAURI_INTERNALS__ = { + invoke: 'not-a-function', + }; + + expect(isTauri()).toBe(false); + }); +}); diff --git a/app/src/utils/tauriCommands/common.ts b/app/src/utils/tauriCommands/common.ts index 0572c2739..6115af3d4 100644 --- a/app/src/utils/tauriCommands/common.ts +++ b/app/src/utils/tauriCommands/common.ts @@ -2,11 +2,41 @@ * Common utilities and types for Tauri Commands. */ import { isTauri as coreIsTauri } from '@tauri-apps/api/core'; +import debug from 'debug'; -// Check if we're running in Tauri +const log = debug('tauri:ipc-guard'); + +/** + * True when the Tauri runtime is present AND the underlying IPC transport is + * wired. The official `coreIsTauri()` check (which reads `globalThis.isTauri`) + * is set early by Tauri's webview bootstrap, but on CEF `__TAURI_INTERNALS__` + * (and the `postMessage` bridge it dispatches through) is injected *after* + * `on_after_created` fires. An `invoke()` landing in that gap throws + * `TypeError: Cannot read properties of undefined (reading 'postMessage')` + * deep inside Tauri's `sendIpcMessage` — see OPENHUMAN-REACT-S / #1472. + * + * Callers that gate on `isTauri()` BEFORE invoking should therefore use this + * function; it returns `false` during the bootstrap gap so the call site + * takes the non-Tauri branch (skip / fallback) instead of synchronously + * throwing into a `new Promise` body where the rejection escapes the local + * try/catch and lands as an unhandled Sentry event. + */ export const isTauri = (): boolean => { - // Tauri v2: prefer the official runtime check over window globals. - return coreIsTauri(); + if (!coreIsTauri()) return false; + if (typeof window === 'undefined') return false; + // Narrow `window` access through a single optional chain so the check is + // resilient to either `__TAURI_INTERNALS__` being absent or `.invoke` + // being missing while the rest of the object is partially populated. + const internals = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: unknown } }) + .__TAURI_INTERNALS__; + if (typeof internals?.invoke !== 'function') { + // Bridge-missing branch: distinct from `!coreIsTauri()` (= not in Tauri + // at all). Logging here makes the CEF bootstrap gap observable in dev + // and is a no-op in production (debug namespace disabled by default). + log('isTauri() -> false: IPC bridge not wired (CEF bootstrap gap or non-Tauri)'); + return false; + } + return true; }; export interface CommandResponse {