From 1f5e8df421bd3f601aeabbe6a6a659acaac141f6 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 22:35:19 -0700 Subject: [PATCH] fix(relay): wait through a scheduled reconnect and name why Relay is not ready (#27141) * fix(relay): wait through a scheduled reconnect and name why Relay is not ready * fix(relay): add the new mint-failure notice strings to the English catalog --------- Co-authored-by: m4air --- .../runtime/relay/desktop-relay-service.ts | 30 +-- .../relay-auth-coordinator-readiness.test.ts | 189 ++++++++++++++++++ .../runtime/relay/relay-auth-coordinator.ts | 64 +++++- src/main/runtime/relay/relay-readiness.ts | 110 ++++++++++ .../runtime/relay/relay-retry-schedule.ts | 46 ++++- .../src/components/mobile/MobileHero.test.tsx | 22 ++ .../mobile-relay-mint-failure-notice.tsx | 40 ++-- src/renderer/src/i18n/locales/en.json | 4 +- 8 files changed, 461 insertions(+), 44 deletions(-) create mode 100644 src/main/runtime/relay/relay-auth-coordinator-readiness.test.ts create mode 100644 src/main/runtime/relay/relay-readiness.ts diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 9e7f13e5672..3bb317b0fdc 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -10,6 +10,7 @@ import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reas import { readRelayAuthContext } from './relay-auth-context' import { relayIdentityKey } from './relay-auth-identity' import { RelayAuthCoordinator } from './relay-auth-coordinator' +import { RelayUnavailableError } from './relay-readiness' import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker' import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' import type { @@ -34,6 +35,10 @@ type DesktopRelayServiceOptions = { // transient auth read) must not stay dead until the user clicks Retry. The // cadence is slow because it is a safety net, not the primary retry path. const RELAY_LIVENESS_INTERVAL_MS = 5 * 60_000 +// How long a demand caller waits through a scheduled reconnect. Phone requests +// get less than a desktop mint so the reply lands inside the phone's budget. +const PAIRING_MINT_READINESS_WAIT_MS = 20_000 +const PHONE_REQUEST_READINESS_WAIT_MS = 10_000 export class DesktopRelayService { private readonly coordinator: RelayAuthCoordinator @@ -113,7 +118,7 @@ export class DesktopRelayService { relayDeviceId: string ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> { return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => { - const broker = await this.requireActiveBroker() + const broker = await this.requireActiveBroker(PAIRING_MINT_READINESS_WAIT_MS) const relay = await broker.createPairingRelay(relayDeviceId) return { relay, @@ -151,7 +156,9 @@ export class DesktopRelayService { return { v: 1, relay: null } } return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => { - const broker = await this.activeBrokerForDemand() + const broker = await this.requireActiveBroker(PHONE_REQUEST_READINESS_WAIT_MS).catch( + () => null + ) if (!broker?.endpoint) { return { v: 1, relay: null } } @@ -191,7 +198,7 @@ export class DesktopRelayService { throw new Error('relay_disabled_for_device') } return await this.withTransientDemand(`provision:${context.deviceId}`, async () => { - const broker = await this.requireActiveBroker() + const broker = await this.requireActiveBroker(PHONE_REQUEST_READINESS_WAIT_MS) if (!broker.endpoint) { throw new Error('relay_control_not_active') } @@ -282,17 +289,14 @@ export class DesktopRelayService { } } - private async activeBrokerForDemand(): Promise { - const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker()) - return broker instanceof RelaySessionBroker ? broker : null - } - - private async requireActiveBroker(): Promise { - const broker = await this.activeBrokerForDemand() - if (!broker) { - throw new Error('relay_control_not_active') + private async requireActiveBroker(waitMs: number): Promise { + const readiness = await this.coordinator.waitForReadiness(waitMs) + if (readiness.ready && readiness.broker instanceof RelaySessionBroker) { + return readiness.broker } - return broker + throw new RelayUnavailableError( + readiness.ready ? { reason: 'control_not_active', retryAt: null } : readiness + ) } private refreshDemand(): void { diff --git a/src/main/runtime/relay/relay-auth-coordinator-readiness.test.ts b/src/main/runtime/relay/relay-auth-coordinator-readiness.test.ts new file mode 100644 index 00000000000..5bfc499973b --- /dev/null +++ b/src/main/runtime/relay/relay-auth-coordinator-readiness.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mobileRelayMintFailureFromUnknown } from '../../../shared/mobile-relay-mint-failure' +import { DesktopRelayService } from './desktop-relay-service' +import { RelayAuthCoordinator } from './relay-auth-coordinator' +import type { RelayAuthContext } from './relay-auth-identity' +import { RelayHttpError } from './relay-http-client' +import { relayUnavailableReasonFor } from './relay-readiness' +import { RelaySessionBroker } from './relay-session-broker' + +const context: RelayAuthContext = { + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + accessToken: 'access-1', + relayEntitled: true +} + +function fakeSessionBroker(): RelaySessionBroker { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service only calls the stubbed members below. + const broker = Object.create(RelaySessionBroker.prototype) as RelaySessionBroker + Object.defineProperties(broker, { + closeNow: { value: vi.fn() }, + isLive: { value: () => true }, + createPairingRelay: { value: vi.fn(async () => ({ inviteExpiresAt: 1 })) }, + endpoint: { value: null }, + hostId: { value: 'host-1' }, + ownerIdentityKey: { value: 'owner-1' } + }) + return broker +} + +function serviceWith(coordinator: RelayAuthCoordinator): DesktopRelayService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createPairingRelay touches only the fields assigned here. + const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService + return Object.assign(service, { + coordinator, + stopped: false, + livenessTimer: null, + demandExpiryTimer: null, + demandLedger: { acquireTransient: () => () => {}, nextPendingExpiry: () => null } + }) +} + +async function mintFailureCode(service: DesktopRelayService): Promise { + const error = await service.createPairingRelay('device-1').then( + () => null, + (caught: unknown) => caught + ) + return mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'failed' + }).code +} + +describe('relay readiness', () => { + afterEach(() => vi.useRealTimers()) + + it('waits through the retry a cold-start failure scheduled instead of failing the mint', async () => { + vi.useFakeTimers() + const broker = fakeSessionBroker() + const openBroker = vi + .fn() + .mockRejectedValueOnce(new RelayHttpError('assignment', 503)) + .mockResolvedValueOnce(broker) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: () => {}, + random: () => 1 + }) + const service = serviceWith(coordinator) + const mint = service.createPairingRelay('device-1') + await vi.advanceTimersByTimeAsync(2_000) + await expect(mint).resolves.toMatchObject({ binding: { relayHostId: 'host-1' } }) + expect(openBroker).toHaveBeenCalledTimes(2) + service.stop() + }) + + it('reports the failure cause when the retry falls outside the wait', async () => { + vi.useFakeTimers() + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: async () => { + throw new RelayHttpError('assignment', 503, 60_000) + }, + onStatus: () => {} + }) + const service = serviceWith(coordinator) + await expect(mintFailureCode(service)).resolves.toBe('relay_director_assignment_503') + service.stop() + }) + + it('names a signed-out or unentitled desktop instead of relay_control_not_active', async () => { + let current: RelayAuthContext | null = null + const coordinator = new RelayAuthCoordinator({ + readContext: async () => current, + openBroker: async () => fakeSessionBroker(), + onStatus: () => {} + }) + const service = serviceWith(coordinator) + await expect(mintFailureCode(service)).resolves.toBe('relay_signed_out') + current = { ...context, relayEntitled: false } + await expect(mintFailureCode(service)).resolves.toBe('relay_not_entitled') + service.stop() + }) + + it('stops waiting when a fence cancels the scheduled retry', async () => { + vi.useFakeTimers() + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: async () => { + throw new RelayHttpError('token-exchange', 502) + }, + onStatus: () => {}, + random: () => 1 + }) + coordinator.reconcile() + const waiting = coordinator.waitForReadiness(20_000) + await vi.advanceTimersByTimeAsync(0) + coordinator.fenceAndCloseNow() + await expect(waiting).resolves.toEqual({ + ready: false, + reason: 'control_not_active', + retryAt: null + }) + }) + + function coldStartWithHungRetry(): RelayAuthCoordinator { + const openBroker = vi + .fn() + .mockRejectedValueOnce(new RelayHttpError('assignment', 503)) + .mockImplementationOnce(() => new Promise(() => {})) + return new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: () => {}, + random: () => 0.5 + }) + } + + it('returns at its deadline while the retry it followed is still opening', async () => { + vi.useFakeTimers() + const coordinator = coldStartWithHungRetry() + coordinator.reconcile() + let settled: unknown = null + void coordinator.waitForReadiness(10_000).then((readiness) => (settled = readiness)) + await vi.advanceTimersByTimeAsync(9_999) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toMatchObject({ ready: false, reason: 'director_assignment_503' }) + coordinator.stop() + }) + + it.each(['fence', 'stop'] as const)( + 'a %s releases a waiter whose followed retry is still opening', + async (action) => { + vi.useFakeTimers() + const coordinator = coldStartWithHungRetry() + coordinator.reconcile() + let settled: unknown = null + void coordinator.waitForReadiness(20_000).then((readiness) => (settled = readiness)) + await vi.advanceTimersByTimeAsync(1_000) + expect(settled).toBeNull() + if (action === 'fence') { + coordinator.fenceAndCloseNow() + } else { + coordinator.stop() + } + await vi.advanceTimersByTimeAsync(0) + expect(settled).toMatchObject({ ready: false, retryAt: null }) + coordinator.stop() + } + ) + + it('classifies transport failures into actionable reasons', () => { + const tls = new TypeError('fetch failed', { + cause: Object.assign(new Error('unable to get local issuer certificate'), { + code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' + }) + }) + expect(relayUnavailableReasonFor(tls)).toBe('tls_untrusted') + expect(relayUnavailableReasonFor(new RelayHttpError('assignment', 429))).toBe('rate_limited') + expect(relayUnavailableReasonFor(new Error('relay_control_closed_4429'))).toBe('rate_limited') + expect(relayUnavailableReasonFor(new Error('relay_control_connect_timeout'))).toBe( + 'control_connect_timeout' + ) + expect(relayUnavailableReasonFor(new Error('ECONNRESET with secret=abc'))).toBe('network') + }) +}) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index 00fc92654e8..002efaa6966 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -6,6 +6,11 @@ import { relayStatusCellUrl } from '../../../shared/mobile-relay-status' import type { RelayBrokerStatus } from './relay-session-broker' import type { RelayAccessTokenRefresh } from './relay-session-broker-contract' import { RelayHttpError, shouldRetryRelayConnectionError } from './relay-http-client' +import { + RelayReadinessWaiters, + relayUnavailableReasonFor, + type RelayUnavailable +} from './relay-readiness' import { RelayRetrySchedule } from './relay-retry-schedule' import { RelayLingerTimer } from './relay-linger-timer' import { relayIdentityKey, type RelayAuthContext } from './relay-auth-identity' @@ -29,6 +34,10 @@ type RelayAuthCoordinatorOptions = { random?: () => number } +export type RelayReadiness = + | { ready: true; broker: CoordinatedRelayBroker } + | ({ ready: false } & RelayUnavailable) + type BrokerOwnership = { identityKey: string broker: CoordinatedRelayBroker | null @@ -51,6 +60,9 @@ export class RelayAuthCoordinator { private latestReconcile: Promise = Promise.resolve() private readonly linger = new RelayLingerTimer() private readonly retry: RelayRetrySchedule + // Why the latest reconcile left no live broker; cleared when one registers. + private unavailableReason: RelayUnavailable['reason'] | null = null + private readonly readinessWaiters = new RelayReadinessWaiters() private stopped = false constructor(options: RelayAuthCoordinatorOptions) { @@ -87,6 +99,9 @@ export class RelayAuthCoordinator { this.retry.reset() this.invalidatePendingOwnerships() this.invalidateOwnership(hostCloseReason) + this.unavailableReason = + hostCloseReason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT ? 'signed_out' : null + this.readinessWaiters.releaseAll() this.publish('offline') } @@ -128,19 +143,45 @@ export class RelayAuthCoordinator { this.beginReconcile(false) } + readiness(): RelayReadiness { + const broker = this.getLiveBroker() + const reason = this.unavailableReason ?? 'control_not_active' + return broker ? { ready: true, broker } : { ready: false, reason, retryAt: this.retry.retryAt } + } + + // Awaits the reconcile in flight (and any it was superseded by), never a retry. async waitForLiveBroker(): Promise { - while (!this.stopped) { - const broker = this.getLiveBroker() - if (broker) { - return broker - } - const pending = this.latestReconcile + let pending: Promise | null = null + while (!this.stopped && !this.getLiveBroker() && pending !== this.latestReconcile) { + pending = this.latestReconcile await pending - if (pending === this.latestReconcile) { - return this.getLiveBroker() + } + return this.stopped ? null : this.getLiveBroker() + } + + // Why waitMs: a cold start that fails once arms a retry seconds away; a caller + // with time to spare waits for it instead of reporting the first failure. The + // deadline bounds every wait, and a fence releases the caller at once. + async waitForReadiness(waitMs: number): Promise { + const deadline = Date.now() + waitMs + while (!this.stopped && !this.getLiveBroker()) { + const pending = this.latestReconcile + if (!(await this.readinessWaiters.until(pending, deadline))) { + break + } + if (pending !== this.latestReconcile) { + continue + } + const retryAt = this.retry.retryAt + if (this.getLiveBroker() || retryAt === null || retryAt > deadline) { + break + } + const retried = await this.readinessWaiters.until(this.retry.settled(), deadline) + if (!retried || pending === this.latestReconcile) { + break } } - return null + return this.readiness() } stop(): void { @@ -159,12 +200,14 @@ export class RelayAuthCoordinator { this.linger.cancel() this.retry.reset() this.invalidateOwnership(authLossCloseReason(context)) + this.unavailableReason = context ? 'not_entitled' : 'signed_out' this.publish('offline') return } const nextIdentityKey = relayIdentityKey(context.identity) if (expectedIdentityKey && nextIdentityKey !== expectedIdentityKey) { this.retry.reset() + this.unavailableReason = 'identity_changed' this.publish('offline') return } @@ -188,6 +231,7 @@ export class RelayAuthCoordinator { (this.ownership.broker?.isLive?.() ?? true) ) { this.retry.reset() + this.unavailableReason = null this.publish('registered') return } @@ -221,6 +265,7 @@ export class RelayAuthCoordinator { } this.ownership = ownership this.retry.reset() + this.unavailableReason = null this.publish('registered') } catch (error) { if (this.isEpochCurrent(epoch)) { @@ -230,6 +275,7 @@ export class RelayAuthCoordinator { '[relay] broker reconcile failed:', error instanceof Error ? error.message : String(error) ) + this.unavailableReason = relayUnavailableReasonFor(error) this.publish('offline') if (shouldRetryRelayConnectionError(error)) { const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 diff --git a/src/main/runtime/relay/relay-readiness.ts b/src/main/runtime/relay/relay-readiness.ts new file mode 100644 index 00000000000..2987a014030 --- /dev/null +++ b/src/main/runtime/relay/relay-readiness.ts @@ -0,0 +1,110 @@ +import { RelayHttpError } from './relay-http-client' + +// Why the last reconcile could not produce a live broker. Every value is a +// lowercase token so `relay_` stays a valid mint failure code. +export type RelayUnavailableReason = + | 'signed_out' + | 'not_entitled' + | 'identity_changed' + | 'rate_limited' + | 'tls_untrusted' + | 'connect_timeout' + | 'network' + | 'control_not_active' + | `director_${'token_exchange' | 'assignment'}_${number}` + // A relay-coded error message with its `relay_` prefix removed. + | `control_${string}` + +export type RelayUnavailable = { reason: RelayUnavailableReason; retryAt: number | null } + +const TLS_TRUST_ERROR_CODES = new Set([ + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_UNTRUSTED', + 'CERT_SIGNATURE_FAILURE' +]) +const TIMEOUT_ERROR_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT']) +const RELAY_CONTROL_MESSAGE = /^relay_(control_[a-z0-9_]{1,60})$/ + +function errorCode(error: unknown): string | null { + return typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' + ? error.code + : null +} + +// undici wraps the socket error as `TypeError('fetch failed', { cause })`. +function errorChain(error: unknown): unknown[] { + const chain: unknown[] = [] + let current = error + while (current !== undefined && current !== null && chain.length < 4) { + chain.push(current) + current = current instanceof Error ? current.cause : undefined + } + return chain +} + +export function relayUnavailableReasonFor(error: unknown): RelayUnavailableReason { + if (error instanceof RelayHttpError) { + return error.statusCode === 429 + ? 'rate_limited' + : `director_${error.operation === 'token-exchange' ? 'token_exchange' : 'assignment'}_${error.statusCode}` + } + for (const link of errorChain(error)) { + const code = errorCode(link) + if (code && (TLS_TRUST_ERROR_CODES.has(code) || code.startsWith('CERT_'))) { + return 'tls_untrusted' + } + if (code && TIMEOUT_ERROR_CODES.has(code)) { + return 'connect_timeout' + } + } + const message = error instanceof Error ? RELAY_CONTROL_MESSAGE.exec(error.message) : null + if (message?.[1] === 'control_closed_4429') { + return 'rate_limited' + } + return message ? `control_${message[1].slice('control_'.length)}` : 'network' +} + +// Bounds a readiness wait by its caller's deadline and lets a fence release it, +// without cancelling the shared reconnect work other callers still need. +export class RelayReadinessWaiters { + private readonly releases = new Set<() => void>() + + // Resolves true when `work` settles first, false at the deadline or on release. + async until(work: Promise, deadline: number): Promise { + let release = (): void => {} + const released = new Promise((resolve) => { + release = () => resolve(false) + }) + const timer = setTimeout(release, Math.max(0, deadline - Date.now())) + this.releases.add(release) + try { + return await Promise.race([work.then(() => true), released]) + } finally { + clearTimeout(timer) + this.releases.delete(release) + } + } + + releaseAll(): void { + for (const release of this.releases) { + release() + } + } +} + +// Carries the readiness reason as a `relay_*` code that mint failure reporting forwards as-is. +export class RelayUnavailableError extends Error { + readonly code: string + + constructor(readonly unavailable: RelayUnavailable) { + super(`relay_${unavailable.reason}`) + this.code = this.message + } +} diff --git a/src/main/runtime/relay/relay-retry-schedule.ts b/src/main/runtime/relay/relay-retry-schedule.ts index c8fdfba1455..61c017c962d 100644 --- a/src/main/runtime/relay/relay-retry-schedule.ts +++ b/src/main/runtime/relay/relay-retry-schedule.ts @@ -6,6 +6,8 @@ const RETRY_MAX_MS = 5 * 60_000 export class RelayRetrySchedule { private timer: ReturnType | null = null private attempt = 0 + private dueAt: number | null = null + private readonly settledWaiters = new Set<() => void>() constructor(private readonly random: () => number = Math.random) {} @@ -13,6 +15,17 @@ export class RelayRetrySchedule { return this.timer !== null } + get retryAt(): number | null { + return this.dueAt + } + + // Resolves once the pending retry has run or was cancelled. + settled(): Promise { + return this.timer + ? new Promise((resolve) => this.settledWaiters.add(resolve)) + : Promise.resolve() + } + schedule(retryAfterMs: number, retry: () => void): void { if (this.timer) { return @@ -20,14 +33,13 @@ export class RelayRetrySchedule { const exponent = Math.min(this.attempt, Math.ceil(Math.log2(RETRY_MAX_MS / RETRY_BASE_MS))) const capMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** exponent) this.attempt++ - const jitterMs = Math.floor(this.random() * (capMs + 1)) - this.timer = setTimeout( - () => { - this.timer = null - retry() - }, - Math.max(jitterMs, retryAfterMs) - ) + const delayMs = Math.max(Math.floor(this.random() * (capMs + 1)), retryAfterMs) + this.dueAt = Date.now() + delayMs + this.timer = setTimeout(() => { + this.clearTimer() + retry() + this.settle() + }, delayMs) } reset(): void { @@ -36,9 +48,25 @@ export class RelayRetrySchedule { // Keeps the attempt count: a superseding reconcile still backs off from it. cancel(): void { + if (this.timer) { + this.clearTimer() + this.settle() + } + } + + private clearTimer(): void { if (this.timer) { clearTimeout(this.timer) - this.timer = null + } + this.timer = null + this.dueAt = null + } + + private settle(): void { + const waiters = [...this.settledWaiters] + this.settledWaiters.clear() + for (const resolve of waiters) { + resolve() } } } diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index d178a28db9c..f6d5662adb6 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -235,6 +235,28 @@ describe('HeroFlow height', () => { expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() }) + it('names a signed-out or unentitled desktop instead of offering a futile retry', () => { + renderFlow(1, { + relayMintFailure: { + code: 'relay_signed_out', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + expect(screen.getByRole('alert')).toHaveTextContent('Sign in again to use Orca Relay') + expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull() + cleanup() + renderFlow(1, { + relayMintFailure: { + code: 'relay_not_entitled', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + expect(screen.getByRole('alert')).toHaveTextContent('isn’t included with this Orca account') + expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull() + }) + it('hides the mint-failure notice when a Relay QR is shown', () => { renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' }) expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() diff --git a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx index ee3d82a51d1..ed0f38cbf88 100644 --- a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx +++ b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx @@ -25,9 +25,10 @@ export function MobileRelayMintFailureNotice({ }): React.JSX.Element { const providerMissing = failure.stage === 'provider_missing' // Why: a revoked cloud session fails every mint; "retry or use LAN" hides the one action that works. - const reconnectRequired = useAppStore( - (state) => state.orcaProfileAuthStatus?.state === 'reconnect-required' - ) + const reconnectRequired = + useAppStore((state) => state.orcaProfileAuthStatus?.state === 'reconnect-required') || + failure.code === 'relay_signed_out' + const notEntitled = failure.code === 'relay_not_entitled' const [showBusyFeedback, setShowBusyFeedback] = useState(false) useEffect(() => { if (!busy) { @@ -53,10 +54,15 @@ export function MobileRelayMintFailureNotice({ 'auto.components.mobile.MobileRelayMintFailureNotice.reconnectTitle', 'Your Orca account session expired.' ) - : translate( - 'auto.components.mobile.MobileRelayMintFailureNotice.title', - 'Couldn’t create a Relay pairing code.' - ) + : notEntitled + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.notEntitledTitle', + 'Orca Relay isn’t included with this Orca account.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.title', + 'Couldn’t create a Relay pairing code.' + ) const body = visibleBusy ? translate( 'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody', @@ -72,10 +78,20 @@ export function MobileRelayMintFailureNotice({ 'auto.components.mobile.MobileRelayMintFailureNotice.reconnectBody', 'Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.' ) - : translate( - 'auto.components.mobile.MobileRelayMintFailureNotice.body', - 'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.' - ) + : notEntitled + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody', + 'Use LAN to pair over Tailscale or the same Wi‑Fi.' + ) + : failure.code === 'relay_rate_limited' + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.rateLimitedBody', + 'Orca Relay is busy. Wait a minute and retry, or use LAN to pair over Tailscale or the same Wi‑Fi.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.body', + 'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.' + ) return (
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')} - {!providerMissing && !reconnectRequired ? ( + {!providerMissing && !reconnectRequired && !notEntitled ? (