From e599ac4637b0a590130c7a9fa168317658e53055 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 23:23:47 -0700 Subject: [PATCH] fix(relay): install the desktop Relay provider on demand (#27159) * fix(relay): install the desktop Relay provider on demand * fix(relay): suspend Relay installs across sign-out and bound the mint's install wait --------- Co-authored-by: m4air --- .../desktop-relay-service-installer.test.ts | 88 +++++++++++++++++++ .../relay/desktop-relay-service-installer.ts | 87 ++++++++++++++++++ ...runtime-rpc-relay-provider-install.test.ts | 76 ++++++++++++++++ .../runtime-rpc/runtime-rpc-mobile-pairing.ts | 12 +++ .../runtime-rpc/runtime-rpc-pairing.ts | 4 + .../runtime/runtime-rpc/runtime-rpc-state.ts | 2 + .../startup/main-process-relay-startup.ts | 39 ++++++++ ...-process-runtime-launch-activation.test.ts | 2 +- .../startup/main-process-runtime-launch.ts | 42 ++------- src/main/startup/main-process-state.ts | 3 + src/main/startup/main-window-core-services.ts | 10 ++- .../src/components/mobile/MobileHero.test.tsx | 4 +- .../mobile-relay-mint-failure-notice.tsx | 2 +- 13 files changed, 330 insertions(+), 41 deletions(-) create mode 100644 src/main/runtime/relay/desktop-relay-service-installer.test.ts create mode 100644 src/main/runtime/relay/desktop-relay-service-installer.ts create mode 100644 src/main/runtime/runtime-rpc-relay-provider-install.test.ts create mode 100644 src/main/startup/main-process-relay-startup.ts diff --git a/src/main/runtime/relay/desktop-relay-service-installer.test.ts b/src/main/runtime/relay/desktop-relay-service-installer.test.ts new file mode 100644 index 00000000000..f3de490af03 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service-installer.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DesktopRelayService } from './desktop-relay-service' +import { createDesktopRelayServiceInstaller } from './desktop-relay-service-installer' + +function fakeService(): DesktopRelayService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer only calls start() and wraps the provider methods. + return { start: vi.fn() } as unknown as DesktopRelayService +} + +function installerWith(overrides: { + create: () => DesktopRelayService | null + isFenced?: () => boolean + whenNetworkReady?: Promise +}) { + const runtimeRpc = { setMobileRelayPairingProvider: vi.fn() } + const onInstalled = vi.fn() + const installer = createDesktopRelayServiceInstaller({ + runtimeRpc, + whenNetworkReady: overrides.whenNetworkReady ?? Promise.resolve(), + isFenced: overrides.isFenced ?? (() => false), + create: overrides.create, + onInstalled + }) + return { installer, runtimeRpc, onInstalled } +} + +describe('createDesktopRelayServiceInstaller', () => { + it('retries a construction that failed at launch on the next demand, without a restart', async () => { + const service = fakeService() + const create = vi + .fn<() => DesktopRelayService | null>() + .mockImplementationOnce(() => { + throw new Error('mobile_runtime_not_ready') + }) + .mockReturnValueOnce(service) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { installer, runtimeRpc, onInstalled } = installerWith({ create }) + + await expect(installer.ensure()).resolves.toBe(false) + expect(runtimeRpc.setMobileRelayPairingProvider).not.toHaveBeenCalled() + await expect(installer.ensure()).resolves.toBe(true) + expect(onInstalled).toHaveBeenCalledWith(service) + expect(runtimeRpc.setMobileRelayPairingProvider).toHaveBeenCalledOnce() + expect(service.start).toHaveBeenCalledOnce() + await expect(installer.ensure()).resolves.toBe(true) + expect(create).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + it('holds the first install until the network is ready and shares one in-flight install', async () => { + let ready!: () => void + const whenNetworkReady = new Promise((resolve) => (ready = resolve)) + const create = vi.fn(() => fakeService()) + const { installer } = installerWith({ create, whenNetworkReady }) + + const first = installer.ensure() + const second = installer.ensure() + await Promise.resolve() + expect(create).not.toHaveBeenCalled() + ready() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(create).toHaveBeenCalledOnce() + }) + + it('refuses installs after sign-out, including one already waiting, until the next auth change', async () => { + let ready!: () => void + const whenNetworkReady = new Promise((resolve) => (ready = resolve)) + const create = vi.fn(() => fakeService()) + const { installer } = installerWith({ create, whenNetworkReady }) + + const waiting = installer.ensure() + installer.suspend() + ready() + await expect(waiting).resolves.toBe(false) + await expect(installer.ensure()).resolves.toBe(false) + expect(create).not.toHaveBeenCalled() + await expect(installer.authChanged()).resolves.toBe(true) + expect(create).toHaveBeenCalledOnce() + }) + + it('never installs once quit or relaunch fenced the host', async () => { + const create = vi.fn(() => fakeService()) + const { installer } = installerWith({ create, isFenced: () => true }) + + await expect(installer.ensure()).resolves.toBe(false) + expect(create).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service-installer.ts b/src/main/runtime/relay/desktop-relay-service-installer.ts new file mode 100644 index 00000000000..129db56b5a3 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service-installer.ts @@ -0,0 +1,87 @@ +import type { MobileRelayPairingProvider } from '../runtime-rpc/runtime-rpc-pairing-types' +import type { DesktopRelayService } from './desktop-relay-service' + +type RelayProviderHost = { + setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void +} + +export type DesktopRelayServiceInstallerOptions = { + runtimeRpc: RelayProviderHost + // App-owned fetchers must not run ahead of the persisted proxy. + whenNetworkReady: Promise + // Quit/relaunch fencing: a fenced host never installs a new service. + isFenced: () => boolean + // Returns null when this build has no cloud auth; throws on a failed construction. + create: () => DesktopRelayService | null + onInstalled: (service: DesktopRelayService) => void +} + +export type DesktopRelayServiceInstaller = { + ensure(): Promise + // Sign-out: refuse installs, including one already waiting, until the next auth change. + suspend(): void + authChanged(): Promise +} + +function pairingProviderFor(service: DesktopRelayService): MobileRelayPairingProvider { + return { + createPairingRelay: (relayDeviceId) => service.createPairingRelay(relayDeviceId), + onDeviceRevokeQueued: (item) => service.onDeviceRevokeQueued(item), + onDemandStateChanged: () => service.demandStateChanged(), + getEndpoints: (context, params) => service.getEndpoints(context, params), + provisionRelay: (context, params) => service.provisionRelay(context, params) + } +} + +// Installs the Relay provider once, on demand: at launch, after a cloud auth +// change, and before an automatic pairing mint. A construction that failed once +// (or ran before the proxy landed) is retried by the next demand, not a restart. +export function createDesktopRelayServiceInstaller( + options: DesktopRelayServiceInstallerOptions +): DesktopRelayServiceInstaller { + let installed: DesktopRelayService | null = null + let pending: Promise | null = null + let suspended = false + const install = async (): Promise => { + await options.whenNetworkReady + if (installed || suspended || options.isFenced()) { + return installed !== null + } + try { + const service = options.create() + if (!service) { + return false + } + installed = service + options.onInstalled(service) + options.runtimeRpc.setMobileRelayPairingProvider(pairingProviderFor(service)) + service.start() + return true + } catch (error) { + console.warn( + '[relay] Desktop relay startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + return false + } + } + const ensure = (): Promise => { + if (installed) { + return Promise.resolve(true) + } + pending ??= install().finally(() => { + pending = null + }) + return pending + } + return { + ensure, + suspend: () => { + suspended = true + }, + authChanged: () => { + suspended = false + return ensure() + } + } +} diff --git a/src/main/runtime/runtime-rpc-relay-provider-install.test.ts b/src/main/runtime/runtime-rpc-relay-provider-install.test.ts new file mode 100644 index 00000000000..5f591b52f2b --- /dev/null +++ b/src/main/runtime/runtime-rpc-relay-provider-install.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +describe('automatic mobile pairing with a missing Relay provider (#20005)', () => { + it('installs the provider on demand instead of failing provider_missing', async () => { + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath: mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')), + enableWebSocket: true, + wsPort: 0 + }) + const relay = { + v: 1 as const, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 as const + } + const install = vi.fn(async () => { + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay, + binding: { relayHostId: relay.relayHostId, relayDeviceId, ownerIdentityKey: 'owner' } + }), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + }) + server.setMobileRelayPairingProviderInstaller(install) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(install).toHaveBeenCalledOnce() + expect(offer).toMatchObject({ available: true, connectionMode: 'automatic' }) + } finally { + await server.stop() + } + }) + + it('reports the provider missing when an install outlives the mint budget', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath: mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')), + enableWebSocket: true, + wsPort: 0 + }) + server.setMobileRelayPairingProviderInstaller(() => new Promise(() => {})) + await server.start() + try { + const offer = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.advanceTimersByTimeAsync(15_000) + await expect(offer).resolves.toMatchObject({ + available: false, + relayFailure: { stage: 'provider_missing' } + }) + } finally { + vi.useRealTimers() + await server.stop() + } + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts index de4e1d3ce88..688a91365cc 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts @@ -6,6 +6,7 @@ import { import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../network-exposure-guidance' import { RuntimeRpcPairing } from './runtime-rpc-pairing' +import { withTimeout } from '../../../shared/promise-timeout-fallback' import { DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE, pairingUnavailable, @@ -14,6 +15,9 @@ import { type PairingOfferUnavailable } from './runtime-rpc-pairing-types' +// How long an automatic mint waits for an on-demand Relay install before reporting it missing. +const RELAY_PROVIDER_INSTALL_WAIT_MS = 15_000 + export class RuntimeRpcMobilePairing extends RuntimeRpcPairing { async createMobilePairingOffer(args: { address?: string | null @@ -145,6 +149,14 @@ export class RuntimeRpcMobilePairing extends RuntimeRpcPairing { relayFailure } } + if (!this.mobileRelayPairingProvider && this.mobileRelayPairingProviderInstaller) { + // Bounded: the install waits for the persisted proxy, which has no time limit of its own. + await withTimeout( + this.mobileRelayPairingProviderInstaller(), + RELAY_PROVIDER_INSTALL_WAIT_MS, + undefined + ) + } const relayProvider = this.mobileRelayPairingProvider if (!relayProvider) { return refuseAutomaticWithoutRelay({ diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 987d2b66ada..7a3a263f9ae 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -86,6 +86,10 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayPairingProvider = provider } + setMobileRelayPairingProviderInstaller(install: (() => Promise) | null): void { + this.mobileRelayPairingProviderInstaller = install + } + async revokeMobileDevice(deviceId: string): Promise { const device = this.deviceRegistry?.getDevice(deviceId) if (device?.scope !== 'mobile') { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index b6492a6683b..b33fd87534b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -70,6 +70,8 @@ export class RuntimeRpcState { // transports under the SAME wiring (see ensureMobileSocketWiring) instead of orphaning relay sockets. protected detachWebSocketWiring: (() => void) | null = null protected mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + // Why: lets an automatic mint install a provider whose launch-time construction failed. + protected mobileRelayPairingProviderInstaller: (() => Promise) | null = null protected mobileRelayPairingOfferQueue: Promise = Promise.resolve() protected mobileRelayPairingOfferInFlight: { generation: number diff --git a/src/main/startup/main-process-relay-startup.ts b/src/main/startup/main-process-relay-startup.ts new file mode 100644 index 00000000000..1f9d47ed789 --- /dev/null +++ b/src/main/startup/main-process-relay-startup.ts @@ -0,0 +1,39 @@ +import { app, powerMonitor } from 'electron' +import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { createDesktopRelayServiceInstaller } from '../runtime/relay/desktop-relay-service-installer' +import { publishDesktopRelayStatus } from './main-process-relay-status' +import { mainProcessState as state } from './main-process-state' + +// Registers the on-demand Relay installer and starts the first install, which +// waits for the persisted proxy itself, so it can run before the proxy lands. +export function startDesktopRelayService(runtimeRpc: OrcaRuntimeRpcServer): void { + const installer = createDesktopRelayServiceInstaller({ + runtimeRpc, + whenNetworkReady: state.initialProxyApplicationReady, + isFenced: () => state.isQuitting, + create: () => { + const cloudAuth = getOrcaCloudAuthConfig() + return cloudAuth.configured + ? new DesktopRelayService({ + authConfig: cloudAuth.config, + userDataPath: getProfileUserDataPath(), + appVersion: app.getVersion(), + runtimeRpc, + onStatus: publishDesktopRelayStatus + }) + : null + }, + onInstalled: (service) => { + state.desktopRelayService = service + } + }) + state.desktopRelayInstaller = installer + runtimeRpc.setMobileRelayPairingProviderInstaller(() => installer.ensure()) + // Why: sleeping past relay-token expiry kills the broker with no retry + // timer; resume is the moment that state becomes recoverable. + powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) + void installer.ensure() +} diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts index 36f8aa69f04..cfc85d7b75e 100644 --- a/src/main/startup/main-process-runtime-launch-activation.test.ts +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -50,7 +50,7 @@ vi.mock('./main-process-relay-status', () => ({ getDesktopRelayStatus: vi.fn(), publishDesktopRelayStatus: vi.fn() })) -vi.mock('../runtime/relay/desktop-relay-service', () => ({ DesktopRelayService: class {} })) +vi.mock('./main-process-relay-startup', () => ({ startDesktopRelayService: vi.fn() })) vi.mock('./main-process-serve', () => ({ getServeOptions: vi.fn(() => null), getBundledWebClientRoot: vi.fn(() => null), diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 6204ca50dc7..80979d6ca54 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -1,7 +1,5 @@ -import { app, powerMonitor, type BrowserWindow } from 'electron' +import { app, type BrowserWindow } from 'electron' import { is } from '@electron-toolkit/utils' -import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' -import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath @@ -13,8 +11,8 @@ import { LocalPtyProvider } from '../providers/local-pty-provider' import { publishHeadlessRuntimeGraph } from '../runtime/headless-runtime-graph' import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' import { browserManager } from '../browser/browser-manager' -import { getDesktopRelayStatus, publishDesktopRelayStatus } from './main-process-relay-status' -import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { getDesktopRelayStatus } from './main-process-relay-status' +import { startDesktopRelayService } from './main-process-relay-startup' import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve' import { bindTerminalRuntimeStartupServices, @@ -249,42 +247,16 @@ async function launchDesktopMode( showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) ) } + // Why before the proxy await: a mint in that window must wait for the installer, not fail + // provider_missing; the installer itself holds its first request until the proxy lands. + startDesktopRelayService(runtimeRpc) // Why after the window and not before it: the default-session request guard already holds every // fetcher until the persisted proxy lands, so this only has to keep the launch phase itself - // ordered ahead of the relay — it must not gate the renderer. + // ordered ahead of the push gateway — it must not gate the renderer. await state.initialProxyApplicationReady // Why after the proxy await: the push gateway client is an app-owned fetcher, so it must not // issue its first request ahead of the persisted proxy. startDesktopPushService(runtimeRpc) - const cloudAuth = getOrcaCloudAuthConfig() - if (cloudAuth.configured) { - try { - const relayService = new DesktopRelayService({ - authConfig: cloudAuth.config, - userDataPath: getProfileUserDataPath(), - appVersion: app.getVersion(), - runtimeRpc, - onStatus: publishDesktopRelayStatus - }) - state.desktopRelayService = relayService - runtimeRpc.setMobileRelayPairingProvider({ - createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), - onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), - onDemandStateChanged: () => relayService.demandStateChanged(), - getEndpoints: (context, params) => relayService.getEndpoints(context, params), - provisionRelay: (context, params) => relayService.provisionRelay(context, params) - }) - relayService.start() - // Why: sleeping past relay-token expiry kills the broker with no retry - // timer; resume is the moment that state becomes recoverable. - powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) - } catch (error) { - console.warn( - '[relay] Desktop relay startup unavailable:', - error instanceof Error ? error.message : String(error) - ) - } - } // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. win.once('show', () => { // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 35508809164..972e3d58d85 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -14,6 +14,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RateLimitService } from '../rate-limits/service' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import type { DesktopRelayServiceInstaller } from '../runtime/relay/desktop-relay-service-installer' import type { DesktopPushService } from '../runtime/push/desktop-push-service' import type { StarNagService } from '../star-nag/service' import type { AgentAwakeService } from '../agent-awake-service' @@ -87,6 +88,8 @@ export const mainProcessState = { runtimeRpc: null as OrcaRuntimeRpcServer | null, serveReadinessPublisher: new ServeReadinessPublisher(), desktopRelayService: null as DesktopRelayService | null, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the installer assigned by main-process-relay-startup. + desktopRelayInstaller: null as DesktopRelayServiceInstaller | null, desktopPushService: null as DesktopPushService | null, desktopRelayStatus: 'offline' as RelayBrokerStatus, desktopRelayCellUrl: undefined as string | undefined, diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 1c7603fa6cb..7185b743e89 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -95,11 +95,17 @@ export function attachMainWindowCoreServices( state.desktopRelayService?.fenceAndCloseNow() await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) }, - onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), + onOrcaProfileAuthMutation: () => { + // Why: a provider whose launch-time install failed is installed by the next auth change. + void state.desktopRelayInstaller?.authChanged() + state.desktopRelayService?.authMutated() + }, // Sign-out is the one fence a paired phone can be told about; quit and // relaunch above stay reasonless so a restart never reads as signed out. - onBeforeOrcaProfileSignOut: () => + onBeforeOrcaProfileSignOut: () => { + state.desktopRelayInstaller?.suspend() state.desktopRelayService?.fenceAndCloseNow(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + } }, state.pluginService ?? undefined, state.pluginMarketplaceService && state.pluginMarketplaceInstaller diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index f6d5662adb6..a81fb90a78f 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -220,7 +220,7 @@ describe('HeroFlow height', () => { await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible()) }) - it('does not offer a futile retry when Relay is unavailable on the desktop', () => { + it('offers Retry Relay when the desktop provider is missing, since a retry installs it', () => { renderFlow(1, { relayMintFailure: { code: 'relay_provider_unavailable', @@ -231,7 +231,7 @@ describe('HeroFlow height', () => { expect(screen.getByRole('alert')).toHaveTextContent( 'Orca Relay isn’t available on this desktop' ) - expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeEnabled() expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() }) 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 ed0f38cbf88..2aba5750093 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 @@ -121,7 +121,7 @@ export function MobileRelayMintFailureNotice({ - {!providerMissing && !reconnectRequired && !notEntitled ? ( + {!reconnectRequired && !notEntitled ? (