diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index efc298ae51..493a98d4f9 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -251,7 +251,7 @@ jobs: --key-data-v2 "$CAPGO_PRIVATE_KEY" \ --path ./out \ --bundle "$VERSION" \ - --auto-min-update-version \ + --min-update-version "$VERSION" \ --version-exists-ok \ --comment "${GITHUB_SHA:0:7} — auto-published with the $VERSION native release" CURRENT="$(npx @capgo/cli@8.42.4 channel currentBundle production \ diff --git a/.github/workflows/capgo-deploy.yml b/.github/workflows/capgo-deploy.yml index 087fadd152..0c44ae83f1 100644 --- a/.github/workflows/capgo-deploy.yml +++ b/.github/workflows/capgo-deploy.yml @@ -189,6 +189,20 @@ jobs: echo "name=$VERSION" >> "$GITHUB_OUTPUT" echo "Uploading as bundle version $VERSION" + # The bundle is built from the current tree, so it targets the newest + # native shell. --auto-min-update-version only copies the previous + # bundle's floor forward, and this checkout has no native version + # stamped on disk (package.json says 1.0.53), so the floor never rose + # past the first upload. Resolve it from the release tags instead and + # fail closed when none is visible. Keep in lockstep with the release + # lanes, which pin the floor to the binary they ship. + - name: Resolve native floor + id: native_floor + run: | + FLOOR="$(node scripts/release-version.mjs native-floor)" + echo "name=$FLOOR" >> "$GITHUB_OUTPUT" + echo "Bundle requires native $FLOOR or newer" + - name: Upload bundle to Capgo # Commit message via env, never inline — a multi-line message (or one # with quotes) injected into the run script breaks --comment quoting. @@ -201,6 +215,7 @@ jobs: COMMIT_MSG: ${{ github.event.head_commit.message }} CHANNEL: ${{ steps.channel.outputs.name }} VERSION: ${{ steps.version.outputs.name }} + NATIVE_FLOOR: ${{ steps.native_floor.outputs.name }} run: | echo "Uploading bundle $VERSION to channel $CHANNEL" COMMENT="${GITHUB_SHA:0:7} — $(printf '%s' "${COMMIT_MSG:-Manual deploy}" | head -n1)" @@ -210,7 +225,7 @@ jobs: --key-data-v2 "$CAPGO_PRIVATE_KEY" \ --path ./out \ --bundle "$VERSION" \ - --auto-min-update-version \ + --min-update-version "$NATIVE_FLOOR" \ --version-exists-ok \ --comment "$COMMENT" diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 7c747bd794..4a3acd117a 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -406,7 +406,7 @@ jobs: --key-data-v2 "$CAPGO_PRIVATE_KEY" \ --path ./out \ --bundle "$VERSION" \ - --auto-min-update-version \ + --min-update-version "$VERSION" \ --version-exists-ok \ --comment "${GITHUB_SHA:0:7} — auto-published with the $VERSION native release" CURRENT="$(npx @capgo/cli@8.42.4 channel currentBundle production \ diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index 495a163bb6..bc0f8fd70e 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -310,9 +310,16 @@ own `out/` under the binary's versionName, then assert the channel serves it. environment has no protection rules, so the run ships immediately. Adding required reviewers under Settings → Environments → Production makes it queue for approval with no workflow change (needs repo admin). -- **Native-version gating:** `--auto-min-update-version` (already set) keeps a JS bundle - built against new plugins off older native shells. **Bump the native version whenever - you change plugins/native code**, then ship that via Play — OTA can't. +- **Native-version gating:** every upload passes an explicit `--min-update-version`, so a + JS bundle built against new plugins stays off older native shells. The release lanes pin + it to the binary they ship; `capgo-deploy.yml` resolves it from the newest `v..0` + tag (`scripts/release-version.mjs native-floor`) and fails if none is visible. It replaced + `--auto-min-update-version`, which only copies the previous bundle's floor forward — with no + native version stamped on the `dev` checkout (package.json says 1.0.53) the floor never + rose past the first upload, and the CLI refuses the two flags together. Capgo only enforces + the floor when the channel's "disable auto update" strategy is set to *version number*. + **Bump the native version whenever you change plugins/native code**, then ship that via + Play — OTA can't. - **Staged rollout:** roll production OTA to ~10% → watch Sentry/crash + error rates → 100%. Don't 100% every merge. - **Rollback** is configured in `capacitor.config.ts` (`appReadyTimeout: 15000` + diff --git a/eslint.config.js b/eslint.config.js index a98edbf6cb..78d82cf358 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -133,8 +133,19 @@ const RESTRICTED_SYNTAX_BASE = [ message: 'Never return a Capacitor plugin object across an await/then boundary — resolving a promise with it probes .then, which the plugin proxy turns into a native call that never settles the promise. Wrap it: `return { Plugin }` and destructure at the call site. See src/utils/crisp.ts and src/utils/auth-token.ts.', }, + { + // The --safe-* tokens (globals.css) are the only place the Android < 15 + // zeroing and Capacitor's native inset injection land; a raw env() read + // paints the phantom status-bar band those exist to remove. + selector: + ':matches(Literal[value=/env\\(safe-area-inset-/], TemplateElement[value.raw=/env\\(safe-area-inset-/])', + message: + "Don't read env(safe-area-inset-*) directly — use var(--safe-top|--safe-right|--safe-bottom|--safe-left) (or the pt-safe-top / pb-safe-bottom utilities) from globals.css. The tokens carry the Android < 15 zeroing and the native inset injection; env() bypasses both. The only legal raw read is the diagnostic at src/app/(mobile-ui)/dev/safe-area/page.tsx.", + }, ] +const SAFE_AREA_ENV_SELECTOR = 'safe-area-inset' + module.exports = [ { ignores: [ @@ -275,6 +286,21 @@ module.exports = [ ], }, }, + { + // The safe-area diagnostic renders raw env() next to the tokens on + // purpose; the DS token dump is generated from globals.css. + files: [ + 'src/app/(mobile-ui)/dev/safe-area/page.tsx', + 'src/app/(mobile-ui)/dev/ds/foundations/tokens.generated.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + ...RESTRICTED_SYNTAX_BASE.filter((r) => !r.selector.includes(SAFE_AREA_ENV_SELECTOR)), + ...QUERY_STRING_PUSH_RESTRICTIONS, + ], + }, + }, { // DS 10 ratchet allowlist — do not add files; migrate to nuqs instead // (remove entries as files migrate). These files imported useSearchParams diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 3e3cde4bbc..59f87ee0e6 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -3,6 +3,7 @@ import posthog from 'posthog-js' import { beforeSendHandler } from './sentry.utils' import { inferSentryEnvironment } from '@/utils/sentry-env' import { withoutBrowserTracing } from '@/utils/sentry-integrations' +import { posthogErrorMirror } from '@/utils/sentry-posthog-mirror' import { whenIdle } from '@/utils/defer-analytics' import { installPaymentNetworkGoogleAnalyticsGuard, isPaymentNetworkExplorerPath } from '@/utils/private-routes' @@ -110,8 +111,14 @@ if ( // and that instrumentation overhead is visible jank in the WebView. sampleRate: 1.0, tracesSampleRate: 0, + // Synthesizes a stack for message events (captureConsole on a + // non-Error, the explicit captureMessage calls) so they attribute + // to a call site — see the web init in sentry-init.ts. + attachStacktrace: true, beforeSend: (event) => isPaymentNetworkExplorerPath(window.location.pathname) ? null : beforeSendHandler(event), + beforeSendTransaction: (event) => + isPaymentNetworkExplorerPath(window.location.pathname) ? null : event, // A WebView that can't reach the bundler can't reach ingest either, // so the report of the failure died with the session. The offline // transport parks undeliverable envelopes in IndexedDB and flushes @@ -121,6 +128,9 @@ if ( integrations: (defaults) => [ ...withoutBrowserTracing(defaults), Sentry.captureConsoleIntegration({ levels: ['error'] }), + // Same PostHog $exception mirror as the web init, so native + // errors keep their session-replay correlation. + posthogErrorMirror(), ], }) diff --git a/scripts/__tests__/native-build-scan.test.js b/scripts/__tests__/native-build-scan.test.js index 3ab231bae6..d107bc1365 100644 --- a/scripts/__tests__/native-build-scan.test.js +++ b/scripts/__tests__/native-build-scan.test.js @@ -4,12 +4,12 @@ const Module = require('module') const SCRIPT_PATH = path.join(__dirname, '..', 'native-build.js') -// native-build.js is a script, not a module: it calls main() at import time and -// exports nothing. Load the real source with that call stripped so the scan -// helpers can be asserted against the actual app tree. +// native-build.js only exports ITEMS_TO_DISABLE. Load the real source with the +// entrypoint stripped so the scan helpers can be asserted against the actual +// app tree. function loadScriptInternals() { const source = fs.readFileSync(SCRIPT_PATH, 'utf-8') - const withoutEntrypoint = source.replace(/\nmain\(\)\s*$/, '\n') + const withoutEntrypoint = source.replace(/\nif \(require\.main === module\) main\(\)\s*$/, '\n') expect(withoutEntrypoint).not.toBe(source) const exposed = diff --git a/scripts/__tests__/native-env-check.test.js b/scripts/__tests__/native-env-check.test.js index 4d5edcdd1e..d150a47c0c 100644 --- a/scripts/__tests__/native-env-check.test.js +++ b/scripts/__tests__/native-env-check.test.js @@ -5,12 +5,12 @@ const Module = require('module') const SCRIPT_PATH = path.join(__dirname, '..', 'native-build.js') -// native-build.js is a script, not a module: it calls main() at import time and -// exports nothing. Load the real source with that call stripped so the env check +// native-build.js is a script first: it calls main() when run directly and only +// exports ITEMS_TO_DISABLE. Load the real source with that call stripped so the env check // can be asserted against the list the build actually enforces. function loadScriptInternals() { const source = fs.readFileSync(SCRIPT_PATH, 'utf-8') - const withoutEntrypoint = source.replace(/\nmain\(\)\s*$/, '\n') + const withoutEntrypoint = source.replace(/\nif \(require\.main === module\) main\(\)\s*$/, '\n') expect(withoutEntrypoint).not.toBe(source) const exposed = diff --git a/scripts/__tests__/release-version.test.js b/scripts/__tests__/release-version.test.js index 75330e5937..081ca618d2 100644 --- a/scripts/__tests__/release-version.test.js +++ b/scripts/__tests__/release-version.test.js @@ -145,6 +145,28 @@ describe('release version resolver', () => { }) }) + describe('native-floor', () => { + // Only v..0 tags are native releases: OTA tags, another major + // and the off-scheme v2026.02.26 on main must not become a floor. + it('picks the newest native release tag', () => { + const result = run(repo('1.0.53', { tags: ['v1.4.0', 'v1.4.2', 'v1.3.0', 'v2.1.0', 'v2026.02.26'] }), [ + 'native-floor', + ]) + + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('1.4.0') + }) + + // A missing floor would upload a bundle every shell accepts, including the + // ones it was not built for — fail the lane instead. + it('fails when no native release tag exists', () => { + const result = run(repo('1.0.53', { tags: ['v1.0.0', 'v2026.02.26'] }), ['native-floor']) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/cut a native release/) + }) + }) + describe('validate', () => { // `v*` is too loose a glob to reject this, and v2026.02.26 is a real tag on // main — it is X.Y.Z shaped, so only the major check catches it. diff --git a/scripts/ds-lint-counts.mjs b/scripts/ds-lint-counts.mjs index 7d7700cf53..4812a5250f 100644 --- a/scripts/ds-lint-counts.mjs +++ b/scripts/ds-lint-counts.mjs @@ -51,6 +51,7 @@ const HEX_ALLOW = [ 'LandingPage/PioneerCard3D', // canvas 3d card 'receipt/[entryId]/pdf/', // @react-pdf/renderer — its StyleSheet takes no tailwind tokens 'app/layout.tsx', // next viewport themeColor — browser chrome, must be a literal + 'Global/UnsupportedWebViewScreen/', // inline fallback shown when the stylesheet itself cannot parse ] // extra allowlist for inline-style only (F-12 taxonomy). canvas/D3/mermaid diff --git a/scripts/native-build.js b/scripts/native-build.js index 3755a16591..8c1ddcb047 100644 --- a/scripts/native-build.js +++ b/scripts/native-build.js @@ -58,6 +58,8 @@ const ITEMS_TO_DISABLE = [ { path: '(mobile-ui)/dev/payment-graph', type: 'dir' }, ] +module.exports = { ITEMS_TO_DISABLE } + const MODIFIED_FILES = [] const WRAPPER_FILES = [] @@ -717,4 +719,4 @@ function pruneExportedAssets() { } } -main() +if (require.main === module) main() diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index d75eb2b2e1..37a66c892c 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -23,6 +23,7 @@ // node scripts/release-version.mjs native // node scripts/release-version.mjs ota --current // node scripts/release-version.mjs staging +// node scripts/release-version.mjs native-floor // node scripts/release-version.mjs validate --kind // // Needs full history and tags (actions/checkout with fetch-depth: 0). @@ -55,10 +56,12 @@ function main(argv) { return nextOta(major, flag(rest, '--current')) case 'staging': return `${major}.${latestBuild(major)}.${commitCount()}` + case 'native-floor': + return nativeFloor(major) case 'validate': return validate(major, rest[0], flag(rest, '--kind')) default: - throw new Error(`unknown mode "${mode ?? ''}" — expected native, ota, staging or validate`) + throw new Error(`unknown mode "${mode ?? ''}" — expected native, ota, staging, native-floor or validate`) } } @@ -98,6 +101,17 @@ function commitCount() { return Number(out) } +// The newest native release, for a bundle's --min-update-version. A bundle +// built from the current tree targets the newest shell, and Capgo's +// --auto-min-update-version only copies the previous bundle's floor forward, +// so on a checkout with no native version stamped on disk the floor never rose. +function nativeFloor(major) { + const build = latestBuild(major) + if (build === 0) + throw new Error(`no v${major}..0 tag exists — cut a native release before publishing a bundle`) + return `${major}.${build}.0` +} + function validate(major, version, kind) { const match = PLAIN_SEMVER.exec(version ?? '') if (!match) throw new Error(`"${version}" is not a plain X.Y.Z version`) diff --git a/sentry.utils.test.ts b/sentry.utils.test.ts index 93cabb30a1..13dc1b46b6 100644 --- a/sentry.utils.test.ts +++ b/sentry.utils.test.ts @@ -1,5 +1,5 @@ import type { ErrorEvent } from '@sentry/nextjs' -import { beforeSendHandler, shouldIgnoreError } from './sentry.utils' +import { beforeSendHandler, getEventSearchTexts, isTransientCapgoNoise, shouldIgnoreError } from './sentry.utils' import { criticalFlowTags } from '@/utils/sentry-critical-flow' function eventWith(partial: { @@ -135,6 +135,31 @@ describe('shouldIgnoreError — Capgo updater noise', () => { it('does not touch non-Capgo errors that mention a download', () => { expect(shouldIgnoreError(eventWith({ type: 'Error', value: 'Download error: statement failed' }))).toBe(false) }) + + // Exported for the PostHog mirror wrapper (sentry-init), whose processEvent + // hook runs before beforeSend and so cannot rely on shouldIgnoreError. + describe('isTransientCapgoNoise', () => { + const textsOf = (message: string) => getEventSearchTexts(eventWith({ message })) + + it('is true for a transient updater failure', () => { + expect(isTransientCapgoNoise(textsOf('[CapgoUpdater] 🔴 Failed to send stats batch'))).toBe(true) + expect(isTransientCapgoNoise(textsOf('[capgo] update check failed: network_error'))).toBe(true) + }) + + it('is false for the actionable failures shouldIgnoreError keeps', () => { + expect(isTransientCapgoNoise(textsOf('[CapgoUpdater] 🔴 Checksum mismatch'))).toBe(false) + expect( + isTransientCapgoNoise(textsOf('[capgo] update check failed: disable_auto_update_under_native')) + ).toBe(false) + }) + + it('is false for anything not from Capgo', () => { + expect(isTransientCapgoNoise(textsOf('Failed to send stats batch'))).toBe(false) + expect(isTransientCapgoNoise(getEventSearchTexts(eventWith({ type: 'TypeError', value: 'boom' })))).toBe( + false + ) + }) + }) }) describe('shouldIgnoreError — passkey wrapper', () => { diff --git a/sentry.utils.ts b/sentry.utils.ts index 82e8458f4f..af075ce169 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -116,7 +116,7 @@ function isActionableCapgoError(searchTexts: string[]): boolean { return isFromCapgo(searchTexts) && searchTexts.some((text) => CAPGO_ACTIONABLE.some((p) => text.includes(p))) } -function isTransientCapgoNoise(searchTexts: string[]): boolean { +export function isTransientCapgoNoise(searchTexts: string[]): boolean { return isFromCapgo(searchTexts) && !isActionableCapgoError(searchTexts) } @@ -182,6 +182,32 @@ export function isThirdPartyScriptFrame(filename: string): boolean { return THIRD_PARTY_SCRIPT_FRAMES.some((pattern) => filename.includes(pattern)) } +/** + * The texts every noise predicate matches against, one entry per field. + * Matching each field independently — rather than one concatenated string — + * keeps a pattern from matching across unrelated fields and suppressing a + * legitimate event. Shared with the PostHog mirror wrapper in sentry-init so + * both filters read the same event the same way. + * + * Class names come from every link in the chain. Sentry orders `exception.values` + * root-cause-first, so a wrapper carrying a `cause` lands at the END — exactly + * where fetchWithSentry's ServiceUnavailableError and useZeroDev's PasskeyError + * always sit. Reading only values[0] left `alreadyReported` inert for a month: + * PEANUT-UI-SNP kept double-counting PEANUT-UI-QEY. + * + * Deliberately types only, not messages. Class names are exact, so matching them + * chain-wide can only catch our own wrappers. Widening the fuzzy message patterns + * the same way would suppress MORE — the failure 5343f1d0 just fixed, where viem's + * "Details: Failed to fetch" ate real payment errors via `networkIssues`. + */ +export function getEventSearchTexts(event: ErrorEvent): string[] { + const message = event.message || '' + const exceptionValue = event.exception?.values?.[0]?.value || '' + const culprit = (event as any).culprit || '' + const exceptionTypes = (event.exception?.values ?? []).map((v) => v.type || '') + return [message, exceptionValue, culprit, ...exceptionTypes] +} + /** * Check if error message matches any ignored pattern */ @@ -190,26 +216,7 @@ export function shouldIgnoreError(event: ErrorEvent): boolean { // stay filtered even there — a user backing out of the passkey sheet is not // a defect, and those would drown out the real failures. const isCriticalFlow = Boolean(event.tags?.[CRITICAL_FLOW_TAG]) - const message = event.message || '' - const exceptionValue = event.exception?.values?.[0]?.value || '' - const culprit = (event as any).culprit || '' - /* - * Class names from every link in the chain. Sentry orders `exception.values` - * root-cause-first, so a wrapper carrying a `cause` lands at the END — exactly - * where fetchWithSentry's ServiceUnavailableError and useZeroDev's PasskeyError - * always sit. Reading only values[0] left `alreadyReported` inert for a month: - * PEANUT-UI-SNP kept double-counting PEANUT-UI-QEY. - * - * Deliberately types only, not messages. Class names are exact, so matching them - * chain-wide can only catch our own wrappers. Widening the fuzzy message patterns - * the same way would suppress MORE — the failure 5343f1d0 just fixed, where viem's - * "Details: Failed to fetch" ate real payment errors via `networkIssues`. - */ - const exceptionTypes = (event.exception?.values ?? []).map((v) => v.type || '') - - // Match each field independently — concatenating them would let a pattern - // match across unrelated fields and suppress a legitimate event. - const searchTexts = [message, exceptionValue, culprit, ...exceptionTypes] + const searchTexts = getEventSearchTexts(event) /* * Rescue actionable OTA failures BEFORE the generic patterns run. The Capgo diff --git a/src/app/(setup)/setup/finish/page.tsx b/src/app/(setup)/setup/finish/page.tsx index 5a92d687ed..ac6510983c 100644 --- a/src/app/(setup)/setup/finish/page.tsx +++ b/src/app/(setup)/setup/finish/page.tsx @@ -6,6 +6,8 @@ import { SetupWrapper } from '@/components/Setup/components/SetupWrapper' import SignTestTransaction from '@/components/Setup/Views/SignTestTransaction' import { PeanutWhistling } from '@/assets/mascot' import { useAuth } from '@/context/authContext' +import { useBackHandler } from '@/hooks/useBackHandler' +import { minimizeNativeApp } from '@/utils/capacitor' import { useTranslations } from 'next-intl' /** @@ -15,6 +17,10 @@ import { useTranslations } from 'next-intl' function FinishSetupPageContent() { const t = useTranslations('setup') const { logoutUser, isLoggingOut } = useAuth() + useBackHandler(() => { + void minimizeNativeApp() + return true + }) return ( (null) + // only mirror steps that actually render: not while the entry step is + // being determined, and not behind the existing-session interstitial + // or the unsupported-device/browser modals + const stepRendered = + !isLoading && + sessionChecked && + !existingSessionUsername && + !showDeviceNotSupportedModal && + !showBrowserNotSupportedModal + useSetupStepUrlSync({ - // only mirror steps that actually render: not while the entry step is - // being determined, and not behind the existing-session interstitial - // or the unsupported-device/browser modals - enabled: - !isLoading && - sessionChecked && - !existingSessionUsername && - !showDeviceNotSupportedModal && - !showBrowserNotSupportedModal, + enabled: stepRendered, step, steps, goToScreen: setScreenId, }) + useSetupBackHandler({ step, canStepBack: stepRendered, onBack: handleBack }) /* * A device can arrive at /setup already authenticated: a half-completed @@ -129,17 +134,15 @@ function SetupPageContent() { setIsLoading(true) await new Promise((resolve) => setTimeout(resolve, 100)) // ensure other initializations can complete - // Skip the invite-code gate straight to signup when either: - // - an invite code is present (cookie survives the PWA-install hop), or - // - the URL asks for it via ?step=signup — the signal every campaign - // entrypoint sends when it pushes to /setup. After authentication, - // useZeroDev submits the queued opaque campaign list to the canonical - // claim service; the step decision never interprets that cookie. + // The entry-step rules (invite code / ?step=signup skipping the invite + // gate, ?step=login, a known device going to Log In) live in + // resolveSetupEntryStep. After authentication, useZeroDev submits the + // queued opaque campaign list to the canonical claim service; the step + // decision never interprets that cookie. // // Why not the campaignTag cookie: retryable campaign acquisition can // intentionally persist for 30 days. Using it as onboarding state would - // route a returning user past Landing (the only screen with Log In) onto - // Signup, unable to log back in (regression from PR #2346). + // route a returning user past Landing onto Signup (regression from PR #2346). /* * ?code= arrives from an /invite deep link (native maps * peanut.me/invite?code=X here — see native-routes.ts). Persist it @@ -158,7 +161,12 @@ function SetupPageContent() { // past the landing gate — otherwise claim/invite links deep-link // straight into the signup form. Native app keeps the fast path. const webSignupClosed = isPwaSunsetOn() && !isCapacitor() - const skipInviteGate = (!!userInviteCode || legacyStepParam === 'signup') && !webSignupClosed + const entryInput = { + hasInviteCode: !!userInviteCode, + stepParam: legacyStepParam, + webSignupClosed, + knownDevice: hasKnownDeviceCredentials(), + } const localDeviceType = detectedDeviceType @@ -166,8 +174,12 @@ function SetupPageContent() { // and go straight to the landing (signup) flow if (isCapacitor()) { setDeviceType(localDeviceType) - // invite code or ?step=signup → straight to signup, else landing - const targetStep = skipInviteGate ? 'signup' : 'landing' + const targetStep = resolveSetupEntryStep({ + ...entryInput, + isCapacitor: true, + deviceType: localDeviceType, + isStandalonePWA: false, + }) const stepIndex = steps.findIndex((s: ISetupStep) => s.screenId === targetStep) if (stepIndex !== -1) { dispatch(setupActions.setStep(stepIndex + 1)) @@ -246,21 +258,14 @@ function SetupPageContent() { setDeferredPrompt({} as BeforeInstallPromptEvent) } - if (localDeviceType === 'android') { - determinedSetupInitialStepId = isStandalonePWA ? 'landing' : 'android-initial-pwa-install' - } - // if ios, show landing screen - else if (localDeviceType === 'ios') { - determinedSetupInitialStepId = 'landing' - } else { - determinedSetupInitialStepId = 'pwa-install' - } + determinedSetupInitialStepId = resolveSetupEntryStep({ + ...entryInput, + isCapacitor: false, + deviceType: localDeviceType, + isStandalonePWA, + }) - // If an invite code or ?step=signup is present, jump to signup - if (determinedSetupInitialStepId && skipInviteGate) { - const signupScreenIndex = steps.findIndex((s: ISetupStep) => s.screenId === 'signup') - dispatch(setupActions.setStep(signupScreenIndex + 1)) - } else if (determinedSetupInitialStepId) { + if (determinedSetupInitialStepId) { const initialStepIndex = steps.findIndex((s: ISetupStep) => s.screenId === determinedSetupInitialStepId) if (initialStepIndex !== -1) { dispatch(setupActions.setStep(initialStepIndex + 1)) @@ -366,6 +371,7 @@ function SetupPageContent() { showBackButton={step.showBackButton} showSkipButton={step.showSkipButton} showLogoutButton={step.screenId === 'sign-test-transaction'} + showLoginButton={step.showLoginButton} imageClassName={step.imageClassName} onBack={handleBack} onSkip={() => handleNext()} diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 5f8bd4502e..3167341e9e 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -9,6 +9,7 @@ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' +import { UnsupportedWebViewScreen, hasUnsupportedWebViewBypass } from '@/components/Global/UnsupportedWebViewScreen' import { MarketingIntlProvider } from '@/i18n/app/MarketingIntlProvider' import { PeanutProvider } from '@/config/peanut.config' import { ContextProvider } from '@/context/contextProvider' @@ -19,6 +20,7 @@ import { useNativeAppLinks } from '@/hooks/useNativeAppLinks' import { useOtaUpdates } from '@/hooks/useOtaUpdates' import { useSplashGate } from '@/hooks/useSplashGate' import { useZeroLegacyAndroidSafeAreaInsets } from '@/hooks/useZeroLegacyAndroidSafeAreaInsets' +import { applyLegacyAndroidSafeAreaZeroFromUserAgent, isCapacitor, isWebViewCssSupported } from '@/utils/capacitor' import { isMarketingRoute } from '@/utils/marketing-routes' import { NuqsAdapter } from 'nuqs/adapters/next/app' import dynamic from 'next/dynamic' @@ -41,6 +43,16 @@ if (DEV_TOOLS_ENABLED && typeof window !== 'undefined') { import('@/dev/devsync-agent').then((m) => m.initDevsyncAgent()) } +// Module scope so it lands before hydration: the first paint on Android < 15 +// would otherwise show the phantom safe-area band until the async Device.getInfo +// pass (useZeroLegacyAndroidSafeAreaInsets, still authoritative) corrects it. +if (typeof window !== 'undefined') applyLegacyAndroidSafeAreaZeroFromUserAgent() + +// Decided once at load, client only. A WebView that cannot parse the +// stylesheet gets the inline-styled update screen in place of the app tree. +const UNSUPPORTED_WEBVIEW = + typeof window !== 'undefined' && isCapacitor() && !isWebViewCssSupported() && !hasUnsupportedWebViewBypass() + const AppGlobals = dynamic(() => import('./AppGlobals').then((m) => m.AppGlobals)) // The full message catalog is 129 KB; app routes load it as their own chunk. const AppIntlProvider = dynamic(() => import('@/i18n/app/AppIntlProvider').then((m) => m.AppIntlProvider)) @@ -60,6 +72,14 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { const marketing = isMarketingRoute(usePathname()) const IntlProvider = marketing ? MarketingIntlProvider : AppIntlProvider + if (UNSUPPORTED_WEBVIEW) { + return ( + + + + ) + } + return ( diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index b15fbd5def..11b01477e4 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -98,7 +98,7 @@ export default function SmartStoreRedirect() { return (
-
+

{t('qr.title')}

{settled && migrationOn && ( diff --git a/src/components/Card/share-asset/ShareAssetActions.tsx b/src/components/Card/share-asset/ShareAssetActions.tsx index 757193a636..34e694a647 100644 --- a/src/components/Card/share-asset/ShareAssetActions.tsx +++ b/src/components/Card/share-asset/ShareAssetActions.tsx @@ -14,7 +14,9 @@ * * Save button: captures the asset to PNG and triggers a download. Works * on every browser; useful even on mobile if the user wants the image - * before composing the post. + * before composing the post. In the native app WKWebView silently cancels + * ``, so Save goes through the OS share sheet (which carries + * "Save Image") and is hidden when files can't be shared. */ import { type FC, type RefObject, useState } from 'react' @@ -27,6 +29,14 @@ import { shareCardOnTwitter } from './share.utils' import { pickWinCaption } from './winCaptions' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { isNativeBridge } from '@/utils/capacitor' + +type SaveMode = 'download' | 'native-share' | 'hidden' + +function resolveSaveMode(): SaveMode { + if (!isNativeBridge()) return 'download' + return canShareImageFiles() ? 'native-share' : 'hidden' +} /** * Serialise whatever the share/save path threw into something Sentry + @@ -91,6 +101,7 @@ export const ShareAssetActions: FC = ({ const [isSharing, setIsSharing] = useState(false) const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState(null) + const [saveMode] = useState(resolveSaveMode) // One random win caption per mount (rotation lives in winCaptions.ts), so // shared timelines don't fill with one identical line. Stable for this @@ -158,9 +169,15 @@ export const ShareAssetActions: FC = ({ const node = captureRef.current if (!node) throw new Error('share asset not yet rendered — try again in a moment') const blob = await captureShareAsset(node) - downloadBlob(blob, filename) - posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SAVED, { source }) + if (saveMode === 'native-share') { + await navigator.share({ files: [new File([blob], filename, { type: 'image/png' })] }) + } else { + downloadBlob(blob, filename) + } + posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SAVED, { source, method: saveMode }) } catch (err) { + // AbortError = user dismissed the share sheet: neither saved nor failed. + if (err instanceof Error && err.name === 'AbortError') return const detail = describeShareError(err) console.error('[share-asset] save failed', detail) Sentry.captureException(err, { @@ -191,16 +208,18 @@ export const ShareAssetActions: FC = ({ > {t('share')} - + {saveMode !== 'hidden' && ( + + )} {error && (

{error} diff --git a/src/components/Card/share-asset/__tests__/ShareAssetActions.test.tsx b/src/components/Card/share-asset/__tests__/ShareAssetActions.test.tsx new file mode 100644 index 0000000000..850d895f59 --- /dev/null +++ b/src/components/Card/share-asset/__tests__/ShareAssetActions.test.tsx @@ -0,0 +1,125 @@ +/** @jest-environment jsdom */ +/** + * Save-button routing. On web the PNG goes through a download anchor; in the + * native app WKWebView cancels `` silently, so Save uses the OS + * share sheet (which carries "Save Image") and is hidden when files can't be + * shared at all. The SAVED event only fires once the chosen path resolved. + */ +import React, { createRef } from 'react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import posthog from 'posthog-js' +import { renderWithIntl } from '@/test-utils/intl' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { ShareAssetActions } from '../ShareAssetActions' + +const mockIsNativeBridge = jest.fn(() => false) +jest.mock('@/utils/capacitor', () => ({ + ...jest.requireActual('@/utils/capacitor'), + isNativeBridge: () => mockIsNativeBridge(), +})) + +const mockCanShareImageFiles = jest.fn(() => false) +const mockDownloadBlob = jest.fn() +const mockCaptureShareAsset = jest.fn(() => Promise.resolve(new Blob(['png'], { type: 'image/png' }))) +jest.mock('../captureShareAsset', () => ({ + captureShareAsset: (...args: unknown[]) => mockCaptureShareAsset(...(args as [])), + canShareImageFiles: () => mockCanShareImageFiles(), + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), + ShareAssetCaptureError: class ShareAssetCaptureError extends Error {}, +})) + +jest.mock('../share.utils', () => ({ shareCardOnTwitter: jest.fn() })) +jest.mock('../winCaptions', () => ({ pickWinCaption: () => 'gg' })) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) + +const mockedCapture = posthog.capture as jest.Mock +const mockShare = jest.fn(() => Promise.resolve()) + +function renderActions() { + const captureRef = createRef() + Object.defineProperty(captureRef, 'current', { value: document.createElement('div'), writable: true }) + return renderWithIntl() +} + +const saveButton = () => screen.getByRole('button', { name: 'Save image' }) + +describe('ShareAssetActions save', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIsNativeBridge.mockReturnValue(false) + mockCanShareImageFiles.mockReturnValue(false) + Object.defineProperty(navigator, 'share', { value: mockShare, configurable: true, writable: true }) + }) + + it('web: downloads the PNG and reports saved', async () => { + renderActions() + fireEvent.click(saveButton()) + await waitFor(() => expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(Blob), 'card.png')) + expect(mockShare).not.toHaveBeenCalled() + expect(mockedCapture).toHaveBeenCalledWith(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SAVED, { + source: 'celebration', + method: 'download', + }) + }) + + it('native: hands the PNG to the share sheet and reports saved only after it resolves', async () => { + mockIsNativeBridge.mockReturnValue(true) + mockCanShareImageFiles.mockReturnValue(true) + let resolveShare!: () => void + mockShare.mockImplementationOnce(() => new Promise((resolve) => (resolveShare = resolve))) + renderActions() + fireEvent.click(saveButton()) + await waitFor(() => expect(mockShare).toHaveBeenCalledTimes(1)) + const [{ files }] = mockShare.mock.calls[0] as unknown as [{ files: File[] }] + expect(files).toHaveLength(1) + expect(files[0].name).toBe('card.png') + expect(files[0].type).toBe('image/png') + expect(mockDownloadBlob).not.toHaveBeenCalled() + expect(mockedCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SAVED, expect.anything()) + resolveShare() + await waitFor(() => + expect(mockedCapture).toHaveBeenCalledWith(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SAVED, { + source: 'celebration', + method: 'native-share', + }) + ) + }) + + it('native: a dismissed share sheet is neither saved nor failed and re-enables Save', async () => { + mockIsNativeBridge.mockReturnValue(true) + mockCanShareImageFiles.mockReturnValue(true) + const abort = new Error('cancelled') + abort.name = 'AbortError' + mockShare.mockRejectedValueOnce(abort) + renderActions() + fireEvent.click(saveButton()) + await waitFor(() => expect(mockShare).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(saveButton()).toBeEnabled()) + expect(mockedCapture).not.toHaveBeenCalled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('native: a real share failure reports failed', async () => { + mockIsNativeBridge.mockReturnValue(true) + mockCanShareImageFiles.mockReturnValue(true) + mockShare.mockRejectedValueOnce(new Error('share broke')) + renderActions() + fireEvent.click(saveButton()) + await waitFor(() => + expect(mockedCapture).toHaveBeenCalledWith( + ANALYTICS_EVENTS.CARD_SHARE_ASSET_FAILED, + expect.objectContaining({ source: 'celebration', action: 'save', message: 'share broke' }) + ) + ) + expect(screen.getByRole('alert')).toHaveTextContent('share broke') + }) + + it('native without file sharing: no Save button at all', () => { + mockIsNativeBridge.mockReturnValue(true) + mockCanShareImageFiles.mockReturnValue(false) + renderActions() + expect(screen.queryByRole('button', { name: 'Save image' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Share' })).toBeInTheDocument() + }) +}) diff --git a/src/components/Global/AppShell/__tests__/AppShell.test.tsx b/src/components/Global/AppShell/__tests__/AppShell.test.tsx new file mode 100644 index 0000000000..a53d4b9366 --- /dev/null +++ b/src/components/Global/AppShell/__tests__/AppShell.test.tsx @@ -0,0 +1,50 @@ +import { act, render, screen } from '@testing-library/react' +import { AppShell } from '..' +import { acquireBottomNavHide, resetBottomNavVisibilityForTests } from '@/utils/bottom-nav-visibility' + +describe('AppShell bottom nav slot', () => { + beforeEach(() => { + resetBottomNavVisibilityForTests() + }) + + it('renders the nav slot interactive by default', () => { + render( + nav}> +

+ + ) + + const slot = screen.getByTestId('app-shell-nav') + expect(slot).not.toHaveClass('translate-y-full') + expect(slot).not.toHaveAttribute('inert') + }) + + it('slides the nav out and makes it inert while a hide is held', () => { + render( + nav}> +
content
+
+ ) + + let release: () => void = () => {} + act(() => { + release = acquireBottomNavHide() + }) + const slot = screen.getByTestId('app-shell-nav') + expect(slot).toHaveClass('translate-y-full') + expect(slot).toHaveAttribute('inert') + + act(() => release()) + expect(slot).not.toHaveClass('translate-y-full') + expect(slot).not.toHaveAttribute('inert') + }) + + it('omits the slot entirely without a nav', () => { + render( + +
content
+
+ ) + expect(screen.queryByTestId('app-shell-nav')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Global/AppShell/index.tsx b/src/components/Global/AppShell/index.tsx index d7e3e924bd..5557f3f46d 100644 --- a/src/components/Global/AppShell/index.tsx +++ b/src/components/Global/AppShell/index.tsx @@ -1,6 +1,7 @@ 'use client' import { twMerge } from '@/utils/tw' +import { useBottomNavHidden } from '@/utils/bottom-nav-visibility' interface AppShellProps { /** app = authed chrome (scroll container + bottom nav); onboarding = setup chrome. */ @@ -35,6 +36,8 @@ export const AppShell = ({ bottomInsetClassName, children, }: AppShellProps) => { + const navHidden = useBottomNavHidden() + if (variant === 'onboarding') { return ( <> @@ -85,7 +88,14 @@ export const AppShell = ({ {/* transparent on purpose: the pill and qr button float over the page content, no strip behind them (they carry their own fills) */} {nav && ( -
+
{nav}
)} diff --git a/src/components/Global/Drawer/__tests__/Drawer.test.tsx b/src/components/Global/Drawer/__tests__/Drawer.test.tsx index 768d93a604..5cd2b0a9bb 100644 --- a/src/components/Global/Drawer/__tests__/Drawer.test.tsx +++ b/src/components/Global/Drawer/__tests__/Drawer.test.tsx @@ -1,5 +1,9 @@ -import { render, screen } from '@testing-library/react' -import { Drawer, DrawerContent, DrawerTitle } from '..' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Drawer, DrawerContent, DrawerTitle, DrawerTrigger } from '..' +import { dispatchBackPress, resetBackHandlersForTests } from '@/utils/back-handler' +import { resetBottomNavVisibilityForTests, useBottomNavHidden } from '@/utils/bottom-nav-visibility' + +const NavProbe = () => {String(useBottomNavHidden())} beforeAll(() => { window.matchMedia = @@ -66,3 +70,162 @@ describe('DrawerContent accessibility', () => { } }) }) + +describe('Drawer hardware back', () => { + beforeEach(() => { + resetBackHandlersForTests() + resetBottomNavVisibilityForTests() + }) + + const pressBack = () => { + let consumed = false + act(() => { + consumed = dispatchBackPress() + }) + return consumed + } + + it('closes an open controlled drawer through onOpenChange and consumes the press', () => { + const onOpenChange = jest.fn() + render( + + +
body
+
+
+ ) + + expect(pressBack()).toBe(true) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('consumes the press without closing a non-dismissible drawer', () => { + const onOpenChange = jest.fn() + render( + + +
body
+
+
+ ) + + expect(pressBack()).toBe(true) + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('never intercepts for a modal={false} sheet', () => { + const onOpenChange = jest.fn() + render( + + +
body
+
+
+ ) + + expect(pressBack()).toBe(false) + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('does not intercept while closed', () => { + const onOpenChange = jest.fn() + render( + + +
body
+
+
+ ) + + expect(pressBack()).toBe(false) + }) + + it('opens and closes an uncontrolled DrawerTrigger drawer', async () => { + render( + + open sheet + +
body
+
+
+ ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(pressBack()).toBe(false) + + fireEvent.click(screen.getByText('open sheet')) + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveAttribute('data-state', 'open') + + expect(pressBack()).toBe(true) + // jsdom never fires vaul's exit animationend, so the node lingers: the + // closed state and the released handler are the observable contract + await waitFor(() => expect(dialog).toHaveAttribute('data-state', 'closed')) + expect(pressBack()).toBe(false) + }) + + it('hands the press to a nested drawer before its parent', () => { + const onOuterChange = jest.fn() + const onInnerChange = jest.fn() + render( + + + + +
inner body
+
+
+
+
+ ) + + expect(pressBack()).toBe(true) + expect(onInnerChange).toHaveBeenCalledWith(false) + expect(onOuterChange).not.toHaveBeenCalled() + }) + + it('holds the bottom nav hidden only while open with hideBottomNav', () => { + const view = render( + <> + + + +
body
+
+
+ + ) + expect(screen.getByTestId('nav-hidden')).toHaveTextContent('true') + + view.rerender( + <> + + + +
body
+
+
+ + ) + expect(screen.getByTestId('nav-hidden')).toHaveTextContent('false') + }) + + it('does not touch the bottom nav without hideBottomNav or for a non-modal sheet', () => { + render( + <> + + + +
body
+
+
+ + +
body
+
+
+ + ) + expect(screen.getByTestId('nav-hidden')).toHaveTextContent('false') + }) +}) diff --git a/src/components/Global/Drawer/index.tsx b/src/components/Global/Drawer/index.tsx index e8bcbf54f8..0628b00324 100644 --- a/src/components/Global/Drawer/index.tsx +++ b/src/components/Global/Drawer/index.tsx @@ -3,6 +3,8 @@ import * as React from 'react' import { twMerge } from '@/utils/tw' import { Drawer as DrawerPrimitive } from 'vaul' +import { useBackHandler } from '@/hooks/useBackHandler' +import { acquireBottomNavHide } from '@/utils/bottom-nav-visibility' type DrawerProps = React.ComponentProps & { /** @@ -11,11 +13,63 @@ type DrawerProps = React.ComponentProps & { * Root double-applies the background scale and fights over the scroll lock. */ nested?: boolean + /** Slide the app bottom nav out of view while this (modal) sheet is open. */ + hideBottomNav?: boolean } -const Drawer = ({ shouldScaleBackground = true, nested = false, ...props }: DrawerProps) => { +/* + * Open state is mirrored here (controlled or not) so the wrapper can own the + * hardware-back contract: a modal sheet consumes back and closes through the + * same onOpenChange path vaul uses for drag/Escape/outside-click; a + * non-dismissible one consumes it as a no-op; a modal={false} sheet never + * intercepts. + */ +const Drawer = ({ + shouldScaleBackground = true, + nested = false, + hideBottomNav = false, + open, + defaultOpen, + onOpenChange, + dismissible = true, + modal = true, + ...props +}: DrawerProps) => { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false) + const isControlled = open !== undefined + const isOpen = isControlled ? open : uncontrolledOpen + + const handleOpenChange = React.useCallback( + (next: boolean) => { + if (!isControlled) setUncontrolledOpen(next) + onOpenChange?.(next) + }, + [isControlled, onOpenChange] + ) + + useBackHandler(() => { + if (dismissible) handleOpenChange(false) + return true + }, isOpen && modal) + + React.useEffect(() => { + if (!hideBottomNav || !isOpen || !modal) return + return acquireBottomNavHide() + }, [hideBottomNav, isOpen, modal]) + const Root = nested ? DrawerPrimitive.NestedRoot : DrawerPrimitive.Root - return + return ( + + ) } Drawer.displayName = 'Drawer' diff --git a/src/components/Global/Modal/__tests__/Modal.test.tsx b/src/components/Global/Modal/__tests__/Modal.test.tsx new file mode 100644 index 0000000000..eb312339bb --- /dev/null +++ b/src/components/Global/Modal/__tests__/Modal.test.tsx @@ -0,0 +1,74 @@ +import { act, render } from '@testing-library/react' +import Modal from '..' +import { dispatchBackPress, resetBackHandlersForTests } from '@/utils/back-handler' + +jest.mock('@/components/Global/Icons/Icon', () => ({ + Icon: () => null, +})) + +describe('Modal hardware back', () => { + beforeEach(() => { + resetBackHandlersForTests() + }) + + it('closes a visible modal and consumes the press', () => { + const onClose = jest.fn() + render( + +
body
+
+ ) + + let consumed = false + act(() => { + consumed = dispatchBackPress() + }) + expect(consumed).toBe(true) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('consumes the press without closing when preventClose is set', () => { + const onClose = jest.fn() + render( + +
body
+
+ ) + + let consumed = false + act(() => { + consumed = dispatchBackPress() + }) + expect(consumed).toBe(true) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not intercept while hidden', () => { + const onClose = jest.fn() + render( + +
body
+
+ ) + + expect(dispatchBackPress()).toBe(false) + expect(onClose).not.toHaveBeenCalled() + }) + + it('releases the handler once the modal hides', () => { + const onClose = jest.fn() + const view = render( + +
body
+
+ ) + view.rerender( + +
body
+
+ ) + + expect(dispatchBackPress()).toBe(false) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Global/Modal/index.tsx b/src/components/Global/Modal/index.tsx index eebb68b6f4..fd414be002 100644 --- a/src/components/Global/Modal/index.tsx +++ b/src/components/Global/Modal/index.tsx @@ -1,6 +1,7 @@ import { Dialog, DialogBackdrop, DialogPanel, Transition } from '@headlessui/react' import { Fragment, useRef } from 'react' import { twMerge } from '@/utils/tw' +import { useBackHandler } from '@/hooks/useBackHandler' import { Icon } from '../Icons/Icon' type ModalProps = { @@ -36,6 +37,11 @@ const Modal = ({ }: ModalProps) => { let dialogRef = useRef(null) + useBackHandler(() => { + if (!preventClose) onClose() + return true + }, visible) + return ( { // leaves a backdrop target to tap-to-close; with no keyboard up it is // slack and 85dvh wins, so the resting look is unchanged. height: visibleHeight - ? `min(85dvh, calc(${visibleHeight}px - env(safe-area-inset-top) - ${TOP_RESERVE}px))` + ? `min(85dvh, calc(${visibleHeight}px - var(--safe-top) - ${TOP_RESERVE}px))` : '85dvh', // The keyboard already covers the home indicator; padding for it too // would just wedge a dead strip between the composer and the keys. - paddingBottom: keyboardInset ? 0 : 'env(safe-area-inset-bottom)', + paddingBottom: keyboardInset ? 0 : 'var(--safe-bottom)', transform: isSupportModalOpen ? `translateY(${dragOffset}px)` : 'translateY(100%)', transition: isDragging ? 'none' : 'transform 300ms ease-out', }} diff --git a/src/components/Global/UnsupportedWebViewScreen/__tests__/UnsupportedWebViewScreen.test.tsx b/src/components/Global/UnsupportedWebViewScreen/__tests__/UnsupportedWebViewScreen.test.tsx new file mode 100644 index 0000000000..ce47f989e0 --- /dev/null +++ b/src/components/Global/UnsupportedWebViewScreen/__tests__/UnsupportedWebViewScreen.test.tsx @@ -0,0 +1,66 @@ +/** @jest-environment jsdom */ +import React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' +import { UnsupportedWebViewScreen } from '../index' + +const mockGetPlatform = jest.fn() +const mockOpenExternalUrl = jest.fn(() => Promise.resolve()) +jest.mock('@/utils/capacitor', () => ({ + getPlatform: () => mockGetPlatform(), + openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...(args as [])), +})) + +const mockCaptureMessage = jest.fn() +jest.mock('@/utils/sentry-lazy', () => ({ + captureMessage: (...args: unknown[]) => mockCaptureMessage(...args), +})) + +describe('UnsupportedWebViewScreen', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // First in the file on purpose: the once-only latch is module state. + it('reports the population to Sentry once, tagged, with the user agent', () => { + mockGetPlatform.mockReturnValue('ios-native') + const first = renderWithIntl() + first.unmount() + renderWithIntl() + expect(mockCaptureMessage).toHaveBeenCalledTimes(1) + expect(mockCaptureMessage).toHaveBeenCalledWith( + expect.stringContaining('unsupported webview'), + expect.objectContaining({ + level: 'warning', + tags: { unsupported_webview: 'true' }, + extra: { userAgent: navigator.userAgent }, + }) + ) + }) + + it('sends android to the System WebView listing on Google Play', () => { + mockGetPlatform.mockReturnValue('android-native') + renderWithIntl() + expect(screen.getByRole('heading', { level: 1, name: 'Update needed to keep going' })).toBeInTheDocument() + expect(screen.getByText(/needs a newer Android System WebView/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Update Android System WebView' })) + expect(mockOpenExternalUrl).toHaveBeenCalledWith( + 'https://play.google.com/store/apps/details?id=com.google.android.webview' + ) + }) + + it('sends ios to the software-update help page', () => { + mockGetPlatform.mockReturnValue('ios-native') + renderWithIntl() + expect(screen.getByText(/newer version of iOS/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'See how to update iOS' })) + expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://support.apple.com/HT204204') + }) + + it('uses inline styles only — no class names to resolve', () => { + mockGetPlatform.mockReturnValue('android-native') + const { container } = renderWithIntl() + expect(container.querySelectorAll('[class]')).toHaveLength(0) + expect(screen.getByRole('main')).toHaveStyle({ display: 'flex' }) + }) +}) diff --git a/src/components/Global/UnsupportedWebViewScreen/index.tsx b/src/components/Global/UnsupportedWebViewScreen/index.tsx new file mode 100644 index 0000000000..cf12153fa3 --- /dev/null +++ b/src/components/Global/UnsupportedWebViewScreen/index.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect, type CSSProperties } from 'react' +import { useTranslations } from 'next-intl' +import { getPlatform, openExternalUrl } from '@/utils/capacitor' +import { captureMessage } from '@/utils/sentry-lazy' + +const BYPASS_KEY = 'unsupportedWebViewBypass' + +/** A session-scoped escape hatch, so a canary false positive never locks the app. */ +export function hasUnsupportedWebViewBypass(): boolean { + try { + return window.sessionStorage.getItem(BYPASS_KEY) === '1' + } catch { + return false + } +} + +function continueAnyway(): void { + try { + window.sessionStorage.setItem(BYPASS_KEY, '1') + } catch {} + window.location.reload() +} + +const ANDROID_WEBVIEW_STORE_URL = 'https://play.google.com/store/apps/details?id=com.google.android.webview' +const IOS_UPDATE_HELP_URL = 'https://support.apple.com/HT204204' + +// Inline styles only: this screen exists because the stylesheet cannot be +// parsed, so no Tailwind class resolves here. +const styles = { + root: { + minHeight: '100dvh', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '16px', + padding: '32px 24px', + boxSizing: 'border-box', + background: '#ffffff', + color: '#000000', + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', + textAlign: 'center', + }, + title: { margin: 0, fontSize: '24px', lineHeight: 1.2, fontWeight: 700 }, + body: { margin: 0, maxWidth: '320px', fontSize: '16px', lineHeight: 1.5 }, + cta: { + marginTop: '8px', + padding: '12px 24px', + border: '2px solid #000000', + borderRadius: '9999px', + background: '#000000', + color: '#ffffff', + fontSize: '16px', + fontWeight: 700, + cursor: 'pointer', + }, + secondary: { + marginTop: '4px', + padding: '8px', + border: 'none', + background: 'transparent', + color: '#5f646d', + fontSize: '14px', + textDecoration: 'underline', + cursor: 'pointer', + }, +} satisfies Record + +let reported = false + +export function UnsupportedWebViewScreen() { + const t = useTranslations('unsupportedWebView') + const platform = getPlatform() === 'android-native' ? 'android' : 'ios' + + useEffect(() => { + if (reported) return + reported = true + captureMessage('unsupported webview: required CSS features missing', { + level: 'warning', + tags: { unsupported_webview: 'true' }, + extra: { userAgent: navigator.userAgent }, + }) + }, []) + + return ( +
+

{t('title')}

+

{t(`body.${platform}`)}

+ + +
+ ) +} + +export default UnsupportedWebViewScreen diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx index 5792e721ec..8f94917bbe 100644 --- a/src/components/Migration/SunsetScreen.tsx +++ b/src/components/Migration/SunsetScreen.tsx @@ -30,7 +30,7 @@ export default function SunsetScreen() { // content centered right.
-
+
{/* centered on desktop to match the centered store CTA below */}

{t('sunset.heading')}

diff --git a/src/components/Setup/Setup.consts.tsx b/src/components/Setup/Setup.consts.tsx index 6ba4fccd2b..84184cebd2 100644 --- a/src/components/Setup/Setup.consts.tsx +++ b/src/components/Setup/Setup.consts.tsx @@ -34,6 +34,7 @@ export const setupSteps: ISetupStep[] = [ component: InstallPWA, showBackButton: false, showSkipButton: false, + showLoginButton: true, imageClassName: 'w-[50%] md:w-[30%] h-auto', titleClassName: 'text-heading-s', contentClassName: 'flex flex-col items-center justify-center gap-6', @@ -45,6 +46,7 @@ export const setupSteps: ISetupStep[] = [ component: InstallPWA, showBackButton: false, showSkipButton: true, + showLoginButton: true, imageClassName: 'w-[50%] md:w-[30%] h-auto mt-16 md:mt-0', }, { @@ -63,6 +65,7 @@ export const setupSteps: ISetupStep[] = [ component: JoinWaitlist, showBackButton: true, showSkipButton: false, + showLoginButton: true, contentClassName: 'flex flex-col items-center justify-center gap-6', }, { @@ -72,6 +75,7 @@ export const setupSteps: ISetupStep[] = [ component: SignupStep, showBackButton: true, showSkipButton: false, + showLoginButton: true, contentClassName: 'flex flex-col items-end pt-8 justify-center gap-6', }, { diff --git a/src/components/Setup/Setup.types.ts b/src/components/Setup/Setup.types.ts index f82a961ee0..ad44a17931 100644 --- a/src/components/Setup/Setup.types.ts +++ b/src/components/Setup/Setup.types.ts @@ -49,6 +49,8 @@ export interface ISetupStep { component: React.ComponentType showBackButton?: boolean showSkipButton?: boolean + /** Pre-auth steps offer Log In in the top-right group so a returning user is never trapped in signup. */ + showLoginButton?: boolean /** * The step component renders the description itself (e.g. only on one of * its sub-views), so the chrome must not also render it. diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index b6933f56db..4c94b00a6f 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -3,16 +3,12 @@ import { Button } from '@/components/0_Bruddle/Button' import { Divider } from '@/components/0_Bruddle/Divider' import { FieldError } from '@/components/0_Bruddle/FieldError' -import { isAlreadyReported } from '@/utils/webauthn.utils' -import { useToast } from '@/components/0_Bruddle/Toast' import ValidatedInput from '@/components/Global/ValidatedInput' import { useEffect, useState } from 'react' -import * as Sentry from '@sentry/nextjs' import { useSetupFlow } from '@/hooks/useSetupFlow' import { useAppDispatch } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' import { invitesApi } from '@/services/invites' -import { useLogin } from '@/hooks/useLogin' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { enableDemoMode, isDemoInviteCode } from '@/utils/demo' @@ -35,10 +31,8 @@ const JoinWaitlist = () => { const [isLoading, setisLoading] = useState(false) const [error, setError] = useState('') - const toast = useToast() const { handleNext } = useSetupFlow() const dispatch = useAppDispatch() - const { handleLoginClick } = useLogin() const router = useRouter() const queryClient = useQueryClient() @@ -81,28 +75,6 @@ const JoinWaitlist = () => { } } - const handleError = (error: unknown) => { - const errorCode = error instanceof Error && 'code' in error ? String(error.code) : undefined - const errorMessage = - errorCode === 'LOGIN_CANCELED' - ? t('waitlist.loginCanceled') - : errorCode === 'NO_PASSKEY' - ? t('waitlist.noPasskey') - : t('waitlist.loginUnexpectedError') - toast.error(errorMessage) - if (!isAlreadyReported(error)) { - Sentry.captureException(error, { extra: { errorCode } }) - } - } - - const _onLoginClick = async () => { - try { - await handleLoginClick() - } catch (e) { - handleError(e) - } - } - return (
{/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} diff --git a/src/components/Setup/Views/Residence.tsx b/src/components/Setup/Views/Residence.tsx index 25710dc157..194033e69b 100644 --- a/src/components/Setup/Views/Residence.tsx +++ b/src/components/Setup/Views/Residence.tsx @@ -6,6 +6,7 @@ import { deriveResidenceRestrictionsFrom } from '@/hooks/useResidenceRestriction import { useResidenceRestrictionSetsWithStatus } from '@/hooks/useResidenceRestrictionSets' import { useGeoLocation } from '@/hooks/useGeoLocation' import { useSetupFlow } from '@/hooks/useSetupFlow' +import { useBackHandler } from '@/hooks/useBackHandler' import { useAppDispatch, useSetupStore } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' import { isValidEmail } from '@/utils/format.utils' @@ -29,6 +30,10 @@ const ResidenceStep = () => { const { sets: restrictionSets, settled: restrictionSetsSettled } = useResidenceRestrictionSetsWithStatus() const [view, setView] = useState('select') + useBackHandler(() => { + if (!isLoading) setView('select') + return true + }, view !== 'select') const [partialRestriction, setPartialRestriction] = useState('card') const [showSecondCountry, setShowSecondCountry] = useState(!!secondResidenceCountry) const [email, setEmail] = useState('') diff --git a/src/components/Setup/Views/__tests__/Residence.test.tsx b/src/components/Setup/Views/__tests__/Residence.test.tsx index 2602f935dc..87e1de287e 100644 --- a/src/components/Setup/Views/__tests__/Residence.test.tsx +++ b/src/components/Setup/Views/__tests__/Residence.test.tsx @@ -9,12 +9,13 @@ * and the notify exit validates the email before capturing it. */ import React from 'react' -import { render as rtlRender, screen, fireEvent } from '@testing-library/react' +import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react' import posthog from 'posthog-js' import { IntlWrapper } from '@/test-utils/intl' import ResidenceStep from '@/components/Setup/Views/Residence' import { setupActions } from '@/redux/slices/setup-slice' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { dispatchBackPress, resetBackHandlersForTests } from '@/utils/back-handler' const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) @@ -26,8 +27,9 @@ jest.mock('@/redux/hooks', () => ({ })) const mockHandleNext = jest.fn() +let mockIsLoading = false jest.mock('@/hooks/useSetupFlow', () => ({ - useSetupFlow: () => ({ handleNext: mockHandleNext, isLoading: false }), + useSetupFlow: () => ({ handleNext: mockHandleNext, isLoading: mockIsLoading }), })) let mockGeoCountry: string | null = null @@ -60,6 +62,8 @@ jest.mock('@/hooks/useResidenceRestrictionSets', () => { describe('ResidenceStep', () => { beforeEach(() => { jest.clearAllMocks() + resetBackHandlersForTests() + mockIsLoading = false mockSetupState = { residenceCountry: '', secondResidenceCountry: '' } mockGeoCountry = null mockRestrictionSets = undefined @@ -335,4 +339,54 @@ describe('ResidenceStep', () => { expect(screen.queryByText('Heads up')).not.toBeInTheDocument() expect(screen.getByText('Have documents from more than one country?')).toBeInTheDocument() }) + + describe('hardware back', () => { + it('returns to the selector from a heads-up sub-view', () => { + mockSetupState.residenceCountry = 'CN' + render() + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + expect(screen.getByRole('heading', { level: 1, name: 'Heads up' })).toBeInTheDocument() + + let consumed = false + act(() => { + consumed = dispatchBackPress() + }) + expect(consumed).toBe(true) + expect(screen.queryByText('Heads up')).not.toBeInTheDocument() + expect(screen.getByText('Have documents from more than one country?')).toBeInTheDocument() + }) + + it('returns to the selector from the congrats view', () => { + mockSetupState.residenceCountry = 'BR' + render() + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + expect(screen.getByRole('heading', { level: 1, name: 'Good news' })).toBeInTheDocument() + + act(() => { + dispatchBackPress() + }) + expect(screen.queryByText('Good news')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument() + }) + + it('does not intercept on the selector itself', () => { + render() + expect(dispatchBackPress()).toBe(false) + }) + + it('consumes but holds the sub-view while the step is advancing', () => { + mockSetupState.residenceCountry = 'CN' + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + mockIsLoading = true + view.rerender() + + let consumed = false + act(() => { + consumed = dispatchBackPress() + }) + expect(consumed).toBe(true) + expect(screen.getByRole('heading', { level: 1, name: 'Heads up' })).toBeInTheDocument() + }) + }) }) diff --git a/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx b/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx index c05a91a699..e04a2e7c0a 100644 --- a/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx +++ b/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx @@ -6,16 +6,18 @@ import SignTestTransaction from '../SignTestTransaction' const WALLET = '0x1111111111111111111111111111111111111111' const mockRouterPush = jest.fn() +const mockRouterReplace = jest.fn() const mockAddAccount = jest.fn() const mockSendUserOp = jest.fn() let accounts: Array<{ type: string }> = [] // useAccountSetup is deliberately NOT mocked: the bug this locks down was a -// router.push inside finalizeAccountSetup, which no component-level mock of -// that hook could ever catch. +// router navigation inside finalizeAccountSetup, which no component-level mock +// of that hook could ever catch. The terminal leg replaces (never pushes) so +// hardware back from /home cannot land on a finished setup. jest.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockRouterPush }), + useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace }), useSearchParams: () => ({ get: () => null }), })) @@ -64,9 +66,11 @@ describe('SignTestTransaction — the account-ready screen', () => { await screen.findByText(/works right now/i) await waitFor(() => expect(mockAddAccount).toHaveBeenCalled()) expect(mockRouterPush).not.toHaveBeenCalled() + expect(mockRouterReplace).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: /go to my account/i })) - expect(mockRouterPush).toHaveBeenCalledWith('/home') + expect(mockRouterReplace).toHaveBeenCalledWith('/home') + expect(mockRouterPush).not.toHaveBeenCalled() }) it('consumes the stored route once, however fast the CTA is tapped', async () => { @@ -83,7 +87,7 @@ describe('SignTestTransaction — the account-ready screen', () => { fireEvent.click(cta) fireEvent.click(cta) - expect(mockRouterPush).toHaveBeenCalledTimes(1) - expect(mockRouterPush).toHaveBeenCalledWith('/receipt?id=abc') + expect(mockRouterReplace).toHaveBeenCalledTimes(1) + expect(mockRouterReplace).toHaveBeenCalledWith('/receipt?id=abc') }) }) diff --git a/src/components/Setup/__tests__/setup-entry.test.ts b/src/components/Setup/__tests__/setup-entry.test.ts new file mode 100644 index 0000000000..f8e295a2d9 --- /dev/null +++ b/src/components/Setup/__tests__/setup-entry.test.ts @@ -0,0 +1,149 @@ +/** @jest-environment jsdom */ +import { DeviceType } from '@/hooks/useGetDeviceType' +import { hasKnownDeviceCredentials, resolveSetupEntryStep, type SetupEntryInput } from '../setup-entry' + +const base: SetupEntryInput = { + isCapacitor: false, + deviceType: DeviceType.WEB, + isStandalonePWA: false, + hasInviteCode: false, + stepParam: null, + webSignupClosed: false, + knownDevice: false, +} + +describe('resolveSetupEntryStep', () => { + describe('known device (passkey credentials, no session) always lands on Log In', () => { + it.each([ + ['capacitor + invite code', { isCapacitor: true, hasInviteCode: true }], + ['capacitor + ?step=signup', { isCapacitor: true, stepParam: 'signup' }], + ['desktop web', {}], + ['desktop web + invite code', { hasInviteCode: true }], + ['android browser (not installed)', { deviceType: DeviceType.ANDROID }], + [ + 'android PWA + ?step=signup', + { deviceType: DeviceType.ANDROID, isStandalonePWA: true, stepParam: 'signup' }, + ], + ['ios', { deviceType: DeviceType.IOS, hasInviteCode: true }], + ])('%s', (_name, overrides) => { + expect(resolveSetupEntryStep({ ...base, ...overrides, knownDevice: true })).toBe('landing') + }) + }) + + describe('?step=login', () => { + it.each([ + ['capacitor', { isCapacitor: true }], + ['desktop web', {}], + ['android browser', { deviceType: DeviceType.ANDROID }], + ])('lands on Log In on %s, even with an invite code', (_name, overrides) => { + expect(resolveSetupEntryStep({ ...base, ...overrides, stepParam: 'login', hasInviteCode: true })).toBe( + 'landing' + ) + }) + }) + + describe('capacitor', () => { + it('lands on landing by default', () => { + expect(resolveSetupEntryStep({ ...base, isCapacitor: true })).toBe('landing') + }) + + it.each([ + ['invite code', { hasInviteCode: true }], + ['?step=signup', { stepParam: 'signup' }], + ])('skips the invite gate with %s', (_name, overrides) => { + expect(resolveSetupEntryStep({ ...base, isCapacitor: true, ...overrides })).toBe('signup') + }) + }) + + describe('web', () => { + it('desktop → pwa-install', () => { + expect(resolveSetupEntryStep(base)).toBe('pwa-install') + }) + + it('android browser → android-initial-pwa-install', () => { + expect(resolveSetupEntryStep({ ...base, deviceType: DeviceType.ANDROID })).toBe( + 'android-initial-pwa-install' + ) + }) + + it('android installed PWA → landing', () => { + expect(resolveSetupEntryStep({ ...base, deviceType: DeviceType.ANDROID, isStandalonePWA: true })).toBe( + 'landing' + ) + }) + + it('ios → landing', () => { + expect(resolveSetupEntryStep({ ...base, deviceType: DeviceType.IOS })).toBe('landing') + }) + + it.each([ + ['invite code', { hasInviteCode: true }], + ['?step=signup', { stepParam: 'signup' }], + ])('%s skips the invite gate on every device', (_name, overrides) => { + for (const deviceType of [DeviceType.WEB, DeviceType.ANDROID, DeviceType.IOS]) { + expect(resolveSetupEntryStep({ ...base, deviceType, ...overrides })).toBe('signup') + } + }) + + it('does not skip the landing gate while web signups are closed', () => { + expect( + resolveSetupEntryStep({ + ...base, + deviceType: DeviceType.IOS, + hasInviteCode: true, + webSignupClosed: true, + }) + ).toBe('landing') + expect(resolveSetupEntryStep({ ...base, stepParam: 'signup', webSignupClosed: true })).toBe('pwa-install') + }) + + it('an unknown ?step value changes nothing', () => { + expect(resolveSetupEntryStep({ ...base, stepParam: 'residence' })).toBe('pwa-install') + }) + }) +}) + +describe('hasKnownDeviceCredentials', () => { + const clearCookies = () => { + for (const entry of document.cookie.split(';')) { + const name = entry.trim().split('=')[0] + if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` + } + } + + beforeEach(() => { + clearCookies() + localStorage.clear() + }) + + it('is false on a fresh device', () => { + expect(hasKnownDeviceCredentials()).toBe(false) + }) + + it('is true when the web-authn-key cookie is set', () => { + document.cookie = `web-authn-key=${encodeURIComponent(JSON.stringify({ authenticatorId: 'abc' }))}; path=/` + expect(hasKnownDeviceCredentials()).toBe(true) + }) + + it('ignores an emptied web-authn-key cookie', () => { + document.cookie = 'web-authn-key=; path=/' + expect(hasKnownDeviceCredentials()).toBe(false) + }) + + it('is true when a user-preferences entry carries a webAuthnKey', () => { + localStorage.setItem('u-123:user-preferences', JSON.stringify({ webAuthnKey: { authenticatorId: 'abc' } })) + expect(hasKnownDeviceCredentials()).toBe(true) + }) + + it('ignores user-preferences without a key and unrelated entries', () => { + localStorage.setItem('u-123:user-preferences', JSON.stringify({ balanceHidden: true })) + localStorage.setItem('u-456:user-preferences', JSON.stringify({ webAuthnKey: undefined })) + localStorage.setItem('web-authn-key', 'not-a-preferences-entry') + expect(hasKnownDeviceCredentials()).toBe(false) + }) + + it('survives a malformed preferences entry', () => { + localStorage.setItem('u-123:user-preferences', '{not json') + expect(hasKnownDeviceCredentials()).toBe(false) + }) +}) diff --git a/src/components/Setup/components/SetupWrapper.tsx b/src/components/Setup/components/SetupWrapper.tsx index a01d481e2f..f09faf2a37 100644 --- a/src/components/Setup/components/SetupWrapper.tsx +++ b/src/components/Setup/components/SetupWrapper.tsx @@ -1,18 +1,24 @@ import starImage from '@/assets/icons/star.png' import { Button } from '@/components/0_Bruddle/Button' import CloudsBackground from '@/components/0_Bruddle/CloudsBackground' +import { useToast } from '@/components/0_Bruddle/Toast' import { Icon } from '@/components/Global/Icons/Icon' import { type BeforeInstallPromptEvent, type LayoutType, type ScreenId } from '@/components/Setup/Setup.types' import InstallPWA from '@/components/Setup/Views/InstallPWA' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState' import { DeviceType } from '@/hooks/useGetDeviceType' import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' +import { useLogin } from '@/hooks/useLogin' import { useMigrationFlag } from '@/hooks/useMigrationFlag' import { isCapacitor } from '@/utils/capacitor' +import { getPasskeyErrorSetupKey, isAlreadyReported } from '@/utils/webauthn.utils' +import * as Sentry from '@sentry/nextjs' import classNames from 'classnames' import { motion, useReducedMotion } from 'framer-motion' import { useTranslations } from 'next-intl' import Image from 'next/image' +import posthog from 'posthog-js' import { Children, type ReactNode, cloneElement, memo, type ReactElement, useState } from 'react' import { twMerge } from '@/utils/tw' @@ -33,6 +39,7 @@ interface SetupWrapperProps { showBackButton?: boolean showSkipButton?: boolean showLogoutButton?: boolean + showLoginButton?: boolean onBack?: () => void onSkip?: () => void onLogout?: () => void @@ -63,25 +70,75 @@ const STAR_POSITIONS = [ ] as const /** - * navigation component for back, skip, and logout buttons + * Log In for the pre-auth steps (install walls, waitlist, signup): a returning + * user whose entry link carried an invite code or ?step=signup used to have no + * way back to the passkey ceremony. Same click path as the landing step. + */ +function LoginButton() { + const t = useTranslations('setup') + const { handleLoginClick, isLoggingIn } = useLogin() + const toast = useToast() + + const onLoginClick = async () => { + try { + await handleLoginClick() + } catch (error) { + const errorCode = error instanceof Error && 'code' in error ? String(error.code) : undefined + // PasskeyError carries a curated English message; known codes have + // translated catalog copy, so prefer that. + const i18nKey = getPasskeyErrorSetupKey(error) + toast.error(i18nKey ? t(i18nKey) : (error instanceof Error && error.message) || t('loginFailed')) + if (!isAlreadyReported(error)) { + Sentry.captureException(error, { extra: { errorCode } }) + } + posthog.capture(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: errorCode }) + } + } + + return ( + + ) +} + +/** + * navigation component for back, skip, login and logout buttons * rendered at the top of the layout when any button is enabled */ const Navigation = memo(function Navigation({ showBackButton, showSkipButton, showLogoutButton, + showLoginButton, onBack, onSkip, onLogout, isLoggingOut, }: Pick< SetupWrapperProps, - 'showBackButton' | 'showSkipButton' | 'showLogoutButton' | 'onBack' | 'onSkip' | 'onLogout' | 'isLoggingOut' + | 'showBackButton' + | 'showSkipButton' + | 'showLogoutButton' + | 'showLoginButton' + | 'onBack' + | 'onSkip' + | 'onLogout' + | 'isLoggingOut' >) { const t = useTranslations('setup.navigation') - if (!showBackButton && !showSkipButton && !showLogoutButton) return null + if (!showBackButton && !showSkipButton && !showLogoutButton && !showLoginButton) return null + // Icons inherit currentColor: the stroke button inverts on hover/active, and + // a hard-coded fill vanished into the black background. return (
@@ -92,11 +149,12 @@ const Navigation = memo(function Navigation({ className="relative size-10 p-0 shadow-none after:absolute after:-inset-0.5" aria-label={t('goBack')} > - + )}
+ {showLoginButton && } {showSkipButton && ( )}
@@ -211,6 +269,7 @@ export const SetupWrapper = memo(function SetupWrapper({ showBackButton, showSkipButton, showLogoutButton, + showLoginButton, onBack, onSkip, onLogout, @@ -255,6 +314,7 @@ export const SetupWrapper = memo(function SetupWrapper({ showSkipButton || (screenId === 'pwa-install' && (!canInstall || deviceType === DeviceType.WEB)) } showLogoutButton={showLogoutButton} + showLoginButton={showLoginButton} onBack={onBack} onSkip={onSkip} onLogout={onLogout} diff --git a/src/components/Setup/components/__tests__/SetupWrapper.test.tsx b/src/components/Setup/components/__tests__/SetupWrapper.test.tsx new file mode 100644 index 0000000000..bb3e5ac18f --- /dev/null +++ b/src/components/Setup/components/__tests__/SetupWrapper.test.tsx @@ -0,0 +1,126 @@ +/** @jest-environment jsdom */ +/** + * Setup chrome: the back chevron must inherit currentColor (the stroke button + * inverts on hover/active, and a hard-coded black stroke vanished into it), and + * pre-auth steps expose Log In next to Skip so a returning user routed past the + * landing step can still reach the passkey ceremony. + */ +import React from 'react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import posthog from 'posthog-js' +import { renderWithIntl } from '@/test-utils/intl' +import en from '@/i18n/app/messages/en.json' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { SetupWrapper } from '../SetupWrapper' + +const mockHandleLoginClick = jest.fn(() => Promise.resolve()) +let mockIsLoggingIn = false +jest.mock('@/hooks/useLogin', () => ({ + useLogin: () => ({ handleLoginClick: mockHandleLoginClick, isLoggingIn: mockIsLoggingIn }), +})) + +const mockToastError = jest.fn() +jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: mockToastError }) })) + +jest.mock('@/hooks/useBravePWAInstallState', () => ({ useBravePWAInstallState: () => ({ isBrave: false }) })) +jest.mock('@/hooks/useKeepWebBypass', () => ({ useKeepWebBypass: () => false })) +jest.mock('@/hooks/useMigrationFlag', () => ({ useMigrationFlag: () => false })) +jest.mock('@/utils/capacitor', () => ({ ...jest.requireActual('@/utils/capacitor'), isCapacitor: () => false })) +jest.mock('@/utils/webauthn.utils', () => ({ + ...jest.requireActual('@/utils/webauthn.utils'), + isAlreadyReported: () => false, +})) +jest.mock('@/components/0_Bruddle/CloudsBackground', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Setup/Views/InstallPWA', () => ({ __esModule: true, default: () => null })) +jest.mock('framer-motion', () => ({ + useReducedMotion: () => true, + motion: { + div: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+ ), + }, +})) +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) + +const mockedCapture = posthog.capture as jest.Mock + +function renderWrapper(props: Partial> = {}) { + return renderWithIntl( + +
+ + ) +} + +describe('SetupWrapper navigation', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIsLoggingIn = false + }) + + it('renders the back chevron without a hard-coded stroke colour', () => { + renderWrapper({ showBackButton: true, onBack: jest.fn() }) + const svg = screen.getByRole('button', { name: 'Go back' }).querySelector('svg') + expect(svg).not.toBeNull() + expect(svg).not.toHaveAttribute('stroke', 'black') + expect(svg).toHaveAttribute('stroke', 'currentColor') + }) + + it('fires onBack from the back button', () => { + const onBack = jest.fn() + renderWrapper({ showBackButton: true, onBack }) + fireEvent.click(screen.getByRole('button', { name: 'Go back' })) + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('hides Log In unless the step asks for it', () => { + renderWrapper({ showBackButton: true }) + expect(screen.queryByRole('button', { name: 'Log In' })).not.toBeInTheDocument() + }) + + it('shows Log In next to Skip and runs the login ceremony', async () => { + renderWrapper({ showLoginButton: true, showSkipButton: true, onSkip: jest.fn() }) + expect(screen.getByRole('button', { name: 'Skip' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Log In' })) + await waitFor(() => expect(mockHandleLoginClick).toHaveBeenCalledTimes(1)) + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('shows Log In on its own when the step has no Skip or Back', () => { + renderWrapper({ showLoginButton: true }) + expect(screen.getByRole('button', { name: 'Log In' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Skip' })).not.toBeInTheDocument() + }) + + it('disables Log In while the ceremony is running', () => { + mockIsLoggingIn = true + renderWrapper({ showLoginButton: true }) + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('surfaces a failed login as a toast and an analytics event', async () => { + const failure = Object.assign(new Error('No passkey found'), { code: 'NO_PASSKEY' }) + mockHandleLoginClick.mockRejectedValueOnce(failure) + renderWrapper({ showLoginButton: true }) + fireEvent.click(screen.getByRole('button', { name: 'Log In' })) + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('No passkey found')) + expect(mockedCapture).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: 'NO_PASSKEY' }) + }) + + it('shows the catalog copy for a mapped passkey error code, not the curated English message', async () => { + const failure = Object.assign(new Error('Couldn’t reach Peanut’s servers (curated)'), { + name: 'PasskeyError', + code: 'NETWORK', + }) + mockHandleLoginClick.mockRejectedValueOnce(failure) + renderWrapper({ showLoginButton: true }) + fireEvent.click(screen.getByRole('button', { name: 'Log In' })) + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith(en.setup.passkey.serverUnreachable)) + expect(mockToastError).not.toHaveBeenCalledWith(failure.message) + }) +}) diff --git a/src/components/Setup/setup-entry.ts b/src/components/Setup/setup-entry.ts new file mode 100644 index 0000000000..2d93696bbe --- /dev/null +++ b/src/components/Setup/setup-entry.ts @@ -0,0 +1,78 @@ +import { USER_PREFERENCES_KEY_SUFFIX, WEB_AUTHN_COOKIE_KEY } from '@/constants/auth.consts' +import type { DeviceType } from '@/hooks/useGetDeviceType' +import type { ScreenId } from './Setup.types' + +export type SetupEntryStep = Extract + +export interface SetupEntryInput { + isCapacitor: boolean + deviceType: DeviceType + isStandalonePWA: boolean + /** An invite code from the store, the cookie or `?code=`. */ + hasInviteCode: boolean + /** The legacy `?step=` param: `signup` skips the invite gate, `login` lands on Log In. */ + stepParam: string | null + /** pwa-sunset notice window on web: signups are closed, so nothing may skip the landing gate. */ + webSignupClosed: boolean + /** Durable passkey credentials on this device (see hasKnownDeviceCredentials). */ + knownDevice: boolean +} + +/** + * The first screen /setup shows. Pure so the routing rules are testable; the + * page only maps the result to a step index. + * + * A live session never reaches this decision — the existing-session effect on + * the page redirects or prompts before any step renders — so `knownDevice` + * means "credentials without a session": a returning user who must be able to + * log in, whatever invite code or `?step=` the entry link carries. + */ +export function resolveSetupEntryStep(input: SetupEntryInput): SetupEntryStep { + if (input.knownDevice || input.stepParam === 'login') return 'landing' + // ?step=signup is what every campaign entrypoint sends; the invite cookie + // survives the PWA-install hop. Neither may skip the landing gate while web + // signups are closed, or claim/invite links deep-link into a closed form. + const skipInviteGate = (input.hasInviteCode || input.stepParam === 'signup') && !input.webSignupClosed + if (input.isCapacitor) return skipInviteGate ? 'signup' : 'landing' + if (skipInviteGate) return 'signup' + if (input.deviceType === 'android') return input.isStandalonePWA ? 'landing' : 'android-initial-pwa-install' + if (input.deviceType === 'ios') return 'landing' + return 'pwa-install' +} + +function hasCookie(key: string): boolean { + return document.cookie.split(';').some((entry) => { + const [name, ...value] = entry.trim().split('=') + return name === key && value.join('=') !== '' + }) +} + +/** + * True when this device holds a passkey credential from an earlier + * registration: the `web-authn-key` cookie, or a `webAuthnKey` inside any + * `:user-preferences` entry (the cookie can expire first). + */ +function hasStoredWebAuthnKey(raw: string | null): boolean { + try { + const prefs: unknown = JSON.parse(raw ?? 'null') + return !!prefs && typeof prefs === 'object' && !!(prefs as { webAuthnKey?: unknown }).webAuthnKey + } catch { + return false + } +} + +export function hasKnownDeviceCredentials(): boolean { + if (typeof document === 'undefined') return false + try { + if (hasCookie(WEB_AUTHN_COOKIE_KEY)) return true + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index) + if (key?.endsWith(USER_PREFERENCES_KEY_SUFFIX) && hasStoredWebAuthnKey(localStorage.getItem(key))) { + return true + } + } + } catch { + // storage unavailable — treat as an unknown device + } + return false +} diff --git a/src/components/TransactionDetails/ReceiptActions.tsx b/src/components/TransactionDetails/ReceiptActions.tsx index 98c0ae5ab4..065f25dc36 100644 --- a/src/components/TransactionDetails/ReceiptActions.tsx +++ b/src/components/TransactionDetails/ReceiptActions.tsx @@ -20,8 +20,10 @@ import { buildSplitBillRequestUrl } from './splitBill.utils' import { EHistoryUserRole } from '@/hooks/useTransactionHistory' import { useActivationStatus } from '@/hooks/useActivationStatus' import { useUserStore } from '@/redux/hooks' +import { openExternalUrl } from '@/utils/capacitor' import { generateInviteCodeLink } from '@/utils/general.utils' import { getReceiptUrl, isTestTransaction } from '@/utils/history.utils' +import { resolveInAppNavigation } from '@/utils/native-routes' type CancelLinkState = 'idle' | 'cancelling' | 'cancelled' @@ -110,6 +112,16 @@ export function ReceiptActions({ if (ok) onClose() } + // The request link is an absolute peanut.me URL; assigning it to + // window.location is an off-origin navigation the native WebView hands to + // the OS, so it is resolved to an in-app route first. + const handlePay = () => { + const target = resolveInAppNavigation(transaction.extraDataForDrawer?.link ?? '') + if (!target) return + if (target.kind === 'push') router.push(target.path) + else openExternalUrl(target.url).catch((err) => console.warn('failed to open request link:', err)) + } + const handleCancelSendLink = async () => { if (!setIsLoading || !onClose) return setIsLoading(true) @@ -187,13 +199,7 @@ export function ReceiptActions({ {isPendingRequestee && setIsLoading && onClose && (
- diff --git a/src/components/TransactionDetails/__tests__/ReceiptActions.pay.test.tsx b/src/components/TransactionDetails/__tests__/ReceiptActions.pay.test.tsx new file mode 100644 index 0000000000..6a58187e5d --- /dev/null +++ b/src/components/TransactionDetails/__tests__/ReceiptActions.pay.test.tsx @@ -0,0 +1,113 @@ +/** + * The Pay CTA on a received request used to assign the absolute peanut.me + * request link to window.location. Inside the Capacitor WebView that is an + * off-origin top-level navigation the shell hands to the OS, so the tap left + * the app. The link must resolve to an in-app route instead. + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { EHistoryUserRole } from '@/hooks/useTransactionHistory' +import type { TransactionDetails } from '../transactionTransformer' +import type { ReceiptViewModel } from '../useReceiptViewModel' + +const mockPush = jest.fn() + +jest.mock('@/i18n/app/useAppTranslations', () => ({ useAppTranslations: () => (key: string) => key })) +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) +// isCapacitor() runs at module load deep in the import chain, so the mocks +// must exist before any const in this file does. +jest.mock('@/utils/capacitor', () => ({ + ...jest.requireActual('@/utils/capacitor'), + isCapacitor: jest.fn(), + openExternalUrl: jest.fn(), +})) +import { isCapacitor, openExternalUrl } from '@/utils/capacitor' +const mockIsCapacitor = isCapacitor as jest.Mock +const mockOpenExternalUrl = openExternalUrl as jest.Mock +jest.mock('@/redux/hooks', () => ({ useUserStore: () => ({ user: { user: { username: 'payer' } } }) })) +jest.mock('@/hooks/useActivationStatus', () => ({ useActivationStatus: () => ({ isActivated: false }) })) +jest.mock('../useReceiptActions', () => ({ + useReceiptActions: () => ({ closeRequest: jest.fn(), rejectRequest: jest.fn(), cancelSendLink: jest.fn() }), +})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => ( + + ), +})) +jest.mock('@/components/Global/CancelSendLinkDrawer', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => null })) +jest.mock('@/components/Global/ShareButton', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Setup/Views/SignTestTransaction', () => ({ PasskeyDocsLink: () => null })) +jest.mock('../provider-actions/CancelDepositActions', () => ({ CancelDepositActions: () => null })) +jest.mock('../ReceiptReferralNudge', () => ({ ReceiptReferralNudge: () => null })) +jest.mock('../ReceiptSupportLink', () => ({ ReceiptSupportLink: () => null })) +jest.mock('../DownloadReceiptPdfLink', () => ({ DownloadReceiptPdfLink: () => null })) + +import { ReceiptActions } from '../ReceiptActions' + +const vm = { + isPendingBankRequest: false, + isPendingRequestee: true, + isPendingRequester: false, + isPendingSentLink: false, +} as unknown as ReceiptViewModel + +function renderWithLink(link: string) { + const transaction = { + id: 'tx-1', + status: 'pending', + extraDataForDrawer: { + originalType: 'REQUEST', + originalUserRole: EHistoryUserRole.RECIPIENT, + link, + }, + } as unknown as TransactionDetails + render( + + ) +} + +beforeEach(() => { + jest.clearAllMocks() + mockOpenExternalUrl.mockResolvedValue(undefined) +}) + +describe('ReceiptActions — Pay on a received request', () => { + test('pushes the native pay-request route instead of leaving the WebView', () => { + mockIsCapacitor.mockReturnValue(true) + renderWithLink('https://peanut.me/alice?chargeId=charge-1') + + fireEvent.click(screen.getByText('actions.pay')) + + expect(mockPush).toHaveBeenCalledWith('/pay-request?chargeId=charge-1') + expect(mockOpenExternalUrl).not.toHaveBeenCalled() + }) + + test('pushes the same-origin path on web', () => { + mockIsCapacitor.mockReturnValue(false) + renderWithLink(`${window.location.origin}/alice?chargeId=charge-1`) + + fireEvent.click(screen.getByText('actions.pay')) + + expect(mockPush).toHaveBeenCalledWith('/alice?chargeId=charge-1') + }) + + test.each(['https://example.com/pay', 'javascript:alert(1)'])('never opens an untrusted link (%s)', (link) => { + mockIsCapacitor.mockReturnValue(false) + renderWithLink(link) + + fireEvent.click(screen.getByText('actions.pay')) + + expect(mockOpenExternalUrl).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + }) +}) diff --git a/src/constants/auth.consts.ts b/src/constants/auth.consts.ts new file mode 100644 index 0000000000..07a2ddaf67 --- /dev/null +++ b/src/constants/auth.consts.ts @@ -0,0 +1,4 @@ +// Durable passkey credential markers a device keeps after logout; their +// presence is what makes /setup offer Log In before signup. +export const WEB_AUTHN_COOKIE_KEY = 'web-authn-key' +export const USER_PREFERENCES_KEY_SUFFIX = ':user-preferences' diff --git a/src/features/home/__tests__/HomeActionDrawers.test.tsx b/src/features/home/__tests__/HomeActionDrawers.test.tsx index bfb22fa9d6..c7788b3039 100644 --- a/src/features/home/__tests__/HomeActionDrawers.test.tsx +++ b/src/features/home/__tests__/HomeActionDrawers.test.tsx @@ -1,6 +1,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { withNuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' import { HomeActionDrawers } from '../components/HomeActionDrawers' +import { resetBottomNavVisibilityForTests, useBottomNavHidden } from '@/utils/bottom-nav-visibility' + +const NavProbe = () => {String(useBottomNavHidden())} // F-28: the real nuqs pipeline runs (parser, enum validation, url writes) via // the official testing adapter — the old suite mocked all of nuqs, so the @@ -37,6 +40,7 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks() + resetBottomNavVisibilityForTests() }) const renderWithUrl = (search: string, onUrlUpdate?: (e: UrlUpdateEvent) => void) => @@ -79,4 +83,31 @@ describe('HomeActionDrawers', () => { // withdraw is reachable via the SEND drawer only (product ruling) expect(screen.queryByTestId('home-drawer-add-withdraw')).not.toBeInTheDocument() }) + + it('hides the bottom nav while open and releases the hold once closed', async () => { + render( + <> + + + , + { wrapper: withNuqsTestingAdapter({ searchParams: '?drawer=send' }) } + ) + + expect(screen.getByTestId('nav-hidden')).toHaveTextContent('true') + + fireEvent.click(screen.getByTestId('home-drawer-send-withdraw')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/withdraw')) + await waitFor(() => expect(screen.getByTestId('nav-hidden')).toHaveTextContent('false')) + }) + + it('leaves the bottom nav alone when no drawer is open', () => { + render( + <> + + + , + { wrapper: withNuqsTestingAdapter({ searchParams: '' }) } + ) + expect(screen.getByTestId('nav-hidden')).toHaveTextContent('false') + }) }) diff --git a/src/features/home/components/HomeActionDrawers.tsx b/src/features/home/components/HomeActionDrawers.tsx index 6722ffcb51..311210f135 100644 --- a/src/features/home/components/HomeActionDrawers.tsx +++ b/src/features/home/components/HomeActionDrawers.tsx @@ -77,8 +77,8 @@ export function HomeActionDrawers() { } return ( - !isOpen && setDrawer(null)}> - + !isOpen && setDrawer(null)} hideBottomNav> + {content && (
diff --git a/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx b/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx index 19dc978d04..52a1b12c2e 100644 --- a/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx +++ b/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx @@ -6,13 +6,14 @@ import { useAccountSetup } from '../useAccountSetup' import { useLogin } from '../useLogin' const mockRouterPush = jest.fn() +const mockRouterReplace = jest.fn() const mockHandleLogin = jest.fn() const mockAddAccount = jest.fn() const mockToastError = jest.fn() let explicitRedirect: string | null = null jest.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockRouterPush }), + useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace }), useSearchParams: () => ({ get: (key: string) => (key === 'redirect_uri' ? explicitRedirect : null), }), @@ -57,7 +58,9 @@ describe('post-auth redirect consumers', () => { act(() => expect(result.current.handleRedirect()).toBe(true)) - expect(mockRouterPush).toHaveBeenCalledWith(FINANCIAL_REDIRECT) + // the setup→destination leg replaces: back from there must not re-enter a finished /setup + expect(mockRouterReplace).toHaveBeenCalledWith(FINANCIAL_REDIRECT) + expect(mockRouterPush).not.toHaveBeenCalled() expect(getRedirectUrl()).toBeNull() }) @@ -72,6 +75,7 @@ describe('post-auth redirect consumers', () => { expect(mockAddAccount).toHaveBeenCalled() expect(mockRouterPush).not.toHaveBeenCalled() + expect(mockRouterReplace).not.toHaveBeenCalled() // the redirect is still queued for the CTA to consume expect(getRedirectUrl()).toBe(CAMPAIGN_REDIRECT) }) diff --git a/src/hooks/__tests__/useBackHandler.test.tsx b/src/hooks/__tests__/useBackHandler.test.tsx new file mode 100644 index 0000000000..a03cb2fdf4 --- /dev/null +++ b/src/hooks/__tests__/useBackHandler.test.tsx @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react' +import { useBackHandler } from '@/hooks/useBackHandler' +import { dispatchBackPress, registerBackHandler, resetBackHandlersForTests } from '@/utils/back-handler' + +describe('useBackHandler', () => { + beforeEach(() => { + resetBackHandlersForTests() + }) + + it('registers while enabled and consumes the press', () => { + const handler = jest.fn(() => true) + renderHook(() => useBackHandler(handler)) + + expect(dispatchBackPress()).toBe(true) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('does not register while disabled', () => { + const handler = jest.fn(() => true) + renderHook(() => useBackHandler(handler, false)) + + expect(dispatchBackPress()).toBe(false) + expect(handler).not.toHaveBeenCalled() + }) + + it('unregisters when enabled flips false and re-registers when it flips true', () => { + const handler = jest.fn(() => true) + const { rerender } = renderHook(({ enabled }) => useBackHandler(handler, enabled), { + initialProps: { enabled: true }, + }) + + rerender({ enabled: false }) + expect(dispatchBackPress()).toBe(false) + + rerender({ enabled: true }) + expect(dispatchBackPress()).toBe(true) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('unregisters on unmount', () => { + const handler = jest.fn(() => true) + const { unmount } = renderHook(() => useBackHandler(handler)) + + unmount() + expect(dispatchBackPress()).toBe(false) + }) + + it('invokes the latest handler without moving its stack position', () => { + const first = jest.fn(() => true) + const second = jest.fn(() => true) + const { rerender } = renderHook(({ handler }) => useBackHandler(handler), { + initialProps: { handler: first }, + }) + // registered after the hook, so it sits above it in the stack + const above = jest.fn(() => false) + registerBackHandler(above) + + rerender({ handler: second }) + + expect(dispatchBackPress()).toBe(true) + expect(above).toHaveBeenCalledTimes(1) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/__tests__/useHostedVerification.test.tsx b/src/hooks/__tests__/useHostedVerification.test.tsx new file mode 100644 index 0000000000..82b1d5d6e4 --- /dev/null +++ b/src/hooks/__tests__/useHostedVerification.test.tsx @@ -0,0 +1,100 @@ +/** @jest-environment jsdom */ +/** + * Native return signals for the hosted-verification wait. The in-app browser + * emits `browserFinished` when the user swipes the sheet away, but a universal + * link closes it programmatically (closeInAppBrowser), which on iOS never + * emits it — the document event is that second signal. Each must refetch. + */ +import { act, renderHook, waitFor } from '@testing-library/react' +import { useHostedVerification } from '../useHostedVerification' + +const CLOSED_EVENT = 'peanut:in-app-browser-closed' + +const mockFetchUser = jest.fn(() => Promise.resolve()) +jest.mock('@/context/authContext', () => ({ useAuth: () => ({ fetchUser: mockFetchUser }) })) + +jest.mock('@/app/actions/sumsub', () => ({ + startHostedVerification: jest.fn(() => Promise.resolve({ url: 'https://bridge.withpersona.com/verify' })), +})) + +const mockOpenExternalUrl = jest.fn(() => Promise.resolve()) +jest.mock('@/utils/capacitor', () => ({ + isNativeBridge: () => true, + openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...(args as [])), + IN_APP_BROWSER_CLOSED_EVENT: 'peanut:in-app-browser-closed', +})) + +const listeners: Record void> = {} +const mockRemove = jest.fn() +const mockAddListener = jest.fn((name: string, cb: () => void) => { + listeners[name] = cb + return Promise.resolve({ remove: mockRemove }) +}) +// Virtual, like every other suite that mocks the plugin: the hook reaches it +// through a dynamic import, and a non-virtual mock left the listener +// unregistered on the Node 20 CI runners. +jest.mock( + '@capacitor/browser', + () => ({ Browser: { addListener: (name: string, cb: () => void) => mockAddListener(name, cb) } }), + { virtual: true } +) + +describe('useHostedVerification (native)', () => { + beforeEach(() => { + jest.clearAllMocks() + mockAddListener.mockImplementation((name: string, cb: () => void) => { + listeners[name] = cb + return Promise.resolve({ remove: mockRemove }) + }) + for (const key of Object.keys(listeners)) delete listeners[key] + }) + + const startAndArm = async () => { + const hook = renderHook(() => useHostedVerification('bridge-hosted')) + await act(async () => { + await hook.result.current.start() + }) + expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://bridge.withpersona.com/verify') + // the dynamic @capacitor/browser import chain settles a few ticks later + await waitFor(() => expect(mockAddListener).toHaveBeenCalledWith('browserFinished', expect.any(Function))) + return hook + } + + it('refetches once per browserFinished', async () => { + await startAndArm() + expect(mockFetchUser).not.toHaveBeenCalled() + act(() => listeners.browserFinished()) + expect(mockFetchUser).toHaveBeenCalledTimes(1) + act(() => listeners.browserFinished()) + expect(mockFetchUser).toHaveBeenCalledTimes(2) + }) + + it('refetches once per programmatic close (the universal-link return leg)', async () => { + await startAndArm() + act(() => { + document.dispatchEvent(new CustomEvent(CLOSED_EVENT)) + }) + expect(mockFetchUser).toHaveBeenCalledTimes(1) + act(() => listeners.browserFinished()) + expect(mockFetchUser).toHaveBeenCalledTimes(2) + }) + + it('does not listen before the flow was started', () => { + renderHook(() => useHostedVerification('bridge-hosted')) + act(() => { + document.dispatchEvent(new CustomEvent(CLOSED_EVENT)) + }) + expect(mockFetchUser).not.toHaveBeenCalled() + expect(listeners.browserFinished).toBeUndefined() + }) + + it('removes both listeners on unmount', async () => { + const hook = await startAndArm() + hook.unmount() + expect(mockRemove).toHaveBeenCalledTimes(1) + act(() => { + document.dispatchEvent(new CustomEvent(CLOSED_EVENT)) + }) + expect(mockFetchUser).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/__tests__/useNativeAppLinks.test.tsx b/src/hooks/__tests__/useNativeAppLinks.test.tsx index 1cd8a82528..7c6ca3519d 100644 --- a/src/hooks/__tests__/useNativeAppLinks.test.tsx +++ b/src/hooks/__tests__/useNativeAppLinks.test.tsx @@ -8,10 +8,13 @@ import { restoreDeferredContext } from '@/utils/deferred-link' import { markDeepLinkNavigated, resetDeepLinkStateForTests } from '@/utils/deep-link-state' import { getOneSignalAdapter } from '@/services/onesignal' import { BASE_URL } from '@/constants/general.consts' +import { App } from '@capacitor/app' +import { registerBackHandler, resetBackHandlersForTests } from '@/utils/back-handler' const push = jest.fn() +const back = jest.fn() jest.mock('next/navigation', () => ({ - useRouter: () => ({ push, back: jest.fn() }), + useRouter: () => ({ push, back }), })) jest.mock('@sentry/nextjs', () => ({ captureMessage: jest.fn() })) @@ -52,6 +55,7 @@ beforeEach(() => { // Module state + the launch-url guard outlive a test: without these resets // an earlier test's navigation suppresses the next test's launch dispatch. resetDeepLinkStateForTests() + resetBackHandlersForTests() sessionStorage.clear() }) @@ -113,6 +117,50 @@ describe('useNativeAppLinks deferred restore wiring', () => { }) }) +describe('hardware back button', () => { + type BackButtonCallback = (event: { canGoBack: boolean }) => void + + const getBackButtonCallback = async (): Promise => { + const addListener = App.addListener as jest.Mock + await waitFor(() => expect(addListener.mock.calls.some(([name]) => name === 'backButton')).toBe(true)) + return addListener.mock.calls.find(([name]) => name === 'backButton')![1] + } + + it('lets a registered handler consume the press before any navigation', async () => { + renderHook(() => useNativeAppLinks()) + const onBack = await getBackButtonCallback() + const handler = jest.fn(() => true) + registerBackHandler(handler) + + onBack({ canGoBack: true }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(back).not.toHaveBeenCalled() + expect(App.minimizeApp).not.toHaveBeenCalled() + }) + + it('walks history when nothing consumed the press and there is history', async () => { + renderHook(() => useNativeAppLinks()) + const onBack = await getBackButtonCallback() + registerBackHandler(() => false) + + onBack({ canGoBack: true }) + + expect(back).toHaveBeenCalledTimes(1) + expect(App.minimizeApp).not.toHaveBeenCalled() + }) + + it('minimizes the app when nothing consumed the press and there is no history', async () => { + renderHook(() => useNativeAppLinks()) + const onBack = await getBackButtonCallback() + + onBack({ canGoBack: false }) + + expect(back).not.toHaveBeenCalled() + expect(App.minimizeApp).toHaveBeenCalledTimes(1) + }) +}) + describe('launch-url replay guard', () => { it('stamps the launch url even when RootRedirect already routed it, so a webview reload cannot replay it', async () => { launchUrl = 'https://peanut.me/claim?i=abc' diff --git a/src/hooks/__tests__/useOtaChannel.test.ts b/src/hooks/__tests__/useOtaChannel.test.ts index a6fedf0a07..a608a34664 100644 --- a/src/hooks/__tests__/useOtaChannel.test.ts +++ b/src/hooks/__tests__/useOtaChannel.test.ts @@ -9,29 +9,32 @@ import { useOtaChannel } from '../useOtaChannel' const status = { channel: null as string | null, - bundleVersion: '1.1.10846', + bundleVersion: '1.1.10846' as string | null, deviceId: 'abc-123', onBuiltinBundle: false, } -const pending = { value: true } +const BETA_BUNDLE = '1.1.10846' +const pending = { value: BETA_BUNDLE as string | null } jest.mock('@/utils/capacitor', () => ({ isNativeBridge: () => true })) jest.mock('@/utils/capgo-updater', () => ({ BETA_OTA_CHANNEL: 'staging', + UNKNOWN_BETA_EXIT_BUNDLE: '1', OtaChannelClosedError: class extends Error {}, OtaChannelOverrideError: class extends Error {}, OtaChannelUnknownError: class extends Error {}, OtaResetFailedError: class extends Error {}, readOtaChannelStatus: () => Promise.resolve(status), - hasPendingBetaExit: () => pending.value, + pendingBetaExitBundle: () => pending.value, clearPendingBetaExit: () => { - pending.value = false + pending.value = null }, })) beforeEach(() => { - pending.value = true + pending.value = BETA_BUNDLE status.channel = null + status.bundleVersion = BETA_BUNDLE status.onBuiltinBundle = false }) @@ -43,17 +46,49 @@ it('still reads as beta while an exit is owed', async () => { it('clears the marker once the store bundle is the one running', async () => { status.onBuiltinBundle = true + status.bundleVersion = null const { result } = renderHook(() => useOtaChannel()) await waitFor(() => expect(result.current.status).not.toBeNull()) - expect(pending.value).toBe(false) + expect(pending.value).toBeNull() expect(result.current.isBeta).toBe(false) }) +// The reset lands on the store shell, whose JS may predate the marker and never +// clear it; by the time this code runs again a production OTA has replaced the +// builtin bundle. The exit is still done — the beta bundle is gone. +it('clears the marker once a production OTA has replaced the beta bundle', async () => { + status.channel = 'production' + status.bundleVersion = '1.1.3' + const { result } = renderHook(() => useOtaChannel()) + await waitFor(() => expect(result.current.status).not.toBeNull()) + expect(pending.value).toBeNull() + expect(result.current.isBeta).toBe(false) +}) + +it('keeps the marker while the recorded beta bundle is still running', async () => { + status.channel = 'production' + const { result } = renderHook(() => useOtaChannel()) + await waitFor(() => expect(result.current.status).not.toBeNull()) + expect(pending.value).toBe(BETA_BUNDLE) + expect(result.current.isBeta).toBe(true) +}) + +// A marker written before the bundle was recorded can only be settled by the +// builtin bundle — any other running version is indistinguishable from beta. +it('settles a legacy marker only on the builtin bundle', async () => { + pending.value = '1' + status.bundleVersion = '1.1.3' + const { result } = renderHook(() => useOtaChannel()) + await waitFor(() => expect(result.current.status).not.toBeNull()) + expect(pending.value).toBe('1') + expect(result.current.isBeta).toBe(true) +}) + it('keeps the marker while the channel is still beta', async () => { status.channel = 'staging' status.onBuiltinBundle = true await act(async () => { renderHook(() => useOtaChannel()) }) - expect(pending.value).toBe(true) + expect(pending.value).toBe(BETA_BUNDLE) }) diff --git a/src/hooks/__tests__/useSetupBackHandler.test.tsx b/src/hooks/__tests__/useSetupBackHandler.test.tsx new file mode 100644 index 0000000000..79a69ba4c9 --- /dev/null +++ b/src/hooks/__tests__/useSetupBackHandler.test.tsx @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react' +import { useSetupBackHandler } from '@/hooks/useSetupBackHandler' +import { dispatchBackPress, resetBackHandlersForTests } from '@/utils/back-handler' +import { minimizeNativeApp } from '@/utils/capacitor' +import { type ISetupStep } from '@/components/Setup/Setup.types' + +jest.mock('@/utils/capacitor', () => ({ + minimizeNativeApp: jest.fn(() => Promise.resolve()), +})) + +const stepWithBack = { screenId: 'signup', showBackButton: true } as unknown as ISetupStep +const stepWithoutBack = { screenId: 'sign-test-transaction', showBackButton: false } as unknown as ISetupStep +const stepUndefinedBack = { screenId: 'landing' } as unknown as ISetupStep + +describe('useSetupBackHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + resetBackHandlersForTests() + }) + + it('steps back when the step shows a back button and stepping back is allowed', () => { + const onBack = jest.fn() + renderHook(() => useSetupBackHandler({ step: stepWithBack, canStepBack: true, onBack })) + + expect(dispatchBackPress()).toBe(true) + expect(onBack).toHaveBeenCalledTimes(1) + expect(minimizeNativeApp).not.toHaveBeenCalled() + }) + + it.each([ + ['showBackButton false', stepWithoutBack, true], + ['showBackButton undefined', stepUndefinedBack, true], + ['no step yet', undefined, true], + ['canStepBack false', stepWithBack, false], + ])('minimizes the app instead of navigating when %s', (_label, step, canStepBack) => { + const onBack = jest.fn() + renderHook(() => useSetupBackHandler({ step, canStepBack, onBack })) + + expect(dispatchBackPress()).toBe(true) + expect(onBack).not.toHaveBeenCalled() + expect(minimizeNativeApp).toHaveBeenCalledTimes(1) + }) + + it('always consumes the press so the native listener never reaches router.back', () => { + renderHook(() => useSetupBackHandler({ step: undefined, canStepBack: false, onBack: jest.fn() })) + expect(dispatchBackPress()).toBe(true) + }) + + it('reads the latest props on each press', () => { + const onBack = jest.fn() + const { rerender } = renderHook(({ step, canStepBack }) => useSetupBackHandler({ step, canStepBack, onBack }), { + initialProps: { step: stepWithBack as ISetupStep | undefined, canStepBack: false }, + }) + + dispatchBackPress() + expect(onBack).not.toHaveBeenCalled() + + rerender({ step: stepWithBack, canStepBack: true }) + dispatchBackPress() + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('unregisters on unmount', () => { + const { unmount } = renderHook(() => + useSetupBackHandler({ step: stepWithBack, canStepBack: true, onBack: jest.fn() }) + ) + unmount() + expect(dispatchBackPress()).toBe(false) + }) +}) diff --git a/src/hooks/__tests__/useSetupStepUrlSync.test.tsx b/src/hooks/__tests__/useSetupStepUrlSync.test.tsx index c72ca35245..7ed253df7b 100644 --- a/src/hooks/__tests__/useSetupStepUrlSync.test.tsx +++ b/src/hooks/__tests__/useSetupStepUrlSync.test.tsx @@ -6,6 +6,9 @@ import { type ISetupStep } from '@/components/Setup/Setup.types' jest.mock('posthog-js', () => ({ capture: jest.fn() })) +let mockNativeBridge = false +jest.mock('@/utils/capacitor', () => ({ isNativeBridge: () => mockNativeBridge })) + const mockedCapture = posthog.capture as jest.MockedFunction const steps = [ @@ -39,6 +42,7 @@ describe('useSetupStepUrlSync', () => { // which would leak call history recorded before a test attaches it jest.restoreAllMocks() jest.clearAllMocks() + mockNativeBridge = false window.history.replaceState(null, '', '/setup') }) @@ -133,4 +137,33 @@ describe('useSetupStepUrlSync', () => { expect(goToScreen).not.toHaveBeenCalled() }) + + describe('native bridge', () => { + it('mirrors step advances with replaceState so hardware back never walks the mirror', () => { + mockNativeBridge = true + const { rerender } = render({ enabled: true, step: stepById('landing') }) + const pushSpy = jest.spyOn(window.history, 'pushState') + const replaceSpy = jest.spyOn(window.history, 'replaceState') + + rerender({ enabled: true, step: stepById('welcome') }) + rerender({ enabled: true, step: stepById('signup') }) + + expect(pushSpy).not.toHaveBeenCalled() + expect(replaceSpy).toHaveBeenCalledTimes(2) + expect(window.location.search).toBe('?screen=signup') + expect(mockedCapture).toHaveBeenLastCalledWith( + ANALYTICS_EVENTS.SIGNUP_STEP_VIEWED, + expect.objectContaining({ screen_id: 'signup', nav_type: 'forward' }) + ) + }) + + it('keeps pushing history entries on the web', () => { + const { rerender } = render({ enabled: true, step: stepById('landing') }) + const pushSpy = jest.spyOn(window.history, 'pushState') + + rerender({ enabled: true, step: stepById('welcome') }) + + expect(pushSpy).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx index 1d03253181..f849000674 100644 --- a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx +++ b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx @@ -98,6 +98,7 @@ jest.mock('@/utils/walletCredential.utils', () => ({ jest.mock('@/utils/webauthn.utils', () => ({ capturePasskeySignFailure: jest.fn(), classifyPasskeyError: () => ({ code: 'UNKNOWN', message: 'unknown' }), + normalizePasskeyServerError: (e: unknown) => e, })) jest.mock('@sentry/nextjs', () => ({ captureException: (...args: unknown[]) => mockCaptureException(...args) })) jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) diff --git a/src/hooks/__tests__/useZeroDev-login-failure.test.tsx b/src/hooks/__tests__/useZeroDev-login-failure.test.tsx new file mode 100644 index 0000000000..83af2a90f2 --- /dev/null +++ b/src/hooks/__tests__/useZeroDev-login-failure.test.tsx @@ -0,0 +1,158 @@ +import { act, renderHook } from '@testing-library/react' +import { useZeroDev } from '../useZeroDev' +import { clearAuthState } from '@/utils/auth.utils' + +const mockDispatch = jest.fn() +const mockCaptureException = jest.fn() +const mockToWebAuthnKey = jest.fn() + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { user: { userId: 'u1', username: 'alice' } }, logoutUser: jest.fn() }), +})) +jest.mock('@/context/kernelClient.context', () => ({ + useKernelClient: () => ({ + setWebAuthnKey: jest.fn(), + getClientForChain: jest.fn(), + ensureClientForChain: jest.fn(), + }), +})) +jest.mock('@/context/loadingStates.context', () => { + const React = jest.requireActual('react') + return { loadingStateContext: React.createContext({ setLoadingState: jest.fn() }) } +}) +jest.mock('@/redux/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useSetupStore: () => ({ inviteCode: '', inviteType: undefined }), + useZerodevStore: () => ({ + isKernelClientReady: true, + isRegistering: false, + isLoggingIn: false, + isSendingUserOp: false, + address: undefined, + }), +})) +jest.mock('@/redux/slices/zerodev-slice', () => ({ + zerodevActions: { + resetZeroDevState: () => ({ type: 'zerodev/reset' }), + setIsRegistering: (payload: boolean) => ({ type: 'zerodev/registering', payload }), + setIsLoggingIn: (payload: boolean) => ({ type: 'zerodev/logging-in', payload }), + setIsSendingUserOp: (payload: boolean) => ({ type: 'zerodev/sending', payload }), + setAddress: (payload: string) => ({ type: 'zerodev/address', payload }), + }, +})) +jest.mock('@/redux/slices/setup-slice', () => ({ + setupActions: { setInviteCode: (payload: string) => ({ type: 'setup/invite-code', payload }) }, +})) +jest.mock('@/utils/general.utils', () => ({ + getFromCookie: () => null, + removeFromCookie: jest.fn(), + saveToCookie: jest.fn(), + saveToLocalStorage: jest.fn(), +})) +jest.mock('@zerodev/passkey-validator', () => ({ + toWebAuthnKey: (...args: unknown[]) => mockToWebAuthnKey(...args), + WebAuthnMode: { Register: 'Register', Login: 'Login' }, +})) +jest.mock('@/services/invites', () => ({ invitesApi: { acceptInvite: jest.fn() } })) +jest.mock('@/services/invite-acquisition', () => ({ settleAcceptedInviteAcquisition: jest.fn() })) +jest.mock('@/services/registration-acquisition', () => ({ persistRegistrationBadgeCampaignDestination: jest.fn() })) +jest.mock('@/app/shhhhh/shhhhh-acquisition', () => ({ settleShhhhhCampaignContinuation: jest.fn() })) +jest.mock('@/components/Invites/badge-campaign-context', () => ({ getPendingBadgeCampaigns: () => [] })) +jest.mock('@/services/badge-campaigns', () => ({ + claimAndSettlePendingBadgeCampaigns: jest.fn(), + isConfirmedBadgeCampaignClaim: jest.fn(), + isUnavailableBadgeCampaignClaim: jest.fn(), +})) +jest.mock('@/services/consent', () => ({ signupConsentDocuments: () => [] })) +jest.mock('@/utils/auth.utils', () => ({ clearAuthState: jest.fn() })) +jest.mock('@/utils/walletCredential.utils', () => ({ + isStaleKeyError: () => false, + createStaleSessionError: () => new Error('stale'), +})) +jest.mock('@sentry/nextjs', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), + captureMessage: jest.fn(), +})) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'localhost' })) +jest.mock('@/utils/demo', () => ({ isDemoMode: () => false })) + +describe('useZeroDev handleLogin — passkey-server failures keep the session', () => { + let errorSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => errorSpy.mockRestore()) + + const loginRejectingWith = async (error: unknown) => { + mockToWebAuthnKey.mockRejectedValue(error) + const { result } = renderHook(() => useZeroDev()) + let thrown: unknown + await act(async () => { + try { + await result.current.handleLogin() + } catch (e) { + thrown = e + } + }) + return thrown as Error & { code?: string } + } + + // zerodev's /login/options error-body path: @simplewebauthn's base64url + // decoder gets an error body instead of a challenge and throws this from + // inside the SDK. The session is untouched — the server request failed. + it.each(["undefined is not an object (evaluating 'e.replace')", 'e.replace is not a function'])( + 'reports %s as passkey_server_failure without clearing auth state', + async (message) => { + const thrown = await loginRejectingWith(new TypeError(message)) + + expect(thrown.name).toBe('PasskeyError') + expect(thrown.code).toBe('NETWORK') + expect(clearAuthState).not.toHaveBeenCalled() + expect(mockCaptureException).toHaveBeenCalledTimes(1) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ name: 'PasskeyServerError' }), + expect.objectContaining({ tags: { error_type: 'passkey_server_failure' } }) + ) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'zerodev/logging-in', payload: false }) + } + ) + + it('treats a plain network failure the same way', async () => { + const thrown = await loginRejectingWith(new TypeError('Load failed')) + + expect(thrown.code).toBe('NETWORK') + expect(clearAuthState).not.toHaveBeenCalled() + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(TypeError), + expect.objectContaining({ tags: { error_type: 'passkey_server_failure' } }) + ) + }) + + it('keeps the login_error path for a rejected /login/verify', async () => { + const thrown = await loginRejectingWith( + new TypeError("undefined is not an object (evaluating 'loginVerifyResult.verification.verified')") + ) + + expect(thrown.code).toBe('LOGIN_ERROR') + expect(clearAuthState).toHaveBeenCalledWith('u1') + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Login not verified' }), + expect.objectContaining({ tags: { error_type: 'login_error' } }) + ) + }) + + it('still clears auth state and reports login_error for a genuine login failure', async () => { + const thrown = await loginRejectingWith(new TypeError('x is not a function')) + + expect(thrown.code).toBe('LOGIN_ERROR') + expect(clearAuthState).toHaveBeenCalledWith('u1') + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(TypeError), + expect.objectContaining({ tags: { error_type: 'login_error' } }) + ) + }) +}) diff --git a/src/hooks/useAccountSetup.ts b/src/hooks/useAccountSetup.ts index 04326585e5..9edbb9ac7f 100644 --- a/src/hooks/useAccountSetup.ts +++ b/src/hooks/useAccountSetup.ts @@ -28,7 +28,7 @@ export const useAccountSetup = () => { }) console.log('[useAccountSetup] Resolved post-auth redirect:', redirect) - router.push(redirect.destination) + router.replace(redirect.destination) return redirect.source === 'explicit' } diff --git a/src/hooks/useBackHandler.ts b/src/hooks/useBackHandler.ts new file mode 100644 index 0000000000..e493300455 --- /dev/null +++ b/src/hooks/useBackHandler.ts @@ -0,0 +1,19 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { registerBackHandler, type BackHandler } from '@/utils/back-handler' + +/** + * Registers `handler` on the hardware-back stack while `enabled`. The stack + * position is fixed at the moment `enabled` flips true; the latest handler is + * always the one invoked, so callers can pass an inline closure. + */ +export function useBackHandler(handler: BackHandler, enabled = true) { + const handlerRef = useRef(handler) + useLayoutEffect(() => { + handlerRef.current = handler + }) + + useEffect(() => { + if (!enabled) return + return registerBackHandler(() => handlerRef.current()) + }, [enabled]) +} diff --git a/src/hooks/useHostedVerification.ts b/src/hooks/useHostedVerification.ts index d62a1daa73..57a43ef945 100644 --- a/src/hooks/useHostedVerification.ts +++ b/src/hooks/useHostedVerification.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { startHostedVerification } from '@/app/actions/sumsub' import { useAuth } from '@/context/authContext' -import { isNativeBridge, openExternalUrl } from '@/utils/capacitor' +import { IN_APP_BROWSER_CLOSED_EVENT, isNativeBridge, openExternalUrl } from '@/utils/capacitor' /** * Drives the handoff to a provider's hosted verification page and the wait for @@ -149,9 +149,13 @@ export function useHostedVerification( // Android WebViews don't reliably fire `visibilitychange` on // resume — the same defect that makes useNativePlugins drive // TanStack's focusManager off `appStateChange`. The in-app - // browser's own close event is the precise signal here. + // browser's own close event is the precise signal here. On iOS a + // universal-link return closes the sheet programmatically + // (useNativeAppLinks → closeInAppBrowser), which never emits + // `browserFinished`, hence the document event as well. let disposed = false let remove: (() => void) | undefined + document.addEventListener(IN_APP_BROWSER_CLOSED_EVENT, refresh) void import('@capacitor/browser') .then(({ Browser }) => Browser.addListener('browserFinished', refresh)) .then((handle) => { @@ -165,6 +169,7 @@ export function useHostedVerification( return () => { disposed = true remove?.() + document.removeEventListener(IN_APP_BROWSER_CLOSED_EVENT, refresh) } } diff --git a/src/hooks/useNativeAppLinks.ts b/src/hooks/useNativeAppLinks.ts index 1195f8b262..82445a1575 100644 --- a/src/hooks/useNativeAppLinks.ts +++ b/src/hooks/useNativeAppLinks.ts @@ -12,10 +12,13 @@ import { hasDeepLinkNavigated, markDeepLinkNavigated } from '@/utils/deep-link-s import { sanitizeRedirectURL, saveToCookie } from '@/utils/cookie-url.utils' import { toInviteCode } from '@/utils/invite-code.utils' import { getOneSignalAdapter } from '@/services/onesignal' +import { dispatchBackPress } from '@/utils/back-handler' /* * App-lifecycle + deep-link listeners (back button, appStateChange focus, - * App Links, deferred restore, push-tap routing). Mounted in ClientProviders — + * App Links, deferred restore, push-tap routing). The back button is offered + * to the in-app handler stack (open sheets, sub-views, setup steps) before it + * touches history. Mounted in ClientProviders — * NOT in a route-group layout — because a cold start that lands on /setup * (logged out) must still register getLaunchUrl/appUrlOpen; when this lived in * useNativePlugins under (mobile-ui) only, an App Link that cold-started a @@ -170,6 +173,7 @@ export function useNativeAppLinks() { }) const backListener = await App.addListener('backButton', ({ canGoBack }: { canGoBack: boolean }) => { + if (dispatchBackPress()) return if (canGoBack) { // eslint-disable-next-line no-restricted-syntax -- native canGoBack guards the call, and the no-history branch must minimize the app (Android convention), which useSafeBack's URL fallback can't express router.back() diff --git a/src/hooks/useOtaChannel.ts b/src/hooks/useOtaChannel.ts index 7617aa7e2c..25a79fca2b 100644 --- a/src/hooks/useOtaChannel.ts +++ b/src/hooks/useOtaChannel.ts @@ -5,14 +5,25 @@ import { isNativeBridge } from '@/utils/capacitor' import { BETA_OTA_CHANNEL, clearPendingBetaExit, - hasPendingBetaExit, OtaChannelClosedError, OtaChannelOverrideError, OtaChannelUnknownError, OtaResetFailedError, + pendingBetaExitBundle, + UNKNOWN_BETA_EXIT_BUNDLE, type OtaChannelStatus, } from '@/utils/capgo-updater' +// An owed exit is over once the beta bundle recorded at the leave is no longer +// the one running — the store bundle or any production OTA counts. A legacy +// marker that never recorded a bundle can only be settled by the builtin bundle. +function betaExitFinished(status: OtaChannelStatus, recordedBundle: string): boolean { + if (status.channel === BETA_OTA_CHANNEL) return false + if (status.onBuiltinBundle) return true + if (recordedBundle === UNKNOWN_BETA_EXIT_BUNDLE) return false + return status.bundleVersion !== null && status.bundleVersion !== recordedBundle +} + /** * - `staged`: on the channel, beta bundle downloaded, waiting for a restart * - `joined`: on the channel with nothing newer to download @@ -66,10 +77,11 @@ export function useOtaChannel(): UseOtaChannel { const { readOtaChannelStatus } = await import('@/utils/capgo-updater') const next = await readOtaChannelStatus() setStatus(next) - // An unfinished exit is only over once the store bundle is the one - // running; until then the device is on beta code whatever the channel says. - const owed = hasPendingBetaExit() && !(next.onBuiltinBundle && next.channel !== BETA_OTA_CHANNEL) - if (hasPendingBetaExit() && !owed) clearPendingBetaExit() + // Until the recorded beta bundle is gone the device is on beta code, + // whatever the channel says. + const recorded = pendingBetaExitBundle() + const owed = recorded !== null && !betaExitFinished(next, recorded) + if (recorded !== null && !owed) clearPendingBetaExit() setPendingExit(owed) }, []) diff --git a/src/hooks/useSetupBackHandler.ts b/src/hooks/useSetupBackHandler.ts new file mode 100644 index 0000000000..9c7f194542 --- /dev/null +++ b/src/hooks/useSetupBackHandler.ts @@ -0,0 +1,24 @@ +import { type ISetupStep } from '@/components/Setup/Setup.types' +import { useBackHandler } from '@/hooks/useBackHandler' +import { minimizeNativeApp } from '@/utils/capacitor' + +/** + * Hardware back inside /setup walks the step's own back button or minimizes + * the app; it never yields to router.back(), which bounced the user through + * the flow's mirrored history entries. + */ +export function useSetupBackHandler({ + step, + canStepBack, + onBack, +}: { + step: ISetupStep | undefined + canStepBack: boolean + onBack: () => void +}) { + useBackHandler(() => { + if (canStepBack && step?.showBackButton) onBack() + else void minimizeNativeApp() + return true + }) +} diff --git a/src/hooks/useSetupStepUrlSync.ts b/src/hooks/useSetupStepUrlSync.ts index 7baf60c3d5..02a8d156f2 100644 --- a/src/hooks/useSetupStepUrlSync.ts +++ b/src/hooks/useSetupStepUrlSync.ts @@ -2,6 +2,7 @@ import { type ISetupStep, type ScreenId } from '@/components/Setup/Setup.types' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import posthog from 'posthog-js' import { useEffect, useLayoutEffect, useRef } from 'react' +import { isNativeBridge } from '@/utils/capacitor' // Not `step`: at /setup entry, ?step=signup is an existing contract that skips // the invite gate (see determineInitialStep), so the mirror needs its own key. @@ -82,7 +83,10 @@ export const useSetupStepUrlSync = ({ const url = new URL(window.location.href) url.searchParams.set(SCREEN_PARAM, screenId) const state = { ...window.history.state, setupScreen: screenId } - if (previous === null) { + // Native owns back through the handler stack (useSetupBackHandler), so + // the mirror must not grow history there — pushed entries turned the + // hardware button into a bounce through already-completed steps. + if (previous === null || isNativeBridge()) { window.history.replaceState(state, '', url) } else { window.history.pushState(state, '', url) diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 452531f066..ca82a197b9 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -1,6 +1,7 @@ 'use client' import { PASSKEY_SERVER_URL } from '@/constants/zerodev.consts' +import { WEB_AUTHN_COOKIE_KEY } from '@/constants/auth.consts' import { loadingStateContext } from '@/context/loadingStates.context' import { useAuth } from '@/context/authContext' import { useKernelClient } from '@/context/kernelClient.context' @@ -10,7 +11,7 @@ import { zerodevActions } from '@/redux/slices/zerodev-slice' import { getFromCookie, removeFromCookie, saveToCookie, saveToLocalStorage } from '@/utils/general.utils' import { clearAuthState } from '@/utils/auth.utils' import { isStaleKeyError, createStaleSessionError } from '@/utils/walletCredential.utils' -import { capturePasskeySignFailure, classifyPasskeyError } from '@/utils/webauthn.utils' +import { capturePasskeySignFailure, classifyPasskeyError, normalizePasskeyServerError } from '@/utils/webauthn.utils' import { withCeremonyPurpose } from '@/utils/webauthn-ceremony-telemetry' import { captureCeremonyGuardError, @@ -60,8 +61,6 @@ class PasskeyError extends Error { } } -const WEB_AUTHN_COOKIE_KEY = 'web-authn-key' - export const useZeroDev = () => { const dispatch = useAppDispatch() const { user, logoutUser } = useAuth() @@ -299,22 +298,17 @@ export const useZeroDev = () => { setWebAuthnKey(webAuthnKey) saveToCookie(WEB_AUTHN_COOKIE_KEY, webAuthnKey, 90) } catch (e) { - // zerodev's toWebAuthnKey login path reads loginVerifyResult.verification.verified - // with no HTTP-status check, so a non-2xx /login/verify (e.g. a 401 when this - // device's passkey doesn't verify) throws a raw TypeError. Normalize it to a - // clean auth error so it classifies and reports as a login failure instead of a - // confusing "undefined is not an object (…verification.verified)" crash (PEANUT-UI-R0V). - const err = - e instanceof TypeError && /verif(ication|ied)/i.test(e.message ?? '') - ? new Error('Login not verified') - : e + const err = normalizePasskeyServerError(e) const { code, message } = classifyPasskeyError(err) dispatch(zerodevActions.setIsLoggingIn(false)) - // Ceremony guards: nothing was authenticated, so keep any existing - // state (no clearAuthState) and report with a discriminating tag — - // this is the telemetry that tells us WHERE native logins hang. + // Ceremony guards and server/network failures: nothing was + // authenticated, so keep any existing state (no clearAuthState) and + // report with a discriminating tag — this is the telemetry that + // tells us WHERE native logins hang. if (isCeremonyGuardError(err)) { captureCeremonyGuardError(err, 'login', { elapsedMs: Date.now() - ceremonyStartedAt }) + } else if (code === 'NETWORK') { + captureException(err, { tags: { error_type: 'passkey_server_failure' } }) } else if (code !== 'LOGIN_CANCELED') { console.error('Error logging in', err) await clearAuthState(user?.user.userId) diff --git a/src/i18n/app/AppIntlProvider.tsx b/src/i18n/app/AppIntlProvider.tsx index 9a02bce51b..9c6474b8d7 100644 --- a/src/i18n/app/AppIntlProvider.tsx +++ b/src/i18n/app/AppIntlProvider.tsx @@ -12,7 +12,7 @@ export { useAppLocale } from './locale-context' */ export function AppIntlProvider({ children }: { children: React.ReactNode }) { return ( - + {children} ) diff --git a/src/i18n/app/IntlCore.tsx b/src/i18n/app/IntlCore.tsx index 5d5eef5ca4..ecfd6b5044 100644 --- a/src/i18n/app/IntlCore.tsx +++ b/src/i18n/app/IntlCore.tsx @@ -35,10 +35,17 @@ export function IntlCore({ children, base, load, + gatesSplash = false, }: { children: React.ReactNode base: AppMessages load: (locale: AppLocale) => Promise + /** + * Only the app catalog instance resolves `localeApplied()`. The marketing + * instance mounts first on native (`/` is a marketing route) and would + * otherwise release the splash before the app copy is on screen. + */ + gatesSplash?: boolean }) { /* SSR and the first client render must both use English so the hydration passes match; the real locale is resolved and swapped in an effect. */ @@ -53,26 +60,32 @@ export function IntlCore({ // device_language + platform super properties for the localization OKR; // independent of which locale resolves, fire-and-forget void emitDeviceContextToAnalytics() - localeReady().then(async (resolved) => { - startupLocale.current = resolved - if (resolved === DEFAULT_APP_LOCALE) { - // already rendered in English — nothing to swap. Skip the emit - // if a manual setLocale won the race: this path never calls - // setIntlState, so the UI keeps the manual locale and emitting - // the startup value would record a language nobody sees. - if (!currentAppLocale()) emitLocaleToAnalytics(resolved) - markLocaleApplied() - return - } - if (cancelled) return - const loaded = await load(resolved) - if (!cancelled) { - setIntlState({ locale: resolved, messages: loaded }) - // emit only after the catalog loaded — analytics report the - // language the user actually sees, not a failed swap - emitLocaleToAnalytics(resolved) - } - }) + localeReady() + .then(async (resolved) => { + startupLocale.current = resolved + if (resolved === DEFAULT_APP_LOCALE) { + // already rendered in English — nothing to swap. Skip the emit + // if a manual setLocale won the race: this path never calls + // setIntlState, so the UI keeps the manual locale and emitting + // the startup value would record a language nobody sees. + if (!currentAppLocale()) emitLocaleToAnalytics(resolved) + if (gatesSplash) markLocaleApplied() + return + } + if (cancelled) return + const loaded = await load(resolved) + if (!cancelled) { + setIntlState({ locale: resolved, messages: loaded }) + // emit only after the catalog loaded — analytics report the + // language the user actually sees, not a failed swap + emitLocaleToAnalytics(resolved) + } + }) + .catch((error) => { + // the splash must never wait on a failed catalog: English stays up + console.error('Startup catalog failed to load', error) + if (gatesSplash) markLocaleApplied() + }) return () => { cancelled = true } @@ -92,7 +105,7 @@ export function IntlCore({ // or the app would keep running under the landing's language. setHtmlLangReleaseListener(applyAppLocale) // signal "startup locale is painted" — the native splash gates on this - if (locale === startupLocale.current) markLocaleApplied() + if (gatesSplash && locale === startupLocale.current) markLocaleApplied() return () => setHtmlLangReleaseListener(null) }, [locale]) diff --git a/src/i18n/app/__tests__/IntlCore.test.tsx b/src/i18n/app/__tests__/IntlCore.test.tsx new file mode 100644 index 0000000000..67340f5576 --- /dev/null +++ b/src/i18n/app/__tests__/IntlCore.test.tsx @@ -0,0 +1,105 @@ +/** @jest-environment jsdom */ +/** + * `localeApplied()` is one-shot and the native splash waits on it. Only the + * instance that gates the splash (AppIntlProvider) may resolve it: the + * marketing instance mounts first on native (`/` is a marketing route) and + * used to release the splash while the app catalog was still loading. + */ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { useTranslations } from 'next-intl' +import { IntlCore } from '../IntlCore' +import en from '../messages/en.json' + +let mockResolvedLocale = 'es-419' +const mockMarkLocaleApplied = jest.fn() +jest.mock('../locale-store', () => ({ + currentAppLocale: () => null, + emitDeviceContextToAnalytics: jest.fn(() => Promise.resolve()), + emitLocaleToAnalytics: jest.fn(), + localeReady: () => Promise.resolve(mockResolvedLocale), + markLocaleApplied: () => mockMarkLocaleApplied(), + persistLocale: jest.fn(), +})) +jest.mock('../../htmlLangClaim', () => ({ + isHtmlLangClaimed: () => false, + setHtmlLangReleaseListener: jest.fn(), +})) + +function Probe() { + const t = useTranslations('common') + return {t('cancel')} +} + +const spanishCatalog = { ...en, common: { ...en.common, cancel: 'Cancelar' } } + +describe('IntlCore splash gating', () => { + beforeEach(() => { + jest.clearAllMocks() + mockResolvedLocale = 'es-419' + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + ;(console.error as jest.Mock).mockRestore() + }) + + it('the marketing instance swaps its catalog but never marks the locale applied', async () => { + const load = jest.fn(() => Promise.resolve(spanishCatalog)) + render( + + + + ) + await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent('Cancelar')) + expect(load).toHaveBeenCalledWith('es-419') + expect(mockMarkLocaleApplied).not.toHaveBeenCalled() + }) + + it('the app instance marks applied once the startup catalog is painted', async () => { + const load = jest.fn(() => Promise.resolve(spanishCatalog)) + render( + + + + ) + expect(mockMarkLocaleApplied).not.toHaveBeenCalled() + await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent('Cancelar')) + expect(mockMarkLocaleApplied).toHaveBeenCalledTimes(1) + }) + + it('the app instance marks applied straight away when the startup locale is English', async () => { + mockResolvedLocale = 'en' + const load = jest.fn(() => Promise.resolve(spanishCatalog)) + render( + + + + ) + await waitFor(() => expect(mockMarkLocaleApplied).toHaveBeenCalledTimes(1)) + expect(load).not.toHaveBeenCalled() + }) + + it('a failed catalog load still marks applied (the splash never waits on it) and keeps English', async () => { + const load = jest.fn(() => Promise.reject(new Error('chunk failed'))) + render( + + + + ) + await waitFor(() => expect(mockMarkLocaleApplied).toHaveBeenCalledTimes(1)) + expect(screen.getByTestId('probe')).toHaveTextContent('Cancel') + expect(console.error).toHaveBeenCalledWith('Startup catalog failed to load', expect.any(Error)) + }) + + it('a failed load on the marketing instance is logged but does not touch the gate', async () => { + const load = jest.fn(() => Promise.reject(new Error('chunk failed'))) + render( + + + + ) + await waitFor(() => expect(console.error).toHaveBeenCalled()) + expect(mockMarkLocaleApplied).not.toHaveBeenCalled() + }) +}) diff --git a/src/i18n/app/__tests__/locale-store.test.ts b/src/i18n/app/__tests__/locale-store.test.ts index 3c8183bc11..3efe35f869 100644 --- a/src/i18n/app/__tests__/locale-store.test.ts +++ b/src/i18n/app/__tests__/locale-store.test.ts @@ -32,10 +32,12 @@ jest.mock('js-cookie', () => ({ })) const mockIsCapacitor = jest.fn() +const mockIsNativeBridge = jest.fn() const mockGetPlatform = jest.fn() jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => mockIsCapacitor(), + isNativeBridge: () => mockIsNativeBridge(), getPlatform: () => mockGetPlatform(), })) @@ -45,6 +47,15 @@ jest.mock('@capacitor/device', () => ({ Device: { getLanguageTag: (...args: unknown[]) => mockGetLanguageTag(...args) }, })) +const mockGetBinaryInfo = jest.fn() + +// Mocked at the consumer boundary, not as @capacitor/app: a module mock of the +// plugin here collided with another suite's virtual mock of it in the same +// worker and made that suite read the real plugin. +jest.mock('@/utils/app-version', () => ({ + getBinaryInfo: (...args: unknown[]) => mockGetBinaryInfo(...args), +})) + function setNavigatorLanguage(value: string): void { Object.defineProperty(navigator, 'language', { value, configurable: true }) } @@ -76,9 +87,17 @@ beforeEach(() => { jest.resetAllMocks() mockIsIdentified.mockReturnValue(true) mockIsCapacitor.mockReturnValue(false) + mockIsNativeBridge.mockReturnValue(false) mockGetPlatform.mockReturnValue('web') }) +function arrangeNativeBridge(): void { + mockIsCapacitor.mockReturnValue(true) + mockIsNativeBridge.mockReturnValue(true) + mockGetPlatform.mockReturnValue('ios-native') + mockGetLanguageTag.mockResolvedValue({ value: 'en-US' }) +} + describe('emitLocaleToAnalytics', () => { it('first emit registers the super property but never $sets (identify covers startup)', () => { const store = freshStore() @@ -171,6 +190,42 @@ describe('emitDeviceContextToAnalytics', () => { expect(store.currentDeviceContext()).toEqual(expect.objectContaining({ app_release: APP_RELEASE })) }) + // app_release is the JS bundle's version; per-shell failure rates need the + // binary's own, which only the native bridge can answer. + it('registers the binary version and build on the native bridge', async () => { + arrangeNativeBridge() + mockGetBinaryInfo.mockResolvedValue({ appVersion: '1.1.0', appBuild: '42' }) + const store = freshStore() + await store.emitDeviceContextToAnalytics() + expect(mockRegister).toHaveBeenCalledWith( + expect.objectContaining({ binary_version: '1.1.0', binary_build: '42' }) + ) + expect(store.currentDeviceContext()).toEqual( + expect.objectContaining({ binary_version: '1.1.0', binary_build: '42' }) + ) + }) + + it('omits the binary fields on web, where there is no binary', async () => { + setNavigatorLanguage('en-US') + const store = freshStore() + await store.emitDeviceContextToAnalytics() + const [registered] = mockRegister.mock.calls[0] + expect(registered).not.toHaveProperty('binary_version') + expect(registered).not.toHaveProperty('binary_build') + expect(mockGetBinaryInfo).not.toHaveBeenCalled() + }) + + it('still registers the rest of the context when the binary read fails', async () => { + arrangeNativeBridge() + // app-version swallows a missing plugin and answers null + mockGetBinaryInfo.mockResolvedValue(null) + const store = freshStore() + await store.emitDeviceContextToAnalytics() + const [registered] = mockRegister.mock.calls[0] + expect(registered).toEqual(expect.objectContaining({ device_language: 'en-us', platform: 'ios-native' })) + expect(registered).not.toHaveProperty('binary_version') + }) + it('emits once per session', async () => { setNavigatorLanguage('pt-BR') const store = freshStore() diff --git a/src/i18n/app/__tests__/resolve-locale.test.ts b/src/i18n/app/__tests__/resolve-locale.test.ts index 4ce4db2605..ced03bfb2a 100644 --- a/src/i18n/app/__tests__/resolve-locale.test.ts +++ b/src/i18n/app/__tests__/resolve-locale.test.ts @@ -1,4 +1,4 @@ -import { resolveLocale, APP_LOCALES, LOCALE_LABELS } from '../config' +import { resolveLocale, resolveLocaleOrNull, APP_LOCALES, LOCALE_LABELS } from '../config' describe('resolveLocale', () => { it.each([ @@ -34,3 +34,28 @@ describe('resolveLocale', () => { } }) }) + +describe('resolveLocaleOrNull', () => { + it.each([ + ['en-US', 'en'], + ['es', 'es-419'], + ['es-AR', 'es-AR'], + ['ES-ar', 'es-AR'], + ['pt-PT', 'pt-BR'], + ] as const)('%s → %s', (input, expected) => { + expect(resolveLocaleOrNull(input)).toBe(expected) + }) + + it.each([null, undefined, '', ' ', 'fr-FR', 'de', 'garbage', 'espresso'])( + 'unsupported %p is null, not the English fallback', + (input) => { + expect(resolveLocaleOrNull(input)).toBeNull() + } + ) + + it('resolveLocale is the same normalization with the default applied', () => { + for (const input of ['es-ar', 'pt', 'en-GB', 'fr', '']) { + expect(resolveLocale(input)).toBe(resolveLocaleOrNull(input) ?? 'en') + } + }) +}) diff --git a/src/i18n/app/config.ts b/src/i18n/app/config.ts index 81e1d65217..e9471b5559 100644 --- a/src/i18n/app/config.ts +++ b/src/i18n/app/config.ts @@ -38,17 +38,25 @@ export const LOCALE_LABELS: Record = { /** * Normalizes any BCP 47-ish tag (device language, cookie, navigator.language) - * to a supported app locale. Every locale source must pass through here so an - * unsupported tag can never reach the intl provider. + * to a supported app locale, or null when the language is unsupported — for + * callers where garbage must not override the device language (deferred links). */ -export function resolveLocale(raw: string | null | undefined): AppLocale { - if (!raw) return DEFAULT_APP_LOCALE - const tag = raw.trim().toLowerCase() - if (!tag) return DEFAULT_APP_LOCALE +export function resolveLocaleOrNull(raw: string | null | undefined): AppLocale | null { + const tag = raw?.trim().toLowerCase() + if (!tag) return null const exact = APP_LOCALES.find((locale) => locale.toLowerCase() === tag) if (exact) return exact const language = tag.split('-')[0] + if (language === 'en') return 'en' if (language === 'es') return 'es-419' if (language === 'pt') return 'pt-BR' - return DEFAULT_APP_LOCALE + return null +} + +/** + * Same normalization with the English fallback. Every locale source must pass + * through here so an unsupported tag can never reach the intl provider. + */ +export function resolveLocale(raw: string | null | undefined): AppLocale { + return resolveLocaleOrNull(raw) ?? DEFAULT_APP_LOCALE } diff --git a/src/i18n/app/locale-store.ts b/src/i18n/app/locale-store.ts index db175523df..805d7d7ae0 100644 --- a/src/i18n/app/locale-store.ts +++ b/src/i18n/app/locale-store.ts @@ -6,7 +6,7 @@ import Cookies from 'js-cookie' import posthog from 'posthog-js' import { APP_RELEASE } from '@/constants/app-release' -import { getPlatform, isCapacitor } from '@/utils/capacitor' +import { getPlatform, isCapacitor, isNativeBridge } from '@/utils/capacitor' import { readStoredValue, writeStoredValue } from '@/utils/safe-storage' import { resolveLocale, type AppLocale } from './config' @@ -78,7 +78,16 @@ async function readDeviceTag(): Promise { // KYC/nationality join. The resolved context is cached (not just a bool) so the // logout handler can re-register it after posthog.reset() wipes super // properties, mirroring app_locale. Fenced so analytics can never break the app. -type DeviceContext = { device_language: string; platform: string; app_release: string } +// binary_version / binary_build are the native shell's own version (app_release +// is the JS bundle's), present only on the native bridge; they are what splits +// a per-build failure rate across shells. +type DeviceContext = { + device_language: string + platform: string + app_release: string + binary_version?: string + binary_build?: string +} let deviceContext: DeviceContext | null = null @@ -91,7 +100,7 @@ export async function emitDeviceContextToAnalytics(): Promise { if (deviceContext) return try { const tag = await rawDeviceTag() - const context = { + const context: DeviceContext = { device_language: tag ? tag.trim().toLowerCase() : 'unknown', platform: getPlatform(), // Also registered in posthog.init's `loaded` callback, which is what @@ -100,6 +109,14 @@ export async function emitDeviceContextToAnalytics(): Promise { // along with the rest of this context. app_release: APP_RELEASE, } + if (isNativeBridge()) { + const { getBinaryInfo } = await import('@/utils/app-version') + const binary = await getBinaryInfo() + if (binary) { + context.binary_version = binary.appVersion + context.binary_build = binary.appBuild + } + } posthog.register(context) // set only after a successful register — a throw leaves this null so a // later call can retry, instead of silently disabling the emit forever diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index d50204ac0d..785fa8bde7 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -815,6 +815,7 @@ "notReady": "The app is still getting ready for passkeys. Wait a moment and try again.", "deviceState": "There was a problem with the passkey on this device. Restart the app and try again.", "interrupted": "Something interrupted the passkey prompt. Please try again.", + "serverUnreachable": "Couldn’t reach Peanut’s servers. Check your connection and try again.", "usernameTaken": "This username is already registered — possibly from an earlier attempt on this device. If that was you, your passkey is ready: just log in.", "learnMore": "Learn more about what Passkeys are", "help": { @@ -863,7 +864,10 @@ "backup": "Your password manager (iCloud Keychain, Google Password Manager, or similar) backs it up and syncs it, so it comes with you on a new phone.", "privacy": "Peanut never sees your fingerprint or face. Your device only tells us that you approved.", "fullGuide": "Read the full guide" - } + }, + "unsupported": "Passkeys aren’t available on this device yet. Sign in to a Google account and update Google Play Services, then try again.", + "origin": "This app isn’t authorized for passkeys on peanut.me. Please update to the latest version.", + "loginError": "We couldn’t verify your passkey. Please try again, or contact support if it keeps happening." }, "testTransaction": { "confirmAndFinish": "Confirm & finish", @@ -3562,5 +3566,17 @@ "title": "ready?", "subtitle": "Try the door." } + }, + "unsupportedWebView": { + "title": "Update needed to keep going", + "body": { + "ios": "Peanut needs a newer version of iOS. Update your device in Settings and open the app again.", + "android": "Peanut needs a newer Android System WebView. Update it from Google Play and open the app again." + }, + "cta": { + "ios": "See how to update iOS", + "android": "Update Android System WebView" + }, + "continueAnyway": "Continue anyway" } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index f2bd152d85..e5e346aa58 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -815,6 +815,7 @@ "notReady": "La app todavía se está preparando para las passkeys. Espera un momento e inténtalo de nuevo.", "deviceState": "Hubo un problema con la passkey en este dispositivo. Reinicia la app e inténtalo de nuevo.", "interrupted": "Algo interrumpió la solicitud de la passkey. Inténtalo de nuevo.", + "serverUnreachable": "No pudimos conectar con los servidores de Peanut. Revisa tu conexión e inténtalo de nuevo.", "usernameTaken": "Este nombre de usuario ya está registrado, posiblemente por un intento anterior en este dispositivo. Si fuiste tú, tu passkey está lista: solo inicia sesión.", "learnMore": "Aprende más sobre qué son las passkeys", "help": { @@ -863,7 +864,10 @@ "backup": "Tu gestor de contraseñas (iCloud Keychain, Administrador de contraseñas de Google o similar) la respalda y la sincroniza, así te acompaña en un teléfono nuevo.", "privacy": "Peanut nunca ve tu huella ni tu rostro. Tu dispositivo solo nos dice que aprobaste.", "fullGuide": "Leer la guía completa" - } + }, + "unsupported": "Las passkeys todavía no están disponibles en este dispositivo. Inicia sesión con una cuenta de Google, actualiza Google Play Services e inténtalo de nuevo.", + "origin": "Esta app no está autorizada para usar passkeys en peanut.me. Actualiza a la última versión.", + "loginError": "No pudimos verificar tu passkey. Inténtalo de nuevo o contacta a soporte si sigue pasando." }, "testTransaction": { "confirmAndFinish": "Confirmar y terminar", @@ -3562,5 +3566,17 @@ "title": "¿todo listo?", "subtitle": "Prueba la puerta." } + }, + "unsupportedWebView": { + "title": "Necesitas actualizar para continuar", + "body": { + "ios": "Peanut necesita una versión más reciente de iOS. Actualiza tu dispositivo en Configuración y vuelve a abrir la app.", + "android": "Peanut necesita una versión más reciente de Android System WebView. Actualízalo desde Google Play y vuelve a abrir la app." + }, + "cta": { + "ios": "Ver cómo actualizar iOS", + "android": "Actualizar Android System WebView" + }, + "continueAnyway": "Continuar de todos modos" } } diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 3509382838..a0ee93ef28 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -471,6 +471,7 @@ "notReady": "La app todavía se está preparando para las passkeys. Esperá un momento e intentalo de nuevo.", "deviceState": "Hubo un problema con la passkey en este dispositivo. Reiniciá la app e intentalo de nuevo.", "interrupted": "Algo interrumpió la solicitud de la passkey. Intentalo de nuevo.", + "serverUnreachable": "No pudimos conectar con los servidores de Peanut. Revisá tu conexión e intentalo de nuevo.", "usernameTaken": "Este nombre de usuario ya está registrado, posiblemente por un intento anterior en este dispositivo. Si fuiste vos, tu passkey está lista: solo iniciá sesión.", "learnMore": "Aprendé más sobre qué son las passkeys", "help": { @@ -506,7 +507,10 @@ "backup": "Tu gestor de contraseñas (iCloud Keychain, Administrador de contraseñas de Google o similar) la respalda y la sincroniza, así te acompaña en un teléfono nuevo.", "privacy": "Peanut nunca ve tu huella ni tu rostro. Tu dispositivo solo nos dice que aprobaste.", "fullGuide": "Leer la guía completa" - } + }, + "unsupported": "Las passkeys todavía no están disponibles en este dispositivo. Iniciá sesión con una cuenta de Google, actualizá Google Play Services e intentalo de nuevo.", + "origin": "Esta app no está autorizada para usar passkeys en peanut.me. Actualizá a la última versión.", + "loginError": "No pudimos verificar tu passkey. Intentalo de nuevo o contactá a soporte si sigue pasando." }, "testTransaction": { "errors": { diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index d2e92cc732..4dec3f16e6 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -815,6 +815,7 @@ "notReady": "O app ainda está se preparando para as passkeys. Aguarde um momento e tente de novo.", "deviceState": "Houve um problema com a passkey neste dispositivo. Reinicie o app e tente de novo.", "interrupted": "Algo interrompeu a solicitação da passkey. Tente de novo.", + "serverUnreachable": "Não foi possível acessar os servidores da Peanut. Verifique sua conexão e tente de novo.", "usernameTaken": "Este nome de usuário já está registrado — possivelmente de uma tentativa anterior neste dispositivo. Se foi você, sua passkey está pronta: é só fazer login.", "learnMore": "Saiba mais sobre o que são as passkeys", "help": { @@ -863,7 +864,10 @@ "backup": "Seu gerenciador de senhas (iCloud Keychain, Gerenciador de Senhas do Google ou similar) faz o backup e a sincroniza, então ela vai com você para um celular novo.", "privacy": "O Peanut nunca vê sua digital nem seu rosto. Seu aparelho só nos diz que você aprovou.", "fullGuide": "Ler o guia completo" - } + }, + "unsupported": "Passkeys ainda não estão disponíveis neste dispositivo. Entre com uma conta Google, atualize o Google Play Services e tente de novo.", + "origin": "Este app não está autorizado a usar passkeys em peanut.me. Atualize para a versão mais recente.", + "loginError": "Não foi possível verificar sua passkey. Tente de novo ou fale com o suporte se continuar acontecendo." }, "testTransaction": { "confirmAndFinish": "Confirmar e concluir", @@ -3562,5 +3566,17 @@ "title": "tudo pronto?", "subtitle": "Teste a porta." } + }, + "unsupportedWebView": { + "title": "Atualize para continuar", + "body": { + "ios": "O Peanut precisa de uma versão mais recente do iOS. Atualize seu aparelho em Ajustes e abra o app de novo.", + "android": "O Peanut precisa de uma versão mais recente do Android System WebView. Atualize pelo Google Play e abra o app de novo." + }, + "cta": { + "ios": "Ver como atualizar o iOS", + "android": "Atualizar o Android System WebView" + }, + "continueAnyway": "Continuar mesmo assim" } } diff --git a/src/utils/__tests__/back-handler.test.ts b/src/utils/__tests__/back-handler.test.ts new file mode 100644 index 0000000000..d7c7e217e1 --- /dev/null +++ b/src/utils/__tests__/back-handler.test.ts @@ -0,0 +1,121 @@ +import { dispatchBackPress, registerBackHandler, resetBackHandlersForTests } from '@/utils/back-handler' + +describe('back-handler stack', () => { + beforeEach(() => { + resetBackHandlersForTests() + jest.restoreAllMocks() + }) + + it('returns false with no handlers registered', () => { + expect(dispatchBackPress()).toBe(false) + }) + + it('dispatches to the most recently registered handler first (LIFO)', () => { + const calls: string[] = [] + registerBackHandler(() => { + calls.push('first') + return true + }) + registerBackHandler(() => { + calls.push('second') + return true + }) + + expect(dispatchBackPress()).toBe(true) + expect(calls).toEqual(['second']) + }) + + it('falls through handlers that return false', () => { + const calls: string[] = [] + registerBackHandler(() => { + calls.push('bottom') + return true + }) + registerBackHandler(() => { + calls.push('middle') + return false + }) + registerBackHandler(() => { + calls.push('top') + return false + }) + + expect(dispatchBackPress()).toBe(true) + expect(calls).toEqual(['top', 'middle', 'bottom']) + }) + + it('returns false when every handler declines', () => { + registerBackHandler(() => false) + registerBackHandler(() => false) + expect(dispatchBackPress()).toBe(false) + }) + + it('unregisters from the middle of the stack without disturbing the others', () => { + const calls: string[] = [] + registerBackHandler(() => { + calls.push('bottom') + return true + }) + const unregisterMiddle = registerBackHandler(() => { + calls.push('middle') + return true + }) + registerBackHandler(() => { + calls.push('top') + return false + }) + + unregisterMiddle() + expect(dispatchBackPress()).toBe(true) + expect(calls).toEqual(['top', 'bottom']) + }) + + it('re-registering a handler moves it to the top', () => { + const calls: string[] = [] + const a = () => { + calls.push('a') + return true + } + const unregisterA = registerBackHandler(a) + registerBackHandler(() => { + calls.push('b') + return true + }) + + unregisterA() + registerBackHandler(a) + + expect(dispatchBackPress()).toBe(true) + expect(calls).toEqual(['a']) + }) + + it('unregister is idempotent and only removes its own registration', () => { + const handler = jest.fn(() => true) + const first = registerBackHandler(handler) + registerBackHandler(handler) + + first() + first() + expect(dispatchBackPress()).toBe(true) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('skips a throwing handler and keeps walking the stack', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const below = jest.fn(() => true) + registerBackHandler(below) + registerBackHandler(() => { + throw new Error('boom') + }) + + expect(dispatchBackPress()).toBe(true) + expect(below).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalled() + }) + + it('resetBackHandlersForTests empties the stack', () => { + registerBackHandler(() => true) + resetBackHandlersForTests() + expect(dispatchBackPress()).toBe(false) + }) +}) diff --git a/src/utils/__tests__/bottom-nav-visibility.test.ts b/src/utils/__tests__/bottom-nav-visibility.test.ts new file mode 100644 index 0000000000..78b76f525b --- /dev/null +++ b/src/utils/__tests__/bottom-nav-visibility.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook } from '@testing-library/react' +import { + acquireBottomNavHide, + resetBottomNavVisibilityForTests, + useBottomNavHidden, +} from '@/utils/bottom-nav-visibility' + +describe('bottom-nav visibility store', () => { + beforeEach(() => { + resetBottomNavVisibilityForTests() + }) + + it('is visible by default', () => { + const { result } = renderHook(() => useBottomNavHidden()) + expect(result.current).toBe(false) + }) + + it('hides while a hold is acquired and shows again on release', () => { + const { result } = renderHook(() => useBottomNavHidden()) + + let release: () => void = () => {} + act(() => { + release = acquireBottomNavHide() + }) + expect(result.current).toBe(true) + + act(() => release()) + expect(result.current).toBe(false) + }) + + it('stays hidden until every hold is released', () => { + const { result } = renderHook(() => useBottomNavHidden()) + + let releaseA: () => void = () => {} + let releaseB: () => void = () => {} + act(() => { + releaseA = acquireBottomNavHide() + releaseB = acquireBottomNavHide() + }) + expect(result.current).toBe(true) + + act(() => releaseA()) + expect(result.current).toBe(true) + + act(() => releaseB()) + expect(result.current).toBe(false) + }) + + it('ignores a double release', () => { + const { result } = renderHook(() => useBottomNavHidden()) + + let releaseA: () => void = () => {} + act(() => { + releaseA = acquireBottomNavHide() + acquireBottomNavHide() + }) + act(() => { + releaseA() + releaseA() + }) + expect(result.current).toBe(true) + }) + + it('resetBottomNavVisibilityForTests clears outstanding holds', () => { + const { result } = renderHook(() => useBottomNavHidden()) + act(() => { + acquireBottomNavHide() + }) + act(() => resetBottomNavVisibilityForTests()) + expect(result.current).toBe(false) + }) +}) diff --git a/src/utils/__tests__/capacitor.test.ts b/src/utils/__tests__/capacitor.test.ts index bc9c92aec2..b3ee953aa8 100644 --- a/src/utils/__tests__/capacitor.test.ts +++ b/src/utils/__tests__/capacitor.test.ts @@ -7,6 +7,13 @@ let isIOSNative: typeof import('../capacitor').isIOSNative let getApiBaseUrl: typeof import('../capacitor').getApiBaseUrl let getPlatform: typeof import('../capacitor').getPlatform +const mockGetInfo = jest.fn() +jest.mock('@capacitor/device', () => ({ Device: { getInfo: () => mockGetInfo() } })) +const mockBrowserClose = jest.fn() +jest.mock('@capacitor/browser', () => ({ + Browser: { open: jest.fn(() => Promise.resolve()), close: () => mockBrowserClose() }, +})) + describe('capacitor utils', () => { const originalEnv = process.env @@ -190,3 +197,204 @@ describe('capacitor utils', () => { }) }) }) + +describe('isWebViewCssSupported', () => { + const win = window as unknown as Record + const setCanary = (layer: boolean, property: boolean, colorMix: boolean) => { + if (layer) win.CSSLayerBlockRule = class {} + else delete win.CSSLayerBlockRule + if (property) win.CSSPropertyRule = class {} + else delete win.CSSPropertyRule + win.CSS = { supports: jest.fn(() => colorMix) } + } + + afterEach(() => { + delete win.CSSLayerBlockRule + delete win.CSSPropertyRule + delete win.CSS + }) + + it('passes when @layer, @property and color-mix(in oklab) are all present', () => { + setCanary(true, true, true) + const { isWebViewCssSupported } = require('../capacitor') + expect(isWebViewCssSupported()).toBe(true) + expect((win.CSS as { supports: jest.Mock }).supports).toHaveBeenCalledWith( + 'color', + 'color-mix(in oklab, red, red)' + ) + }) + + it.each([ + ['@layer', [false, true, true]], + ['@property', [true, false, true]], + ['color-mix', [true, true, false]], + ] as const)('fails when %s is missing', (_name, [layer, property, colorMix]) => { + setCanary(layer, property, colorMix) + const { isWebViewCssSupported } = require('../capacitor') + expect(isWebViewCssSupported()).toBe(false) + }) + + it('fails when the CSS object itself is missing', () => { + setCanary(true, true, true) + delete win.CSS + const { isWebViewCssSupported } = require('../capacitor') + expect(isWebViewCssSupported()).toBe(false) + }) +}) + +describe('androidSdkFromUserAgent', () => { + const { androidSdkFromUserAgent } = require('../capacitor') as typeof import('../capacitor') + + it.each([ + ['Mozilla/5.0 (Linux; Android 9; Pixel) AppleWebKit/537.36', 28], + ['Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36', 29], + ['Mozilla/5.0 (Linux; Android 11; SM-A515F) AppleWebKit/537.36', 30], + ['Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36', 31], + ['Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36', 33], + ['Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36', 34], + ['Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36', 35], + ['Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36', 36], + ])('%s → %i', (ua, sdk) => { + expect(androidSdkFromUserAgent(ua)).toBe(sdk) + }) + + it.each([ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', + 'Mozilla/5.0 (Linux; Android 8.1.0; Nexus) AppleWebKit/537.36', + 'Mozilla/5.0 (Linux; Android 99; Future) AppleWebKit/537.36', + 'Mozilla/5.0 (Linux; Android; K) AppleWebKit/537.36', + '', + ])('returns null for %s', (ua) => { + expect(androidSdkFromUserAgent(ua)).toBeNull() + }) +}) + +describe('legacy android safe-area zeroing', () => { + const edges = ['top', 'right', 'bottom', 'left'] + const inline = () => + edges.map((edge) => document.documentElement.style.getPropertyValue(`--safe-area-inset-${edge}`)) + const setUserAgent = (ua: string) => + Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true }) + const originalUserAgent = navigator.userAgent + + beforeEach(() => { + jest.resetModules() + mockGetInfo.mockReset() + for (const edge of edges) document.documentElement.style.removeProperty(`--safe-area-inset-${edge}`) + }) + + afterEach(() => { + setUserAgent(originalUserAgent) + delete window.Capacitor + }) + + it('zeroes the four inline insets synchronously on Android < 15', () => { + window.Capacitor = { getPlatform: () => 'android', isNativePlatform: () => true } + setUserAgent('Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36') + const { applyLegacyAndroidSafeAreaZeroFromUserAgent } = require('../capacitor') + applyLegacyAndroidSafeAreaZeroFromUserAgent() + expect(inline()).toEqual(['0px', '0px', '0px', '0px']) + }) + + it.each([ + ['Android 15', 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36'], + ['an unmapped version', 'Mozilla/5.0 (Linux; Android 8.1.0; Nexus) AppleWebKit/537.36'], + ])('leaves the insets alone on %s', (_name, ua) => { + window.Capacitor = { getPlatform: () => 'android', isNativePlatform: () => true } + setUserAgent(ua) + const { applyLegacyAndroidSafeAreaZeroFromUserAgent } = require('../capacitor') + applyLegacyAndroidSafeAreaZeroFromUserAgent() + expect(inline()).toEqual(['', '', '', '']) + }) + + it('does nothing outside android native even with an Android UA', () => { + setUserAgent('Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36') + const { applyLegacyAndroidSafeAreaZeroFromUserAgent } = require('../capacitor') + applyLegacyAndroidSafeAreaZeroFromUserAgent() + expect(inline()).toEqual(['', '', '', '']) + }) + + it('the Device pass clears a UA-based zeroing when the device reports SDK 35+', async () => { + window.Capacitor = { getPlatform: () => 'android', isNativePlatform: () => true } + setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36') + mockGetInfo.mockResolvedValue({ androidSDKVersion: 35 }) + const { applyLegacyAndroidSafeAreaZeroFromUserAgent, zeroLegacyAndroidSafeAreaInsets } = require('../capacitor') + applyLegacyAndroidSafeAreaZeroFromUserAgent() + expect(inline()).toEqual(['0px', '0px', '0px', '0px']) + await zeroLegacyAndroidSafeAreaInsets() + expect(inline()).toEqual(['', '', '', '']) + }) + + it('the Device pass leaves natively injected insets alone on SDK 35+ when nothing was zeroed', async () => { + window.Capacitor = { getPlatform: () => 'android', isNativePlatform: () => true } + for (const edge of edges) document.documentElement.style.setProperty(`--safe-area-inset-${edge}`, '24px') + mockGetInfo.mockResolvedValue({ androidSDKVersion: 35 }) + const { zeroLegacyAndroidSafeAreaInsets } = require('../capacitor') + await zeroLegacyAndroidSafeAreaInsets() + expect(inline()).toEqual(['24px', '24px', '24px', '24px']) + }) + + it('the Device pass zeroes when the device reports SDK < 35', async () => { + window.Capacitor = { getPlatform: () => 'android', isNativePlatform: () => true } + mockGetInfo.mockResolvedValue({ androidSDKVersion: 33 }) + const { zeroLegacyAndroidSafeAreaInsets } = require('../capacitor') + await zeroLegacyAndroidSafeAreaInsets() + expect(inline()).toEqual(['0px', '0px', '0px', '0px']) + }) + + it('the Device pass keeps the env() seed when the sdk is unknown', async () => { + window.Capacitor = { getPlatform: () => 'android', isNativePlatform: () => true } + mockGetInfo.mockResolvedValue({}) + const { zeroLegacyAndroidSafeAreaInsets } = require('../capacitor') + await zeroLegacyAndroidSafeAreaInsets() + expect(inline()).toEqual(['', '', '', '']) + }) +}) + +describe('closeInAppBrowser', () => { + beforeEach(() => { + jest.resetModules() + mockBrowserClose.mockReset() + window.Capacitor = { getPlatform: () => 'ios', isNativePlatform: () => true } + }) + + afterEach(() => { + delete window.Capacitor + }) + + it('dispatches the closed event once the sheet it opened is closed', async () => { + mockBrowserClose.mockResolvedValue(undefined) + const cap = require('../capacitor') as typeof import('../capacitor') + const onClosed = jest.fn() + document.addEventListener(cap.IN_APP_BROWSER_CLOSED_EVENT, onClosed) + await cap.openExternalUrl('https://example.com') + await cap.closeInAppBrowser() + expect(mockBrowserClose).toHaveBeenCalledTimes(1) + expect(onClosed).toHaveBeenCalledTimes(1) + // already closed: neither the plugin nor the event fire again + await cap.closeInAppBrowser() + expect(mockBrowserClose).toHaveBeenCalledTimes(1) + expect(onClosed).toHaveBeenCalledTimes(1) + document.removeEventListener(cap.IN_APP_BROWSER_CLOSED_EVENT, onClosed) + }) + + it('still dispatches when the plugin close rejects (sheet already gone)', async () => { + mockBrowserClose.mockRejectedValue(new Error('no browser')) + const cap = require('../capacitor') as typeof import('../capacitor') + const onClosed = jest.fn() + document.addEventListener(cap.IN_APP_BROWSER_CLOSED_EVENT, onClosed) + await cap.openExternalUrl('https://example.com') + await expect(cap.closeInAppBrowser()).resolves.toBeUndefined() + expect(onClosed).toHaveBeenCalledTimes(1) + document.removeEventListener(cap.IN_APP_BROWSER_CLOSED_EVENT, onClosed) + }) + + it('does not dispatch when no sheet was opened', async () => { + const cap = require('../capacitor') as typeof import('../capacitor') + const onClosed = jest.fn() + document.addEventListener(cap.IN_APP_BROWSER_CLOSED_EVENT, onClosed) + await cap.closeInAppBrowser() + expect(onClosed).not.toHaveBeenCalled() + document.removeEventListener(cap.IN_APP_BROWSER_CLOSED_EVENT, onClosed) + }) +}) diff --git a/src/utils/__tests__/capgo-updater.test.ts b/src/utils/__tests__/capgo-updater.test.ts index 9faee92659..b579325580 100644 --- a/src/utils/__tests__/capgo-updater.test.ts +++ b/src/utils/__tests__/capgo-updater.test.ts @@ -13,6 +13,7 @@ const mockUpdater = { setChannel: jest.fn(), unsetChannel: jest.fn().mockResolvedValue(undefined), getChannel: jest.fn(), + current: jest.fn(), reset: jest.fn().mockResolvedValue(undefined), } @@ -66,11 +67,25 @@ it('escalates the same failure to error on the third consecutive launch', async expect(info).toHaveBeenCalledTimes(2) }) +// Plugin 8.45+ rejects getLatest() with the server's error code, not the +// sentence the docs list; both must read as up to date, or every device that is +// current on its bundle logs a failure on every launch. +it.each(['no_new_version_available', 'No new version available'])( + 'treats a %s rejection as up to date, not a failure', + async (message) => { + mockUpdater.getLatest.mockRejectedValue(new Error(message)) + await launch() + expect(info).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + expect(window.localStorage.getItem('capgoUpdateFailureStreak')).toBeNull() + } +) + it('resets the streak after a successful check', async () => { mockUpdater.getLatest.mockRejectedValue(new Error('Failed to fetch')) await launch() await launch() - mockUpdater.getLatest.mockRejectedValue(new Error('No new version available')) + mockUpdater.getLatest.mockRejectedValue(new Error('no_new_version_available')) await launch() mockUpdater.getLatest.mockRejectedValue(new Error('Failed to fetch')) await launch() @@ -96,7 +111,8 @@ describe('beta channel opt-in', () => { mockUpdater.unsetChannel.mockResolvedValue(undefined) mockUpdater.setChannel.mockResolvedValue({ status: 'ok' }) mockUpdater.getChannel.mockResolvedValue({ channel: null, status: 'default' }) - mockUpdater.getLatest.mockRejectedValue(new Error('No new version available')) + mockUpdater.current.mockResolvedValue({ bundle: { id: 'beta-bundle', version: '1.1.10846' } }) + mockUpdater.getLatest.mockRejectedValue(new Error('no_new_version_available')) }) // The launch check can still be downloading when the tester joins. Two @@ -208,6 +224,50 @@ describe('beta channel opt-in', () => { expect(mockUpdater.reset).toHaveBeenCalled() }) + // unsetChannel() is local-only on both platforms (it drops a stored key); + // the device→channel assignment lives on the server and only setChannel() + // rewrites it. Without this the device stayed on beta server-side and the + // next check pulled the beta bundle straight back. + it('assigns the device to production on the server when leaving', async () => { + const { leaveBetaOtaChannel, PRODUCTION_OTA_CHANNEL } = await import('../capgo-updater') + await leaveBetaOtaChannel() + expect(mockUpdater.setChannel).toHaveBeenCalledWith({ + channel: PRODUCTION_OTA_CHANNEL, + triggerAutoUpdate: false, + }) + const [unsetOrder] = mockUpdater.unsetChannel.mock.invocationCallOrder + const [setOrder] = mockUpdater.setChannel.mock.invocationCallOrder + expect(unsetOrder).toBeLessThan(setOrder) + }) + + // A production channel that refuses self-assign is a valid dashboard + // configuration. The local unset already happened, so the exit must go on + // and let getChannel() decide — otherwise every retry would fail the same + // way and the device could never leave the beta bundle. + it.each([ + ['rejects', () => mockUpdater.setChannel.mockRejectedValue(new Error('channel_self_set_not_allowed'))], + [ + 'answers with an error', + () => mockUpdater.setChannel.mockResolvedValue({ status: 'error', error: 'channel_self_set_not_allowed' }), + ], + ])('still resets when the production self-assign %s but beta no longer sticks', async (_case, arrange) => { + const { leaveBetaOtaChannel } = await import('../capgo-updater') + arrange() + await leaveBetaOtaChannel() + expect(mockUpdater.getChannel).toHaveBeenCalled() + expect(mockUpdater.reset).toHaveBeenCalled() + }) + + it('reports an override when the self-assign is refused and beta still sticks', async () => { + const { leaveBetaOtaChannel, OtaChannelOverrideError, hasPendingBetaExit, BETA_OTA_CHANNEL } = + await import('../capgo-updater') + mockUpdater.setChannel.mockRejectedValue(new Error('channel_self_set_not_allowed')) + mockUpdater.getChannel.mockResolvedValue({ channel: BETA_OTA_CHANNEL, status: 'ok' }) + await expect(leaveBetaOtaChannel()).rejects.toBeInstanceOf(OtaChannelOverrideError) + expect(mockUpdater.reset).not.toHaveBeenCalled() + expect(hasPendingBetaExit()).toBe(true) + }) + // Channel unset + beta bundle still running is the one state no OTA can // repair, so a failed reset must not be reported as a clean exit. // unsetChannel() is local-only on both platforms (it drops a stored key), so a @@ -245,6 +305,36 @@ describe('beta channel opt-in', () => { expect(hasPendingBetaExit()).toBe(true) }) + // The marker names the beta bundle that was running, so a later launch on + // any other bundle — builtin or a production OTA — can settle the exit. A + // bare flag could only recognise the builtin bundle, and after the reset the + // JS reading it is the store shell, which may never clear it. + it('records the running beta bundle in the marker', async () => { + const { leaveBetaOtaChannel, pendingBetaExitBundle } = await import('../capgo-updater') + mockUpdater.getChannel.mockRejectedValue(new Error('Failed to fetch')) + await expect(leaveBetaOtaChannel()).rejects.toBeTruthy() + expect(pendingBetaExitBundle()).toBe('1.1.10846') + }) + + it('falls back to the legacy marker when the running bundle is unreadable', async () => { + const { leaveBetaOtaChannel, pendingBetaExitBundle, UNKNOWN_BETA_EXIT_BUNDLE } = + await import('../capgo-updater') + mockUpdater.current.mockRejectedValue(new Error('plugin gone')) + mockUpdater.getChannel.mockRejectedValue(new Error('Failed to fetch')) + await expect(leaveBetaOtaChannel()).rejects.toBeTruthy() + expect(pendingBetaExitBundle()).toBe(UNKNOWN_BETA_EXIT_BUNDLE) + }) + + // Nothing changed, so nothing is owed — a marker left here would keep the + // switch on and make the next "off" discard a bundle that was never beta. + it('drops the marker when unsetChannel itself rejects', async () => { + const { leaveBetaOtaChannel, hasPendingBetaExit } = await import('../capgo-updater') + mockUpdater.unsetChannel.mockRejectedValue(new Error('Failed to fetch')) + await expect(leaveBetaOtaChannel()).rejects.toThrow('Failed to fetch') + expect(hasPendingBetaExit()).toBe(false) + expect(mockUpdater.setChannel).not.toHaveBeenCalled() + }) + it('keeps the marker when the reset itself fails', async () => { const { leaveBetaOtaChannel, hasPendingBetaExit } = await import('../capgo-updater') mockUpdater.getChannel.mockResolvedValue({ channel: '', status: 'default' }) diff --git a/src/utils/__tests__/connectivity.test.ts b/src/utils/__tests__/connectivity.test.ts index 807fcbd0c9..d62861a6d0 100644 --- a/src/utils/__tests__/connectivity.test.ts +++ b/src/utils/__tests__/connectivity.test.ts @@ -120,6 +120,19 @@ describe('hasRecentFailure — one Sentry report per endpoint per outage', () => expect(hasRecentFailure('/users/me')).toBe(false) }) + // A poll that keeps failing must not slide the window forward on every + // attempt, or a continuous outage never gets a second report at all. + it('expires from the FIRST failure, not the latest retry', () => { + reportNetworkError('/users/me') + jest.advanceTimersByTime(FAILURE_WINDOW_MS / 2) + reportNetworkError('/users/me') + expect(hasRecentFailure('/users/me')).toBe(true) + + jest.advanceTimersByTime(FAILURE_WINDOW_MS / 2 + 1000) + expect(hasRecentFailure('/users/me')).toBe(false) + expect(getRecentFailures()).toBe(0) + }) + it('reports again immediately after a recovery clears the window', () => { reportNetworkError('/users/me') clearRecentFailures() diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 2f14fa9538..3e7334be08 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -225,7 +225,9 @@ describe('applyDeferredPayload', () => { it('normalizes and persists supported locales under the app-locale key', async () => { expect(applyDeferredPayload({ lang: 'pt-br' }).locale).toBe('pt-BR') - expect(applyDeferredPayload({ lang: 'es-ar' }).locale).toBe('es-419') + expect(applyDeferredPayload({ lang: 'es-ar' }).locale).toBe('es-AR') + expect(applyDeferredPayload({ lang: 'es-AR' }).locale).toBe('es-AR') + expect(applyDeferredPayload({ lang: 'es-MX' }).locale).toBe('es-419') expect(applyDeferredPayload({ lang: 'es-419' }).locale).toBe('es-419') expect(applyDeferredPayload({ lang: 'en' }).locale).toBe('en') expect(localStorage.getItem(APP_LOCALE_KEY)).toBe('en') @@ -238,6 +240,8 @@ describe('applyDeferredPayload', () => { it('returns null locale for unsupported languages and does not persist', () => { expect(applyDeferredPayload({ lang: 'fr' }).locale).toBeNull() expect(applyDeferredPayload({ lang: 'xx-yy' }).locale).toBeNull() + expect(applyDeferredPayload({ lang: 'garbage' }).locale).toBeNull() + expect(applyDeferredPayload({ lang: ' ' }).locale).toBeNull() expect(applyDeferredPayload({}).locale).toBeNull() expect(localStorage.getItem(APP_LOCALE_KEY)).toBeNull() }) diff --git a/src/utils/__tests__/native-canary.test.ts b/src/utils/__tests__/native-canary.test.ts index 7fd970232b..bc13af7938 100644 --- a/src/utils/__tests__/native-canary.test.ts +++ b/src/utils/__tests__/native-canary.test.ts @@ -2,14 +2,13 @@ import * as Sentry from '@sentry/nextjs' import { runCanary, scheduleTransportCanary } from '../native-canary' jest.mock('@sentry/nextjs', () => ({ captureMessage: jest.fn() })) -// isCapacitor gates the binary-version read in app-version.ts, which the -// canary tags its Sentry event with jest.mock('../capacitor', () => ({ isNativeBridge: jest.fn(() => true), isCapacitor: jest.fn(() => true) })) jest.mock('../native-auth-capture', () => ({ getUnderlyingFetch: () => null })) jest.mock('../native-http', () => ({ nativeHttpRequest: jest.fn() })) -jest.mock('@capacitor/app', () => ({ App: { getInfo: async () => ({ version: '1.0.57', build: '412' }) } }), { - virtual: true, -}) +// Mocked at the consumer boundary rather than as a (virtual) @capacitor/app +// module: another suite in the same worker mocking that package non-virtually +// made this one resolve the real plugin and tag the event 'unknown'. +jest.mock('../app-version', () => ({ getBinaryInfo: async () => ({ appVersion: '1.0.57', appBuild: '412' }) })) const { nativeHttpRequest } = jest.requireMock('../native-http') as { nativeHttpRequest: jest.Mock } const { isNativeBridge } = jest.requireMock('../capacitor') as { isNativeBridge: jest.Mock } diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 0583eed0bf..723da5561a 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -23,6 +23,8 @@ import { rewriteMethodPath, deepLinkToNativePath, isNativeExportPath, + resolveInAppNavigation, + NATIVE_EXPORT_ROOTS, } from '../native-routes' describe('native-routes', () => { @@ -652,6 +654,171 @@ describe('native-routes', () => { }) }) +/* + * Export drift guard: NATIVE_EXPORT_ROOTS is a hand-written list of what the + * native static export ships. It once carried `notifications`, a route that + * never existed. Derive the exported page roots from src/app minus what + * scripts/native-build.js disables, and pin the two against each other. + */ +describe('NATIVE_EXPORT_ROOTS matches the pages the native export ships', () => { + const { existsSync, readdirSync, statSync } = require('fs') + const { join } = require('path') + const { ITEMS_TO_DISABLE } = require('../../../scripts/native-build.js') as { + ITEMS_TO_DISABLE: Array<{ path: string; type: 'dir' | 'file' }> + } + const APP_DIR = join(process.cwd(), 'src/app') + const disabled = new Set(ITEMS_TO_DISABLE.map((item) => item.path)) + const PAGE_FILE = /^page\.(tsx|ts|jsx|js)$/ + + // Exported, deliberately not in NATIVE_EXPORT_ROOTS: + // - `app`: the smart store link. It must open externally, never be pushed + // in-app, so isNativeExportPath must keep saying no. + // - `dev`: pruneExportedAssets() strips every /dev page but /dev/deferred, + // which is reached through the AASA, not from in-app anchors. + const WEB_ONLY_EXPORTED = ['app', 'dev'] + + // A directory counts once it has a page file anywhere below it that the + // native build does not disable — a disabled page/dir contributes nothing. + function hasExportedPage(dir: string, rel: string): boolean { + if (disabled.has(rel)) return false + for (const entry of readdirSync(dir)) { + const entryRel = rel ? `${rel}/${entry}` : entry + if (disabled.has(entryRel)) continue + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + if (hasExportedPage(full, entryRel)) return true + } else if (PAGE_FILE.test(entry)) { + return true + } + } + return false + } + + function exportedRoots(): Set { + const roots = new Set() + for (const group of ['(mobile-ui)', '(setup)', '']) { + const base = join(APP_DIR, group) + for (const entry of readdirSync(base)) { + const full = join(base, entry) + if (!statSync(full).isDirectory()) continue + // route groups only at the top level (handled above); dynamic + // segments have no static root of their own + if (entry.startsWith('(') || entry.startsWith('[') || entry === '__tests__') continue + const rel = group ? `${group}/${entry}` : entry + if (hasExportedPage(full, rel)) roots.add(entry) + } + } + return roots + } + + const onDisk = exportedRoots() + + it.each([...NATIVE_EXPORT_ROOTS].sort())('listed root %s has a page in the export', (root) => { + expect(onDisk.has(root)).toBe(true) + }) + + it.each([...onDisk].sort())('exported root %s is listed or explicitly web-only', (root) => { + expect(NATIVE_EXPORT_ROOTS.has(root) || WEB_ONLY_EXPORTED.includes(root)).toBe(true) + }) + + it('keeps the web-only allowlist honest', () => { + for (const root of WEB_ONLY_EXPORTED) { + expect(onDisk.has(root)).toBe(true) + expect(NATIVE_EXPORT_ROOTS.has(root)).toBe(false) + } + expect(existsSync(join(APP_DIR, '(mobile-ui)/notifications'))).toBe(false) + }) +}) + +/* + * The receipt's Pay CTA assigned an absolute peanut.me URL to window.location — + * an off-origin top-level navigation the Capacitor WebView hands to the OS. + */ +describe('resolveInAppNavigation', () => { + describe('capacitor mode', () => { + beforeEach(() => mockIsCapacitor.mockReturnValue(true)) + + it('pushes the native stand-in for a request link', () => { + expect(resolveInAppNavigation('https://peanut.me/alice?chargeId=abc')).toEqual({ + kind: 'push', + path: '/pay-request?chargeId=abc', + }) + expect(resolveInAppNavigation('https://peanut.me/alice/5usdc?id=pot-1')).toEqual({ + kind: 'push', + path: '/pay-request?id=pot-1', + }) + }) + + it('pushes a bare in-app path unchanged', () => { + expect(resolveInAppNavigation('/pay-request?chargeId=abc')).toEqual({ + kind: 'push', + path: '/pay-request?chargeId=abc', + }) + }) + + it('hands a peanut.me page the export does not ship to the browser', () => { + expect(resolveInAppNavigation('https://peanut.me/en/help')).toEqual({ + kind: 'external', + url: 'https://peanut.me/en/help', + }) + }) + + // The link is a caller-supplied baseUrl persisted by the charge API, so + // only an https Peanut origin may leave the app; anything else is dropped. + it.each([ + ['an off-domain https link', 'https://example.com/pay'], + ['a look-alike host', 'https://peanut.me.evil.example/pay'], + ['a javascript: url', 'javascript:alert(1)'], + ['a data: url', 'data:text/html,'], + ['a plain http peanut link', 'http://peanut.me/en/help'], + ])('refuses to open %s', (_name, link) => { + expect(resolveInAppNavigation(link)).toBeNull() + }) + + it('returns null for an empty or unparseable link', () => { + expect(resolveInAppNavigation('')).toBeNull() + expect(resolveInAppNavigation('not a url')).toBeNull() + }) + }) + + describe('web mode', () => { + beforeEach(() => mockIsCapacitor.mockReturnValue(false)) + + it('pushes the path of a same-origin link, query and fragment included', () => { + expect(resolveInAppNavigation(`${window.location.origin}/alice?chargeId=abc#x`)).toEqual({ + kind: 'push', + path: '/alice?chargeId=abc#x', + }) + }) + + it('pushes a relative path', () => { + expect(resolveInAppNavigation('/alice?chargeId=abc')).toEqual({ + kind: 'push', + path: '/alice?chargeId=abc', + }) + }) + + it('hands an https peanut.me link that is not same-origin to the browser', () => { + expect(resolveInAppNavigation('https://app.peanut.me/en/help')).toEqual({ + kind: 'external', + url: 'https://app.peanut.me/en/help', + }) + }) + + it.each([ + ['another origin', 'https://example.com/pay'], + ['a javascript: url', 'javascript:alert(1)'], + ['a data: url', 'data:text/html,hi'], + ])('refuses to open %s', (_name, link) => { + expect(resolveInAppNavigation(link)).toBeNull() + }) + + it('returns null for an empty link', () => { + expect(resolveInAppNavigation('')).toBeNull() + }) + }) +}) + describe('redactNativePath (deep-link telemetry)', () => { // The BLOCKING finding: the code lives in a path segment, so stripping // only query and fragment left an unclaimed, claimable QR code readable diff --git a/src/utils/__tests__/sentry-init.test.ts b/src/utils/__tests__/sentry-init.test.ts new file mode 100644 index 0000000000..7fc51713d3 --- /dev/null +++ b/src/utils/__tests__/sentry-init.test.ts @@ -0,0 +1,121 @@ +import type { ErrorEvent as SentryErrorEvent } from '@sentry/nextjs' + +jest.mock('@sentry/nextjs', () => ({ + init: jest.fn(), + getClient: jest.fn(), + captureException: jest.fn(), + captureConsoleIntegration: jest.fn(() => ({ name: 'CaptureConsole' })), +})) + +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { + sentryIntegration: jest.fn(() => ({ name: 'posthog-error-tracking', processEvent: (e: unknown) => e })), + }, +})) + +type SentryMock = { init: jest.Mock; getClient: jest.Mock } + +const ENV_KEYS = ['NEXT_PUBLIC_CAPACITOR_BUILD', 'NEXT_PUBLIC_PERF_BARE'] as const +const savedEnv: Partial> = {} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function load(env: Partial>) { + jest.resetModules() + for (const key of ENV_KEYS) { + if (env[key] === undefined) delete process.env[key] + else process.env[key] = env[key] + } + const mod = require('../sentry-init') as typeof import('../sentry-init') + const Sentry = require('@sentry/nextjs') as SentryMock + return { ...mod, Sentry } +} + +beforeEach(() => { + for (const key of ENV_KEYS) savedEnv[key] = process.env[key] +}) + +afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key] + else process.env[key] = savedEnv[key] + } +}) + +describe('initSentry', () => { + it('never inits on the Capacitor build — instrumentation-client owns that client', async () => { + const { initSentry, Sentry } = load({ NEXT_PUBLIC_CAPACITOR_BUILD: 'true' }) + + initSentry() + await flush() + + expect(Sentry.init).not.toHaveBeenCalled() + }) + + it('inits exactly once on web, however many times it is called', async () => { + const { initSentry, Sentry } = load({}) + Sentry.getClient.mockReturnValue(undefined) + + initSentry() + initSentry() + await flush() + initSentry() + await flush() + + expect(Sentry.init).toHaveBeenCalledTimes(1) + expect(Sentry.init.mock.calls[0][0]).toMatchObject({ attachStacktrace: true }) + }) + + it('leaves an existing client alone', async () => { + const { initSentry, Sentry } = load({}) + Sentry.getClient.mockReturnValue({}) + + initSentry() + await flush() + + expect(Sentry.init).not.toHaveBeenCalled() + }) +}) + +describe('withoutNoise', () => { + const event = (partial: Partial) => partial as SentryErrorEvent + const wrap = () => { + const { withoutNoise } = load({}) + const inner = jest.fn((e: SentryErrorEvent) => e) + return { inner, wrapped: withoutNoise({ name: 'mirror', processEvent: inner }) } + } + + it('skips the mirror for transient Capgo updater noise', () => { + const { inner, wrapped } = wrap() + const e = event({ message: '[CapgoUpdater] 🔴 Failed to send stats batch' }) + + expect(wrapped.processEvent!(e)).toBe(e) + expect(inner).not.toHaveBeenCalled() + }) + + it('still mirrors an actionable Capgo failure', () => { + const { inner, wrapped } = wrap() + wrapped.processEvent!(event({ message: '[CapgoUpdater] 🔴 Checksum mismatch' })) + + expect(inner).toHaveBeenCalledTimes(1) + }) + + it('skips the mirror for injected third-party script frames', () => { + const { inner, wrapped } = wrap() + wrapped.processEvent!( + event({ + exception: { values: [{ stacktrace: { frames: [{ filename: 'app:///executors/200.js' }] } }] }, + }) + ) + + expect(inner).not.toHaveBeenCalled() + }) + + it('mirrors everything else', () => { + const { inner, wrapped } = wrap() + wrapped.processEvent!(event({ message: 'TypeError: x is not a function' })) + + expect(inner).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/utils/__tests__/sentry-lazy.test.ts b/src/utils/__tests__/sentry-lazy.test.ts new file mode 100644 index 0000000000..098643ae61 --- /dev/null +++ b/src/utils/__tests__/sentry-lazy.test.ts @@ -0,0 +1,151 @@ +/** + * The lazy wrapper must call the SDK synchronously once it is loaded: the real + * SDK pops a `withScope` fork the moment the callback returns, so a capture + * deferred to a later microtask lands on the outer scope and loses the + * fingerprint the callback set. The mock below reproduces exactly that + * scope-stack behaviour and records the CURRENT scope at capture time. + */ +type FakeScope = { + fingerprint?: string[] + tags: Record + setFingerprint: (fingerprint: string[]) => void + setTag: (key: string, value: string) => void +} + +type Captured = { + kind: 'exception' | 'message' + payload: unknown + fingerprint?: string[] + tags: Record +} + +type FakeSdk = { + __captured: Captured[] + withScope: (cb: (scope: FakeScope) => unknown) => void + captureException: (error: unknown) => void + captureMessage: (message: string) => void + setUser: jest.Mock +} + +jest.mock('@sentry/nextjs', () => { + const makeScope = (parent?: FakeScope): FakeScope => ({ + fingerprint: parent?.fingerprint, + tags: { ...(parent?.tags ?? {}) }, + setFingerprint(fingerprint) { + this.fingerprint = fingerprint + }, + setTag(key, value) { + this.tags[key] = value + }, + }) + const stack: FakeScope[] = [makeScope()] + const current = () => stack[stack.length - 1] + const captured: Captured[] = [] + const record = (kind: Captured['kind'], payload: unknown) => + captured.push({ kind, payload, fingerprint: current().fingerprint, tags: { ...current().tags } }) + return { + __captured: captured, + withScope: (cb: (scope: FakeScope) => unknown) => { + stack.push(makeScope(current())) + try { + cb(current()) + } finally { + stack.pop() + } + }, + captureException: (error: unknown) => record('exception', error), + captureMessage: (message: string) => record('message', message), + setUser: jest.fn(), + } +}) + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function fresh() { + jest.resetModules() + const lazy = require('../sentry-lazy') as typeof import('../sentry-lazy') + const sdk = require('@sentry/nextjs') as FakeSdk + return { lazy, sdk } +} + +describe('sentry-lazy — scope survives to the capture', () => { + it('keeps the fingerprint set inside withScope once the SDK is loaded', async () => { + const { lazy, sdk } = fresh() + await lazy.loadSentry() + + lazy.withScope((scope) => { + scope.setFingerprint(['network-error', '/charges', 'POST']) + scope.setTag('feature', 'charges') + lazy.captureException(new Error('boom')) + lazy.captureMessage('boom message') + }) + + expect(sdk.__captured).toHaveLength(2) + expect(sdk.__captured[0]).toMatchObject({ + kind: 'exception', + fingerprint: ['network-error', '/charges', 'POST'], + tags: { feature: 'charges' }, + }) + expect(sdk.__captured[1]).toMatchObject({ + kind: 'message', + fingerprint: ['network-error', '/charges', 'POST'], + }) + }) + + it('does not leak the forked scope into later captures', async () => { + const { lazy, sdk } = fresh() + await lazy.loadSentry() + + lazy.withScope((scope) => { + scope.setFingerprint(['scoped']) + lazy.captureMessage('inside') + }) + lazy.captureMessage('outside') + + expect(sdk.__captured[1]).toMatchObject({ payload: 'outside', fingerprint: undefined }) + }) + + it('still delivers calls made before the SDK loaded', async () => { + const { lazy, sdk } = fresh() + + lazy.captureException(new Error('early')) + lazy.setUser({ id: 'u1' }) + expect(sdk.__captured).toHaveLength(0) + + await flush() + expect(sdk.__captured).toHaveLength(1) + expect(sdk.__captured[0]).toMatchObject({ kind: 'exception' }) + expect(sdk.setUser).toHaveBeenCalledWith({ id: 'u1' }) + }) +}) + +describe('fetchWithSentry through the real lazy wrapper', () => { + let infoSpy: jest.SpyInstance + + beforeEach(() => { + infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}) + }) + + afterEach(() => infoSpy.mockRestore()) + + // The fingerprint is what root sentry.utils' isFetchSiteMutationFailure + // rescues failed mutations by; if it does not reach the capture, every + // failed POST is swallowed by the networkIssues pattern. + it('captures a failed POST with the network-error fingerprint on the scope', async () => { + const { lazy, sdk } = fresh() + const { fetchWithSentry, sanitizeUrl } = require('../sentry.utils') as typeof import('../sentry.utils') + await lazy.loadSentry() + + global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch')) + const url = 'https://api.peanut.me/charges' + await expect(fetchWithSentry(url, { method: 'POST', body: '{}' })).rejects.toThrow( + 'Something went wrong. Please try again.' + ) + + expect(sdk.__captured).toHaveLength(1) + expect(sdk.__captured[0]).toMatchObject({ + kind: 'exception', + fingerprint: ['network-error', sanitizeUrl(url), 'POST'], + }) + }) +}) diff --git a/src/utils/__tests__/sentry-posthog-mirror.test.ts b/src/utils/__tests__/sentry-posthog-mirror.test.ts new file mode 100644 index 0000000000..780b25d000 --- /dev/null +++ b/src/utils/__tests__/sentry-posthog-mirror.test.ts @@ -0,0 +1,25 @@ +const mockSentryIntegration = jest.fn() +jest.mock('posthog-js', () => ({ __esModule: true, default: { sentryIntegration: mockSentryIntegration } })) + +import { posthogErrorMirror } from '../sentry-posthog-mirror' + +describe('posthogErrorMirror', () => { + it('wraps the PostHog integration so Capgo noise never reaches the mirror', () => { + const inner = jest.fn((event) => event) + mockSentryIntegration.mockReturnValue({ name: 'posthog', processEvent: inner }) + + const mirror = posthogErrorMirror() + expect(mockSentryIntegration).toHaveBeenCalledWith({ + organization: 'peanut-c34d84c05', + projectId: 4505827431415808, + }) + + const noise = { message: '[CapgoUpdater] Failed to download bundle' } as never + expect(mirror.processEvent?.(noise)).toBe(noise) + expect(inner).not.toHaveBeenCalled() + + const real = { exception: { values: [{ type: 'TypeError', value: 'boom' }] } } as never + mirror.processEvent?.(real) + expect(inner).toHaveBeenCalledWith(real) + }) +}) diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index e90b21ce42..56f4d0439b 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -145,6 +145,23 @@ describe('fetchWithSentry — expected-response suppression', () => { expect(warnSpy).not.toHaveBeenCalled() }) + // Same integration, other path: the per-attempt retry notice on a GET + // timeout must not become its own Sentry event either. + it('retries a GET timeout without a console.warn', async () => { + const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}) + const abort = () => Object.assign(new Error('aborted'), { name: 'AbortError' }) + global.fetch = jest.fn().mockRejectedValue(abort()) + + await expect(fetchWithSentry('https://api.peanut.me/users/me', { method: 'GET' })).rejects.toThrow( + 'Peanut is taking too long to respond — check your connection and try again.' + ) + + expect(global.fetch).toHaveBeenCalledTimes(2) + expect(warnSpy).not.toHaveBeenCalled() + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('timed out — retrying')) + infoSpy.mockRestore() + }) + it('still reports 400s from endpoints without a skip rule', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse(400, { error: 'bad request' })) diff --git a/src/utils/__tests__/webauthn.utils.test.ts b/src/utils/__tests__/webauthn.utils.test.ts index cde9a8eb87..73189db95a 100644 --- a/src/utils/__tests__/webauthn.utils.test.ts +++ b/src/utils/__tests__/webauthn.utils.test.ts @@ -1,5 +1,10 @@ import posthog from 'posthog-js' -import { capturePasskeySignFailure, classifyPasskeyError, getPasskeyErrorSetupKey } from '../webauthn.utils' +import { + capturePasskeySignFailure, + classifyPasskeyError, + getPasskeyErrorSetupKey, + normalizePasskeyServerError, +} from '../webauthn.utils' jest.mock('posthog-js', () => ({ __esModule: true, @@ -74,6 +79,48 @@ describe('classifyPasskeyError', () => { }) }) +describe('normalizePasskeyServerError', () => { + // zerodev reads the passkey-server body with no status check, so a non-2xx + // surfaces as one of these raw TypeErrors from inside the SDK. None of them + // says anything about this device's passkey, so none may wipe the session. + test.each([ + "undefined is not an object (evaluating 'e.replace')", + 'e.replace is not a function', + "undefined is not an object (evaluating 't.replace')", + ])('maps %s to a PasskeyServerError that classifies as NETWORK', (message) => { + const raw = new TypeError(message) + const normalized = normalizePasskeyServerError(raw) + + expect(normalized).toBeInstanceOf(Error) + expect((normalized as Error).name).toBe('PasskeyServerError') + expect((normalized as Error).cause).toBe(raw) + expect(classifyPasskeyError(normalized).code).toBe('NETWORK') + }) + + // A rejected /login/verify is a real login failure (PEANUT-UI-R0V): it keeps + // the LOGIN_ERROR path that clears stale auth state, just with a readable message. + test.each([ + "undefined is not an object (evaluating 'loginVerifyResult.verification.verified')", + "Cannot read properties of undefined (reading 'verified')", + ])('maps %s to a plain "Login not verified" error that classifies as LOGIN_ERROR', (message) => { + const normalized = normalizePasskeyServerError(new TypeError(message)) as Error + expect(normalized.message).toBe('Login not verified') + expect(classifyPasskeyError(normalized).code).toBe('LOGIN_ERROR') + }) + + test('leaves an unrelated TypeError alone so it still classifies as LOGIN_ERROR', () => { + const raw = new TypeError('x is not a function') + expect(normalizePasskeyServerError(raw)).toBe(raw) + expect(classifyPasskeyError(raw).code).toBe('LOGIN_ERROR') + }) + + test('leaves non-TypeErrors alone even when the message mentions verification', () => { + const raw = Object.assign(new Error('verification failed'), { name: 'NotAllowedError' }) + expect(normalizePasskeyServerError(raw)).toBe(raw) + expect(normalizePasskeyServerError('not an error')).toBe('not an error') + }) +}) + describe('getPasskeyErrorSetupKey', () => { const passkeyError = (code: string) => Object.assign(new Error('curated english copy'), { name: 'PasskeyError', code }) @@ -84,11 +131,10 @@ describe('getPasskeyErrorSetupKey', () => { expect(getPasskeyErrorSetupKey(passkeyError('PASSKEY_NOT_READY'))).toBe('passkey.notReady') expect(getPasskeyErrorSetupKey(passkeyError('PASSKEY_STATE'))).toBe('passkey.deviceState') expect(getPasskeyErrorSetupKey(passkeyError('PASSKEY_INTERRUPTED'))).toBe('passkey.interrupted') - }) - - test('returns undefined for codes without a translated equivalent (English fallback)', () => { - expect(getPasskeyErrorSetupKey(passkeyError('NETWORK'))).toBeUndefined() - expect(getPasskeyErrorSetupKey(passkeyError('LOGIN_ERROR'))).toBeUndefined() + expect(getPasskeyErrorSetupKey(passkeyError('NETWORK'))).toBe('passkey.serverUnreachable') + expect(getPasskeyErrorSetupKey(passkeyError('PASSKEY_UNSUPPORTED'))).toBe('passkey.unsupported') + expect(getPasskeyErrorSetupKey(passkeyError('PASSKEY_ORIGIN'))).toBe('passkey.origin') + expect(getPasskeyErrorSetupKey(passkeyError('LOGIN_ERROR'))).toBe('passkey.loginError') }) test('returns undefined for non-PasskeyError failures and unknown codes', () => { diff --git a/src/utils/back-handler.ts b/src/utils/back-handler.ts new file mode 100644 index 0000000000..036d3ca3d8 --- /dev/null +++ b/src/utils/back-handler.ts @@ -0,0 +1,35 @@ +/** + * LIFO stack of hardware-back handlers. Overlays and in-page sub-views register + * while they are showing; the native backButton listener dispatches top-down + * and only falls through to history navigation when no handler consumed it. + * Registration happens on every platform; dispatch only happens on Capacitor. + */ +export type BackHandler = () => boolean + +type Entry = { handler: BackHandler } + +const stack: Entry[] = [] + +export function registerBackHandler(handler: BackHandler): () => void { + const entry: Entry = { handler } + stack.push(entry) + return () => { + const index = stack.indexOf(entry) + if (index !== -1) stack.splice(index, 1) + } +} + +export function dispatchBackPress(): boolean { + for (let i = stack.length - 1; i >= 0; i--) { + try { + if (stack[i].handler()) return true + } catch (e) { + console.warn('back handler threw:', e) + } + } + return false +} + +export function resetBackHandlersForTests(): void { + stack.length = 0 +} diff --git a/src/utils/bottom-nav-visibility.ts b/src/utils/bottom-nav-visibility.ts new file mode 100644 index 0000000000..37fa8c6de4 --- /dev/null +++ b/src/utils/bottom-nav-visibility.ts @@ -0,0 +1,38 @@ +import { useSyncExternalStore } from 'react' + +// Counted holds so overlapping sheets (nested drawers) release independently. +let holds = 0 +const listeners = new Set<() => void>() + +const emit = () => listeners.forEach((listener) => listener()) + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +const getSnapshot = () => holds > 0 +const getServerSnapshot = () => false + +export function acquireBottomNavHide(): () => void { + holds += 1 + emit() + let released = false + return () => { + if (released) return + released = true + holds -= 1 + emit() + } +} + +export function useBottomNavHidden(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} + +export function resetBottomNavVisibilityForTests(): void { + holds = 0 + emit() +} diff --git a/src/utils/capacitor.ts b/src/utils/capacitor.ts index 22df7a4468..1e806808ad 100644 --- a/src/utils/capacitor.ts +++ b/src/utils/capacitor.ts @@ -92,6 +92,54 @@ export function isIOSNative(): boolean { return getPlatform() === 'ios-native' } +/** + * The export's stylesheet needs `@layer` (Safari 15.4), `color-mix(in oklab)` + * (16.2) and `@property` (16.4); a WebView missing any of them paints the app + * unstyled, so ClientProviders swaps in UnsupportedWebViewScreen instead. + */ +export function isWebViewCssSupported(): boolean { + if (typeof window === 'undefined') return true + return ( + 'CSSLayerBlockRule' in window && + typeof CSS !== 'undefined' && + CSS.supports('color', 'color-mix(in oklab, red, red)') && + 'CSSPropertyRule' in window + ) +} + +const ANDROID_MAJOR_TO_SDK: Record = { 9: 28, 10: 29, 11: 30, 12: 31, 13: 33, 14: 34, 15: 35, 16: 36 } + +/** SDK level from the `Android N` UA token; null when absent or unmapped. */ +export function androidSdkFromUserAgent(ua: string): number | null { + const major = Number(/Android (\d+)/.exec(ua)?.[1]) + return ANDROID_MAJOR_TO_SDK[major] ?? null +} + +const SAFE_AREA_EDGES = ['top', 'right', 'bottom', 'left'] as const + +function setInlineSafeAreaInsets(value: '0px' | null): void { + for (const edge of SAFE_AREA_EDGES) { + const property = `--safe-area-inset-${edge}` + if (value === null) document.documentElement.style.removeProperty(property) + else document.documentElement.style.setProperty(property, value) + } +} + +/** + * Synchronous first pass of {@link zeroLegacyAndroidSafeAreaInsets} from the + * user agent so the first paint never shows the phantom band; the Device.getInfo + * pass stays authoritative and un-zeroes if the UA lied. + */ +let zeroedFromUserAgent = false + +export function applyLegacyAndroidSafeAreaZeroFromUserAgent(): void { + if (!isAndroidNative()) return + const sdk = androidSdkFromUserAgent(navigator.userAgent) + if (sdk === null || sdk >= 35) return + setInlineSafeAreaInsets('0px') + zeroedFromUserAgent = true +} + /** * Below Android 15 the app window is never edge-to-edge (enforcement starts at * SDK 35), so the webview never extends under the system bars and the correct @@ -100,16 +148,23 @@ export function isIOSNative(): boolean { * the real status bar. Capacitor's native inset injection is 15+ only, so on * older Android we occupy the same slot ourselves: the inline style on * that outranks the env() seed in globals.css (see the :root contract there). - * No-op on web, iOS and Android 15+. + * No-op on web and iOS; on Android 15+ it only clears a zeroing the UA pass got wrong. */ export async function zeroLegacyAndroidSafeAreaInsets(): Promise { if (!isAndroidNative()) return try { const { Device } = await import('@capacitor/device') const { androidSDKVersion } = await Device.getInfo() - if (!androidSDKVersion || androidSDKVersion >= 35) return - for (const edge of ['top', 'right', 'bottom', 'left']) { - document.documentElement.style.setProperty(`--safe-area-inset-${edge}`, '0px') + if (!androidSDKVersion) return + if (androidSDKVersion < 35) { + setInlineSafeAreaInsets('0px') + return + } + // On 15+ the inline values are Capacitor's natively measured insets; + // only undo a zeroing this module wrote itself. + if (zeroedFromUserAgent) { + setInlineSafeAreaInsets(null) + zeroedFromUserAgent = false } } catch { // older binary running OTA'd JS without @capacitor/device — keep the env() seed @@ -154,6 +209,13 @@ export function markInAppBrowserClosed(): void { inAppBrowserOpen = false } +/** + * Dispatched on `document` once closeInAppBrowser has settled. The iOS plugin's + * close() dismisses the sheet without emitting `browserFinished`, so anything + * waiting on the sheet (hosted verification) must listen to both. + */ +export const IN_APP_BROWSER_CLOSED_EVENT = 'peanut:in-app-browser-closed' + export async function closeInAppBrowser(): Promise { if (!inAppBrowserOpen || !isCapacitor()) return inAppBrowserOpen = false @@ -162,6 +224,8 @@ export async function closeInAppBrowser(): Promise { await Browser.close() } catch { // Browser.close rejects when the sheet is already gone — fine. + } finally { + document.dispatchEvent(new CustomEvent(IN_APP_BROWSER_CLOSED_EVENT)) } } @@ -176,3 +240,12 @@ export async function openExternalUrl(url: string): Promise { window.location.assign(url) } } + +// Android convention for back with nothing to go back to; iOS has no minimize. +export async function minimizeNativeApp(): Promise { + if (!isNativeBridge()) return + try { + const { App } = await import('@capacitor/app') + await App.minimizeApp() + } catch {} +} diff --git a/src/utils/capgo-updater.ts b/src/utils/capgo-updater.ts index 09d57a78bb..86b31e0b80 100644 --- a/src/utils/capgo-updater.ts +++ b/src/utils/capgo-updater.ts @@ -129,8 +129,7 @@ async function checkAndStageUpdate( return 'up-to-date' } catch (err) { const message = err instanceof Error ? err.message : String(err ?? '') - // "No new version available" is the normal up-to-date path, not a failure. - if (message === 'No new version available') { + if (isUpToDateRejection(message)) { removeStoredValue(FAILURE_STREAK_KEY) return 'up-to-date' } @@ -153,6 +152,12 @@ async function checkAndStageUpdate( } } +// The normal up-to-date path, not a failure. Plugin 8.45+ rejects getLatest() +// with the server's error code; older builds used the sentence the docs list. +function isUpToDateRejection(message: string): boolean { + return message === 'No new version available' || message.includes('no_new_version_available') +} + // disable_auto_update_under_native: the served bundle semver-sorts below the // installed binary, so every device refuses it. Checksum mismatch: the bundle // arrived corrupt. Neither retries its way out. @@ -178,6 +183,11 @@ function recordFailureStreak(message: string): number { // channel (production) and never sees these bundles. export const BETA_OTA_CHANNEL = 'staging' +// The app's default channel (ios-release.yml / android-release.yml / release-ota.yml). +// Leaving beta also assigns the device here when the channel allows device +// self-assign in the Capgo dashboard; otherwise the local unset has to do. +export const PRODUCTION_OTA_CHANNEL = 'production' + export interface OtaChannelStatus { channel: string | null bundleVersion: string | null @@ -191,11 +201,19 @@ export interface OtaChannelStatus { // A leave that started but was never confirmed. Written before the channel is // cleared, because after that the device looks like it is on the default channel // while it still runs the beta bundle — invisible, and unreachable by any -// production OTA. +// production OTA. The value is the beta bundle that was running, so a later +// launch can tell "still on it" from "replaced by the store bundle or any +// production OTA" — a bare flag could only recognise the builtin bundle, and +// the JS reading it after the reset may be a shell that has never seen the key. const PENDING_EXIT_KEY = 'capgoPendingBetaExit' +export const UNKNOWN_BETA_EXIT_BUNDLE = '1' + +export function pendingBetaExitBundle(): string | null { + return readStoredValue(PENDING_EXIT_KEY) +} export function hasPendingBetaExit(): boolean { - return readStoredValue(PENDING_EXIT_KEY) === '1' + return pendingBetaExitBundle() !== null } export function clearPendingBetaExit(): void { @@ -265,11 +283,9 @@ export async function joinBetaOtaChannel(): Promise { // the two: production versions sort below it, so nothing will ever replace it. export class OtaResetFailedError extends Error {} -// A device Capgo itself routes to the beta channel — forced from the dashboard, -// which is the documented way to enrol someone outside the cohort. unsetChannel() -// only clears the plugin's local preference (verified in the plugin source: both -// platforms just drop a stored key and return ok), so this assignment outlives it -// and nothing in the app can undo it. +// A device Capgo still routes to the beta channel after the leave: the server +// refused the production self-assign, or someone forced the device onto beta +// from the dashboard and the assignment outlived the app's attempt to rewrite it. export class OtaChannelOverrideError extends Error {} // Capgo could not say which channel it will serve. Resetting on that guess is how @@ -291,8 +307,31 @@ export async function leaveBetaOtaChannel(): Promise { return queueOtaWork(async () => { // Before the unset, not after: everything below can fail, and once the // channel is cleared nothing else records that an exit is owed. - writeStoredValue(PENDING_EXIT_KEY, '1') - await CapacitorUpdater.unsetChannel({}) + const running = await CapacitorUpdater.current().catch(() => null) + writeStoredValue(PENDING_EXIT_KEY, running?.bundle?.version || UNKNOWN_BETA_EXIT_BUNDLE) + try { + await CapacitorUpdater.unsetChannel({}) + } catch (err) { + clearPendingBetaExit() + throw err + } + + // unsetChannel() only drops the plugin's local preference (verified in + // the plugin source: both platforms just remove a stored key). The + // device→channel assignment lives on the server, and only setChannel() + // rewrites it — so also assign production. Best effort: a channel that + // refuses self-assign must not strand a device whose beta preference is + // already gone; getChannel() below is what decides whether beta still + // sticks server-side. + try { + const reassigned = await CapacitorUpdater.setChannel({ + channel: PRODUCTION_OTA_CHANNEL, + triggerAutoUpdate: false, + }) + if (reassigned.error) console.info(`[capgo] production self-assign refused: ${reassigned.error}`) + } catch (err) { + console.info(`[capgo] production self-assign failed: ${err instanceof Error ? err.message : String(err)}`) + } // getChannel() asks the backend what it will actually serve, and only a // successful, channel-bearing answer licenses the reset. Offline, rate diff --git a/src/utils/connectivity.ts b/src/utils/connectivity.ts index 4e2fb29e8e..f85395fdea 100644 --- a/src/utils/connectivity.ts +++ b/src/utils/connectivity.ts @@ -49,6 +49,10 @@ function prune(): void { // should be a sanitized url so retries of the same route dedupe to one entry. export function reportNetworkError(endpoint: string): void { prune() + // An un-expired entry already covers this endpoint; re-stamping it would + // slide the window forward on every retry and never let a continuous + // outage age out. + if (failures.some((f) => f.endpoint === endpoint)) return failures.push({ t: Date.now(), endpoint }) // notify again once this entry has aged out so subscribers re-read the // pruned count; on freeze/sleep the overdue timer fires at resume, which diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index db94ceefd4..aba61b528b 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -8,6 +8,7 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS, DEFERRED_LINK_OUTCOMES, type DeferredLinkOutcome } from '@/constants/analytics.consts' import { PLAY_STORE_URL } from '@/constants/general.consts' import { isValidLocale } from '@/i18n/config' +import { type AppLocale, resolveLocaleOrNull } from '@/i18n/app/config' import { isAndroidNative, isIOSNative } from './capacitor' import { getFromCookie, saveToCookie, sanitizeRedirectURL } from './cookie-url.utils' import { toInviteCode } from './invite-code.utils' @@ -25,27 +26,10 @@ import { const MARKER = 'pnutdl' export const CONSUMED_KEY = 'deferredLinkConsumed' -// the key the in-app i18n reads (dev branch: src/i18n/app/locale-store.ts — -// Preferences on native, cookie/localStorage on web). we persist the restored -// preference under it so it applies the moment that system lands on main. -// when it does, replace the mini-resolver below with its resolveLocale. +// the key the in-app i18n reads (src/i18n/app/locale-store.ts — Preferences +// on native, cookie/localStorage on web); the restored preference is persisted +// under it so it applies on the next startup resolution. export const APP_LOCALE_KEY = 'app-locale' -const APP_LOCALES = ['en', 'es-419', 'pt-BR'] as const -type AppLocale = (typeof APP_LOCALES)[number] - -/** normalizes a BCP 47-ish tag to a supported app locale; null when the - * language is unsupported (a garbage payload must never override the device - * language). */ -function resolveAppLocale(raw: string): AppLocale | null { - const tag = raw.trim().toLowerCase() - const exact = APP_LOCALES.find((l) => l.toLowerCase() === tag) - if (exact) return exact - const lang = tag.split('-')[0] - if (lang === 'en') return 'en' - if (lang === 'es') return 'es-419' - if (lang === 'pt') return 'pt-BR' - return null -} function persistRestoredLocale(locale: AppLocale): void { try { @@ -362,7 +346,8 @@ export function applyDeferredPayload(payload: DeferredPayload): RestoredContext const badgeCampaigns = parsePendingBadgeCampaigns(payload.badgeCampaigns ?? payload.campaign) if (badgeCampaigns.length > 0) queuePendingBadgeCampaigns(badgeCampaigns, 30) - const locale = payload.lang ? resolveAppLocale(payload.lang) : null + // null, not the English fallback: a garbage payload must never override the device language + const locale = payload.lang ? resolveLocaleOrNull(payload.lang) : null if (locale) persistRestoredLocale(locale) // must-map, like openDeepLink: an unmappable dest (off-host, malformed diff --git a/src/utils/native-canary.ts b/src/utils/native-canary.ts index 054bad82f4..eb51323208 100644 --- a/src/utils/native-canary.ts +++ b/src/utils/native-canary.ts @@ -28,10 +28,10 @@ * requests, so they mean the same thing on both platforms. * * Denominator lives in PostHog (app opens), not here — that is the whole - * reason this can skip success events. Note PostHog's registered device - * context carries `platform` but NOT the app version, so per-build rates - * (the split that surfaced 3% on `8016c68` vs 21% on `d4bd3ab`) need - * `app_version` added to that `posthog.register` call to be reproducible. + * reason this can skip success events. PostHog's registered device context + * (locale-store.ts) carries `platform` plus `binary_version` / `binary_build`, + * so per-build rates (the split that surfaced 3% on `8016c68` vs 21% on + * `d4bd3ab`) can be reproduced by splitting on those. * * Query: message starts `native canary:` — the message carries the outcome * signature so each distinct failure shape is its own Sentry issue. diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 0127a032ed..3990ae9e59 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -4,6 +4,7 @@ import { couldBeRecipient, isPlausibleUsername, isReservedRoute } from '@/constants/routes' import { isCapacitor } from './capacitor' +import { sanitizeRedirectURL } from './cookie-url.utils' // Deep links are peanut.me links by definition — that's the host the Android // App Links filter and the AASA are bound to. Deliberately not derived from @@ -232,12 +233,13 @@ function mapDeepLinkPath(parsed: URL): string | null { /* * Route roots that exist in the native static export — src/app/(mobile-ui)/* + - * /setup + /shhhhh, minus what scripts/native-build.js disables. The AASA - * drift test in __tests__/native-routes.test.ts walks the App Links path list - * against this mapper, so a root claimed for the app but missing here fails CI - * instead of shipping a dead deep link. + * /setup + /shhhhh, minus what scripts/native-build.js disables. Two tests in + * __tests__/native-routes.test.ts pin it: the AASA drift test walks the App + * Links path list against this mapper, and the export drift test walks src/app + * against this set, so a root claimed for the app but missing here — or listed + * here with no page behind it — fails CI instead of shipping a dead deep link. */ -const NATIVE_EXPORT_ROOTS = new Set([ +export const NATIVE_EXPORT_ROOTS: ReadonlySet = new Set([ 'add-money', 'badges', 'card', @@ -248,7 +250,6 @@ const NATIVE_EXPORT_ROOTS = new Set([ 'history', 'home', 'limits', - 'notifications', 'pay-request', 'points', 'profile', @@ -281,6 +282,54 @@ export function isNativeExportPath(path: string): boolean { return NATIVE_EXPORT_ROOTS.has(root.toLowerCase()) } +export type InAppNavigation = { kind: 'push'; path: string } | { kind: 'external'; url: string } + +/** + * Where an app-authored link should go: an in-app route push, or a hand-off to + * the browser. Assigning `window.location` to an absolute peanut.me URL is an + * off-origin top-level navigation inside the Capacitor WebView, which the shell + * hands to the OS — so on native the link is mapped through the deep-link + * mapper first, and only a path the static export renders is pushed. On web, + * same-origin links push their path; everything else is external. Null for an + * empty or unparseable link — nothing to navigate to. + */ +export function resolveInAppNavigation(url: string): InAppNavigation | null { + if (!url) return null + if (isCapacitor()) { + const target = deepLinkToNativePath(url) + const safe = target === null ? null : sanitizeRedirectURL(target) + if (safe) return { kind: 'push', path: safe } + return parseExternal(url) + } + const safe = sanitizeRedirectURL(url) + if (safe) return { kind: 'push', path: safe } + return parseExternal(url) +} + +// The request link comes from the charge API, which stores whatever baseUrl the +// creating caller supplied — so it must not be trusted past this boundary. Only +// an https Peanut origin (or the build's own base URL, for previews) may be +// handed to the browser; any other scheme or host is dropped, never opened. +function parseExternal(url: string): InAppNavigation | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + if (parsed.protocol !== 'https:') return null + if (!APP_HOSTS.test(parsed.hostname) && parsed.origin !== baseOrigin()) return null + return { kind: 'external', url: parsed.href } +} + +function baseOrigin(): string | null { + try { + return new URL(process.env.NEXT_PUBLIC_BASE_URL || 'https://peanut.me').origin + } catch { + return null + } +} + /** * Static sub-view segments that carry diagnostic value and no identifier. * Everything NOT here and not a route root is treated as an identifier. diff --git a/src/utils/sentry-init.ts b/src/utils/sentry-init.ts index f6b545f9a0..a7ceac2bca 100644 --- a/src/utils/sentry-init.ts +++ b/src/utils/sentry-init.ts @@ -1,14 +1,18 @@ -import posthog from 'posthog-js' - -import type { ErrorEvent as SentryErrorEvent } from '@sentry/nextjs' - -import { beforeSendHandler, isThirdPartyScriptFrame } from '../../sentry.utils' +import { beforeSendHandler } from '../../sentry.utils' +import { posthogErrorMirror, withoutNoise } from '@/utils/sentry-posthog-mirror' import { inferSentryEnvironment } from '@/utils/sentry-env' import { loadSentry } from '@/utils/sentry-lazy' import { isPaymentNetworkExplorerPath } from '@/utils/private-routes' +export { withoutNoise } + // NEXT_PUBLIC_PERF_BARE builds strip all instrumentation to A/B jank against production. -const ENABLED = process.env.NODE_ENV !== 'development' && process.env.NEXT_PUBLIC_PERF_BARE !== 'true' +// The Capacitor build initialises its own client in instrumentation-client.ts +// (offline transport, no BrowserTracing); a second init here would replace it. +const ENABLED = + process.env.NODE_ENV !== 'development' && + process.env.NEXT_PUBLIC_PERF_BARE !== 'true' && + process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true' /* * The SDK is fetched and initialised on demand rather than on every page load. @@ -33,37 +37,6 @@ function bufferEvent(event: ErrorEvent | PromiseRejectionEvent): void { initSentry() } -/* - * The PostHog mirror is an integration, so its `processEvent` hook runs during - * event processing — BEFORE `beforeSend`. Everything `beforeSendHandler` drops - * has therefore already been copied into PostHog, which is why PostHog's error - * list is Sentry's noise list. - * - * Mostly that is a feature and we leave it alone: PostHog holding what Sentry - * filters is the only reason the browser-native fetch failures were ever - * visible. Suppression there is configured server-side (grouping, per-issue - * rate limit, suppression rules) where it is tunable without a release. - * - * The one class worth stopping in the client is injected third-party scripts: - * nobody can act on them in either tool, and one wallet injector alone billed - * ~3.7k events. Wrapping rather than filtering inside beforeSend, because - * beforeSend is downstream of this hook and cannot reach it. - */ -function withoutThirdPartyScripts SentryErrorEvent | null }>( - integration: T -): T { - const inner = integration.processEvent?.bind(integration) - if (!inner) return integration - return { - ...integration, - processEvent: (event: SentryErrorEvent) => { - const frames = (event.exception?.values ?? []).flatMap((v) => v.stacktrace?.frames ?? []) - if (frames.some((frame) => isThirdPartyScriptFrame(frame.filename || ''))) return event - return inner(event) - }, - } -} - export function initSentry(): void { if (!ENABLED || started || typeof window === 'undefined') return if (isPaymentNetworkExplorerPath(window.location.pathname)) return @@ -73,6 +46,12 @@ export function initSentry(): void { window.removeEventListener('error', bufferEvent) window.removeEventListener('unhandledrejection', bufferEvent) + // Another bootstrap already owns the client; a second init would replace it. + if (Sentry.getClient()) { + flushBuffered(Sentry) + return + } + Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: inferSentryEnvironment(), @@ -104,29 +83,23 @@ export function initSentry(): void { Sentry.captureConsoleIntegration({ levels: ['error', 'warn'], }), - // Cross-link Sentry ↔ PostHog: every Sentry error becomes a `$exception` - // event in PostHog with a Sentry deeplink, and the Sentry event gets a - // PostHog tag pointing back at the user's profile + session replay. - // posthog.init() runs in instrumentation-client.ts; the integration uses - // the singleton lazily, so init order doesn't matter. - withoutThirdPartyScripts( - posthog.sentryIntegration({ - organization: 'peanut-c34d84c05', - projectId: 4505827431415808, - }) - ), + posthogErrorMirror(), ], }) - for (const event of buffered) { - Sentry.captureException( - 'reason' in event ? event.reason : (event.error ?? new Error(event.message || 'Unknown error')) - ) - } - buffered.length = 0 + flushBuffered(Sentry) }) } +function flushBuffered(Sentry: Awaited>): void { + for (const event of buffered) { + Sentry.captureException( + 'reason' in event ? event.reason : (event.error ?? new Error(event.message || 'Unknown error')) + ) + } + buffered.length = 0 +} + if (ENABLED && typeof window !== 'undefined') { window.addEventListener('error', bufferEvent) window.addEventListener('unhandledrejection', bufferEvent) diff --git a/src/utils/sentry-lazy.ts b/src/utils/sentry-lazy.ts index be4a0c918e..749cf5b3b8 100644 --- a/src/utils/sentry-lazy.ts +++ b/src/utils/sentry-lazy.ts @@ -8,8 +8,12 @@ import type { Scope, SeverityLevel } from '@sentry/nextjs' * load. These wrappers keep the SDK out of the initial graph; it arrives when * an app route mounts (see sentry-init) or when something actually throws. * - * Reporting is asynchronous and deliberately returns void — no caller used the - * event id, and `withScope`'s return value was never read either. + * Reporting returns void — no caller used the event id, and `withScope`'s + * return value was never read either. Once the SDK is loaded every call goes + * through synchronously: the SDK pops a `withScope` fork the moment the + * callback returns, so a capture deferred to a later microtask lands on the + * outer scope and loses the fingerprint/tags the callback just set. Only + * calls made while the SDK is still in flight are queued behind the import. */ type SentryModule = typeof import('@sentry/nextjs') @@ -22,22 +26,27 @@ export function loadSentry(): Promise { return loading } +function withSdk(fn: (S: SentryModule) => void): void { + if (loaded) fn(loaded) + else void loadSentry().then(fn) +} + // The SDK's own signatures are overloaded; these mirror the shapes actually // called in this codebase rather than reproducing every overload. type CaptureContext = SeverityLevel | Record export function captureException(error: unknown, hint?: Record): void { - void loadSentry().then((S) => (S.captureException as (e: unknown, h?: unknown) => void)(error, hint)) + withSdk((S) => (S.captureException as (e: unknown, h?: unknown) => void)(error, hint)) } export function captureMessage(message: string, context?: CaptureContext): void { - void loadSentry().then((S) => (S.captureMessage as (m: string, c?: unknown) => void)(message, context)) + withSdk((S) => (S.captureMessage as (m: string, c?: unknown) => void)(message, context)) } export function setUser(user: Parameters[0]): void { - void loadSentry().then((S) => S.setUser(user)) + withSdk((S) => S.setUser(user)) } export function withScope(callback: (scope: Scope) => unknown): void { - void loadSentry().then((S) => S.withScope(callback as (scope: Scope) => void)) + withSdk((S) => S.withScope(callback as (scope: Scope) => void)) } diff --git a/src/utils/sentry-posthog-mirror.ts b/src/utils/sentry-posthog-mirror.ts new file mode 100644 index 0000000000..db4e704e48 --- /dev/null +++ b/src/utils/sentry-posthog-mirror.ts @@ -0,0 +1,55 @@ +import posthog from 'posthog-js' + +import type { ErrorEvent as SentryErrorEvent } from '@sentry/nextjs' + +import { getEventSearchTexts, isThirdPartyScriptFrame, isTransientCapgoNoise } from '../../sentry.utils' + +type EventProcessor = { processEvent?: (event: SentryErrorEvent) => SentryErrorEvent | null } + +/* + * The PostHog mirror is an integration, so its `processEvent` hook runs during + * event processing — BEFORE `beforeSend`. Everything `beforeSendHandler` drops + * has therefore already been copied into PostHog, which is why PostHog's error + * list is Sentry's noise list. + * + * Mostly that is a feature and we leave it alone: PostHog holding what Sentry + * filters is the only reason the browser-native fetch failures were ever + * visible. Suppression there is configured server-side (grouping, per-issue + * rate limit, suppression rules) where it is tunable without a release. + * + * Two classes are worth stopping in the client. Injected third-party scripts: + * nobody can act on them in either tool, and one wallet injector alone billed + * ~3.7k events. And Capgo's transient updater chatter, which is retried on the + * next launch and only ever means "the CDN hiccuped". Wrapping rather than + * filtering inside beforeSend, because beforeSend is downstream of this hook + * and cannot reach it. + */ +export function withoutNoise(integration: T): T { + const inner = integration.processEvent?.bind(integration) + if (!inner) return integration + return { + ...integration, + processEvent: (event: SentryErrorEvent) => { + const frames = (event.exception?.values ?? []).flatMap((v) => v.stacktrace?.frames ?? []) + if (frames.some((frame) => isThirdPartyScriptFrame(frame.filename || ''))) return event + if (isTransientCapgoNoise(getEventSearchTexts(event))) return event + return inner(event) + }, + } +} + +/** + * Cross-link Sentry ↔ PostHog: every Sentry error becomes a `$exception` event + * in PostHog with a Sentry deeplink, and the Sentry event gets a PostHog tag + * pointing back at the user's profile + session replay. posthog.init() runs in + * instrumentation-client.ts; the integration uses the singleton lazily, so + * init order does not matter. Shared by the web and the native Sentry init. + */ +export function posthogErrorMirror() { + return withoutNoise( + posthog.sentryIntegration({ + organization: 'peanut-c34d84c05', + projectId: 4505827431415808, + }) + ) +} diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index bacbdfae4c..e3d7bcf80d 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -546,7 +546,9 @@ export const fetchWithSentry = async ( }) } catch (error) { if (attempt < maxAttempts && error instanceof Error && error.name === 'AbortError') { - console.warn(`Request to ${String(url).replace(/[\r\n]/g, '')} timed out — retrying`) + // console.info, not warn: captureConsoleIntegration listens on + // warn, and the retry outcome is reported explicitly below. + console.info(`Request to ${String(url).replace(/[\r\n]/g, '')} timed out — retrying`) await new Promise((resolve) => setTimeout(resolve, TRANSPORT_TIMEOUT_RETRY_DELAY_MS)) continue } diff --git a/src/utils/webauthn.utils.ts b/src/utils/webauthn.utils.ts index c6abcb7a0f..bbd178cb89 100644 --- a/src/utils/webauthn.utils.ts +++ b/src/utils/webauthn.utils.ts @@ -60,7 +60,11 @@ const PASSKEY_ERROR_SETUP_KEYS = { PASSKEY_NOT_READY: 'passkey.notReady', PASSKEY_STATE: 'passkey.deviceState', PASSKEY_INTERRUPTED: 'passkey.interrupted', -} as const satisfies Partial> + NETWORK: 'passkey.serverUnreachable', + PASSKEY_UNSUPPORTED: 'passkey.unsupported', + PASSKEY_ORIGIN: 'passkey.origin', + LOGIN_ERROR: 'passkey.loginError', +} as const satisfies Record /** Reads the classification code off a thrown PasskeyError, if it carries one. */ export function getPasskeyErrorCode(error: unknown): PasskeyErrorCode | undefined { @@ -83,6 +87,38 @@ export function getPasskeyErrorSetupKey( : undefined } +/** + * zerodev's toWebAuthnKey reads passkey-server responses with no HTTP-status + * check, so a non-2xx surfaces as a raw TypeError thrown from deep inside the + * SDK. Two shapes, two meanings: + * - `.replace is not a function` / `evaluating 'e.replace'`: /login/options + * returned an error body and @simplewebauthn's base64url decoder got no + * challenge. Nothing was authenticated and nothing is known about this + * device's passkey, so it must not classify as LOGIN_ERROR (whose handler + * wipes the session). + * - `…verification.verified`: /login/verify rejected this device's assertion + * (PEANUT-UI-R0V). That is a real login failure; it keeps the LOGIN_ERROR + * path, just with a readable message. + */ +const PASSKEY_SERVER_TYPE_ERROR = /\.replace is not a function|evaluating '[^']*\.replace'/i +const LOGIN_NOT_VERIFIED_TYPE_ERROR = /verif(ication|ied)/i + +export class PasskeyServerError extends Error { + constructor(cause: Error) { + super('Passkey server request failed') + this.name = 'PasskeyServerError' + this.cause = cause + } +} + +export function normalizePasskeyServerError(error: unknown): unknown { + if (!(error instanceof TypeError)) return error + const message = error.message ?? '' + if (PASSKEY_SERVER_TYPE_ERROR.test(message)) return new PasskeyServerError(error) + if (LOGIN_NOT_VERIFIED_TYPE_ERROR.test(message)) return new Error('Login not verified') + return error +} + function isNetworkError(error: Error): boolean { if (error.name === 'TypeError' && /fetch|network/i.test(error.message)) return true // "Load failed" is WebKit's message for a failed fetch (common when the @@ -135,6 +171,9 @@ export function classifyPasskeyError(error: unknown): PasskeyErrorClassification case 'PasskeyShimFailedError': code = 'PASSKEY_STATE' break + case 'PasskeyServerError': + code = 'NETWORK' + break default: if (isNetworkError(err)) code = 'NETWORK' }