diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 40e2c2f70..be6f07ff2 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -151,7 +151,6 @@ describe('registerMobileHandlers', () => { pairingUrl: 'orca://pair#mobile', endpoint: 'ws://100.102.47.57:6768', deviceId: 'mobile-1', - // The encoded mode passes through so the UI can flag a degraded mint. connectionMode: 'automatic' }) @@ -163,6 +162,32 @@ describe('registerMobileHandlers', () => { }) }) + it('forwards structured Relay mint failures to the renderer', async () => { + networkInterfacesMock.mockReturnValue({ + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + const relayFailure = { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: false, + reason: 'relay_mint_failed', + guidance: 'Use LAN or retry Relay.', + relayFailure + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toEqual({ + available: false, + reason: 'relay_mint_failed', + guidance: 'Use LAN or retry Relay.', + relayFailure + }) + }) + it('forwards an explicit local-only pairing choice', async () => { networkInterfacesMock.mockReturnValue({ en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 21c09e832..1e9e4d99a 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -126,7 +126,12 @@ export function registerMobileHandlers( // ZeroTier) where the default LAN IP isn't reachable from the phone. const ip = args?.address ?? getDefaultPairingAddress() if (!ip) { - return { available: false as const } + return { + available: false as const, + reason: 'invalid_advertised_endpoint', + guidance: + 'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.' + } } // Why: coalesce repeated QR regenerations onto a single never-scanned @@ -143,7 +148,14 @@ export function registerMobileHandlers( name: `Mobile ${new Date().toLocaleDateString()}` }) if (!offer.available) { - return { available: false as const } + // Why: surface Relay mint failures (and other pairing unavailability) + // so the UI can refuse a silent LAN QR under the Relay label. + return { + available: false as const, + reason: offer.reason, + guidance: offer.guidance, + ...(offer.relayFailure ? { relayFailure: offer.relayFailure } : {}) + } } const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl) @@ -155,9 +167,6 @@ export function registerMobileHandlers( pairingUrl: offer.pairingUrl, endpoint: offer.endpoint, deviceId: offer.deviceId, - // Why: an automatic request can degrade to a local-only offer when - // Relay provisioning fails; the UI needs the encoded mode to avoid - // labeling a LAN-only code as Relay. connectionMode: offer.connectionMode } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index d246b1f73..959b52c94 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -102,13 +102,15 @@ export class DeviceRegistry { } removeDevice(deviceId: string): boolean { - const before = this.devices.length - this.devices = this.devices.filter((d) => d.deviceId !== deviceId) - if (this.devices.length < before) { - this.save() - return true + const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId) + if (nextDevices.length === this.devices.length) { + return false } - return false + // Why: persist before memory swap so a failed write does not drop a device + // only in-process while disk still lists it (and vice versa on reload). + this.save(nextDevices) + this.devices = nextDevices + return true } getDevice(deviceId: string): DeviceEntry | null { @@ -120,22 +122,30 @@ export class DeviceRegistry { } setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { - const device = this.devices.find((candidate) => candidate.deviceId === deviceId) - if (!device || binding.relayDeviceId !== deviceId) { + const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) + if (index < 0 || binding.relayDeviceId !== deviceId) { return false } - device.relayBinding = binding - this.save() + const nextDevices = this.devices.map((device, candidateIndex) => + candidateIndex === index ? { ...device, relayBinding: binding } : device + ) + this.save(nextDevices) + this.devices = nextDevices return true } setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { - const device = this.devices.find((candidate) => candidate.deviceId === deviceId) - if (!device || device.scope !== 'mobile') { + const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) + if (index < 0 || this.devices[index]?.scope !== 'mobile') { return false } - device.mobilePairingConnectionMode = mode - this.save() + // Why: persist before swapping memory so a failed write does not leave a + // mode the UI/runtime believe was stored. + const nextDevices = this.devices.map((device, candidateIndex) => + candidateIndex === index ? { ...device, mobilePairingConnectionMode: mode } : device + ) + this.save(nextDevices) + this.devices = nextDevices return true } @@ -158,11 +168,18 @@ export class DeviceRegistry { } updateLastSeen(deviceId: string): void { - const device = this.devices.find((d) => d.deviceId === deviceId) - if (device) { - device.lastSeenAt = Date.now() - this.save() + const index = this.devices.findIndex((d) => d.deviceId === deviceId) + if (index < 0) { + return } + // Why: persist before memory swap so a failed write cannot leave a scanned + // device looking never-scanned on disk, where rotation would drop it. + const seenAt = Date.now() + const nextDevices = this.devices.map((device, candidateIndex) => + candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device + ) + this.save(nextDevices) + this.devices = nextDevices } private load(): void { @@ -187,7 +204,7 @@ export class DeviceRegistry { } } - private save(devices: DeviceEntry[] = this.devices): void { + private save(devices: DeviceEntry[]): void { writeSecureJsonFile(this.registryPath, devices) } } diff --git a/src/main/runtime/relay/relay-revoke-outbox.test.ts b/src/main/runtime/relay/relay-revoke-outbox.test.ts index 54ab04730..2ee9e5158 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.test.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.test.ts @@ -1,12 +1,29 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as SecureFileModule from '../../../shared/secure-file' import { RelayRevokeOutbox } from './relay-revoke-outbox' +const secureFileMocks = vi.hoisted(() => ({ failWrites: false })) + +vi.mock('../../../shared/secure-file', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeSecureJsonFile: (targetPath: string, value: unknown) => { + if (secureFileMocks.failWrites) { + throw new Error('disk full') + } + actual.writeSecureJsonFile(targetPath, value) + } + } +}) + describe('RelayRevokeOutbox', () => { const paths: string[] = [] afterEach(() => { + secureFileMocks.failWrites = false for (const path of paths.splice(0)) { rmSync(path, { recursive: true, force: true }) } @@ -29,4 +46,40 @@ describe('RelayRevokeOutbox', () => { new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) ).toEqual([]) }) + + it('does not retain an enqueue that failed to reach disk', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + const binding = { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + ownerIdentityKey: 'user-1\0profile-1\0org-1' + } + const outbox = new RelayRevokeOutbox(path) + secureFileMocks.failWrites = true + expect(() => outbox.enqueue(binding)).toThrow('disk full') + + secureFileMocks.failWrites = false + const persisted = outbox.enqueue(binding) + expect( + new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) + ).toEqual([persisted]) + }) + + it('does not remove an item in memory when the durable removal fails', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + const binding = { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + ownerIdentityKey: 'user-1\0profile-1\0org-1' + } + const outbox = new RelayRevokeOutbox(path) + const item = outbox.enqueue(binding) + secureFileMocks.failWrites = true + expect(() => outbox.remove(item.reqId)).toThrow('disk full') + + secureFileMocks.failWrites = false + expect(outbox.pendingFor(binding.ownerIdentityKey, binding.relayHostId)).toEqual([item]) + }) }) diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index 8a7e8cc19..473f619e1 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -54,8 +54,9 @@ export class RelayRevokeOutbox { return existing } const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() } - this.items.push(item) - this.save() + const next = [...this.items, item] + this.save(next) + this.items = next return item } @@ -70,8 +71,8 @@ export class RelayRevokeOutbox { if (next.length === this.items.length) { return } + this.save(next) this.items = next - this.save() } private load(): RelayRevokeOutboxItem[] { @@ -87,7 +88,7 @@ export class RelayRevokeOutbox { } } - private save(): void { - writeSecureJsonFile(this.path, this.items) + private save(items: readonly RelayRevokeOutboxItem[]): void { + writeSecureJsonFile(this.path, items) } } diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 308c8ae20..433967935 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -746,7 +746,74 @@ describe('OrcaRuntimeRpcServer', () => { } }) - it('falls back to a valid direct-only GUI offer when relay invite minting fails', async () => { + it('queues the old Relay binding when a stable provider changes accounts', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let relayHostId = 'AbCdEf0123_-xyZ9' + let ownerIdentityKey = 'user-a\0profile-a\0org' + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId, + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId, + relayDeviceId, + ownerIdentityKey + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(first.available).toBe(true) + if (!first.available) { + throw new Error('WebSocket pairing unavailable') + } + const firstBinding = server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding + expect(firstBinding).toBeTruthy() + if (!firstBinding) { + throw new Error('Relay binding unavailable') + } + relayHostId = 'ZyXwVu9876_-abcD' + ownerIdentityKey = 'user-b\0profile-b\0org' + + const second = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(second.available).toBe(true) + if (!second.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(second.deviceId).toBe(first.deviceId) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(onDeviceRevokeQueued).toHaveBeenCalledWith(expect.objectContaining(firstBinding)) + expect(server.getDeviceRegistry()?.getDevice(second.deviceId)?.relayBinding).toEqual({ + relayHostId, + relayDeviceId: second.deviceId, + ownerIdentityKey + }) + } finally { + await server.stop() + } + }) + + it('refuses a silent LAN QR when relay invite minting fails under Anywhere', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), @@ -764,18 +831,576 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() try { const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) - expect(offer.available).toBe(true) - if (!offer.available) { + // Why: Anywhere must not ship a scannable local-only code under the Relay label. + expect(offer.available).toBe(false) + if (offer.available) { + throw new Error('expected relay mint failure') + } + expect(offer.reason).toBe('relay_mint_failed') + expect(offer.relayFailure).toMatchObject({ + code: 'relay_mint_failed', + stage: 'create_pairing_relay' + }) + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('reports a missing Relay provider without creating a fallback QR', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer).toMatchObject({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + } + }) + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('preserves an existing Relay QR when a same-mode remint fails', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const createPairingRelay = vi + .fn() + .mockImplementationOnce(async (relayDeviceId: string) => ({ + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + })) + .mockRejectedValueOnce(new Error('relay offline')) + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(first.available).toBe(true) + if (!first.available) { throw new Error('WebSocket pairing unavailable') } - expect(parsePairingCode(offer.pairingUrl)).toMatchObject({ - endpoint: offer.endpoint, - scope: 'mobile' + const second = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(second.available).toBe(false) + expect(server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding).toBeTruthy() + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(first.deviceId) + } finally { + await server.stop() + } + }) + + it('coalesces concurrent mobile Relay mints for the shared pending credential', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveFirst: (() => void) | undefined + const firstMint = new Promise((resolve) => { + resolveFirst = resolve + }) + const createPairingRelay = vi.fn(async (relayDeviceId: string) => { + await firstMint + return { + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }) + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true }) + const second = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true }) + await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledTimes(1)) + resolveFirst?.() + const [firstOffer, secondOffer] = await Promise.all([first, second]) + expect(firstOffer.available).toBe(true) + expect(secondOffer.available).toBe(true) + if (!firstOffer.available || !secondOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(secondOffer.deviceId).toBe(firstOffer.deviceId) + expect(secondOffer.pairingUrl).toBe(firstOffer.pairingUrl) + expect(createPairingRelay).toHaveBeenCalledTimes(1) + } finally { + await server.stop() + } + }) + + it('supersedes an older concurrent Relay rotation for a different address', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveFirst: (() => void) | undefined + const firstMint = new Promise((resolve) => { + resolveFirst = resolve + }) + const createPairingRelay = vi.fn(async (relayDeviceId: string) => { + if (createPairingRelay.mock.calls.length === 1) { + await firstMint + } + return { + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = server.createMobilePairingOffer({ + address: '100.64.1.20', + rotate: true }) - expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') - // Why: the result reports what the offer actually encodes so the UI can - // flag the degraded mint instead of labeling it as Relay. - expect(offer.connectionMode).toBe('local-only') + await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce()) + const second = server.createMobilePairingOffer({ + address: '100.64.1.21', + rotate: true + }) + resolveFirst?.() + await expect(first).resolves.toMatchObject({ + available: false, + relayFailure: { code: 'relay_request_superseded' } + }) + const secondOffer = await second + expect(secondOffer.available).toBe(true) + if (!secondOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(secondOffer.endpoint).toContain('100.64.1.21') + expect(createPairingRelay).toHaveBeenCalledTimes(2) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + } finally { + await server.stop() + } + }) + + it('supersedes an older concurrent Relay mint for a different address without rotate', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveFirst: (() => void) | undefined + const firstMint = new Promise((resolve) => { + resolveFirst = resolve + }) + const createPairingRelay = vi.fn(async (relayDeviceId: string) => { + if (createPairingRelay.mock.calls.length === 1) { + await firstMint + } + return { + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce()) + const second = server.createMobilePairingOffer({ address: '100.64.1.21' }) + resolveFirst?.() + await expect(first).resolves.toMatchObject({ + available: false, + relayFailure: { code: 'relay_request_superseded' } + }) + const secondOffer = await second + expect(secondOffer.available).toBe(true) + if (!secondOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(secondOffer.endpoint).toContain('100.64.1.21') + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + } finally { + await server.stop() + } + }) + + it('lets LAN supersede a pending Relay mint without waiting for it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveRelay: (() => void) | undefined + const relayGate = new Promise((resolve) => { + resolveRelay = resolve + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => { + await relayGate + return { + relay: { + v: 1, + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }, + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const relayOffer = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull() + ) + const localOffer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(localOffer.available).toBe(true) + if (!localOffer.available) { + throw new Error('LAN pairing unavailable') + } + expect(localOffer.connectionMode).toBe('local-only') + resolveRelay?.() + const staleRelayOffer = await relayOffer + expect(staleRelayOffer.available).toBe(false) + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe( + localOffer.deviceId + ) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + } finally { + await server.stop() + } + }) + + it('revokes a Relay invite when binding persistence throws', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const registry = server.getDeviceRegistry() + if (!registry) { + throw new Error('Device registry unavailable') + } + vi.spyOn(registry, 'setRelayBinding').mockImplementation(() => { + throw new Error('disk full') + }) + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer.available).toBe(false) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(registry.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('revokes a Relay result from a provider replaced during minting', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveRelay: (() => void) | undefined + const relayGate = new Promise((resolve) => { + resolveRelay = resolve + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => { + await relayGate + return { + relay: { + v: 1, + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull() + ) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn(), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + resolveRelay?.() + await expect(offerPromise).resolves.toMatchObject({ available: false }) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('retains a minted Relay binding on the device when cleanup cannot be queued', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveRelay: (() => void) | undefined + const relayGate = new Promise((resolve) => { + resolveRelay = resolve + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => { + await relayGate + return { + relay: { + v: 1, + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const registry = server.getDeviceRegistry() + if (!registry) { + throw new Error('Device registry unavailable') + } + const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => expect(registry.getPendingDevice('mobile')).not.toBeNull()) + const deviceId = registry.getPendingDevice('mobile')?.deviceId + vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => { + throw new Error('disk full') + }) + // Why: swapping the provider supersedes the in-flight mint. + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn(), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + resolveRelay?.() + await expect(offerPromise).resolves.toMatchObject({ available: false }) + expect(registry.getDevice(deviceId ?? '')?.relayBinding).toMatchObject({ + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: deviceId + }) + } finally { + await server.stop() + } + }) + + it('queues cloud cleanup when a minted Relay binding cannot be persisted', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async () => ({ + relay: { + v: 1, + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'wrong-device', + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer.available).toBe(false) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() } finally { await server.stop() } @@ -833,6 +1458,28 @@ describe('OrcaRuntimeRpcServer', () => { enableWebSocket: true, wsPort: 0 }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + 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 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) await server.start() try { @@ -843,6 +1490,7 @@ describe('OrcaRuntimeRpcServer', () => { if (!offer.available) { throw new Error('WebSocket pairing unavailable') } + expect(offer.connectionMode).toBe('automatic') expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( 'automatic' ) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 54734e693..48903b5a1 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -29,6 +29,10 @@ import { } from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { + mobileRelayMintFailureFromUnknown, + type MobileRelayMintFailure +} from '../../shared/mobile-relay-mint-failure' import { RelayRevokeOutbox, type RelayDeviceBinding, @@ -71,13 +75,28 @@ export type PairingOfferUnavailableReason = | 'device_registry_unavailable' | 'e2ee_key_unavailable' | 'invalid_advertised_endpoint' + | 'relay_mint_failed' export type PairingOfferUnavailable = { available: false reason: PairingOfferUnavailableReason guidance: string + /** Present when an Anywhere mint refused to silently fall back to LAN-only. */ + relayFailure?: MobileRelayMintFailure } +type MobilePairingOfferAvailable = { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + /** Mode the offer actually encodes. */ + connectionMode: MobilePairingConnectionMode +} + +type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable + type PairingIdentityInitialization = | { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair } | { ok: false; failure: PairingOfferUnavailable } @@ -469,6 +488,14 @@ export class OrcaRuntimeRpcServer { private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null private mobileSocketWiring: MobileSocketWiring | null = null private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + private mobileRelayPairingOfferQueue: Promise = Promise.resolve() + private mobileRelayPairingOfferInFlight: { + generation: number + address: string | null + rotate: boolean + request: Promise + } | null = null + private mobilePairingOfferGeneration = 0 private onUnpairedDeviceAuthFailure: (() => void) | null = null private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null private readonly binaryStreamHandlers = new Map< @@ -552,7 +579,9 @@ export class OrcaRuntimeRpcServer { current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) ) { // Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline. - this.queueRelayDeviceRevoke(current.relayBinding) + if (!this.queueRelayDeviceRevoke(current.relayBinding)) { + return false + } } const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false if (updated) { @@ -576,7 +605,9 @@ export class OrcaRuntimeRpcServer { return false } if (device.relayBinding) { - this.queueRelayDeviceRevoke(device.relayBinding) + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return false + } } if (!this.deviceRegistry?.removeDevice(deviceId)) { return false @@ -672,18 +703,57 @@ export class OrcaRuntimeRpcServer { connectionMode?: MobilePairingConnectionMode name?: string rotate?: boolean - }): Promise< - | PairingOfferUnavailable - | { - available: true - pairingUrl: string - endpoint: string - deviceId: string - webClientUrl: string | null - /** Mode the offer actually encodes — 'local-only' when an automatic request degraded (Relay couldn't attach). */ - connectionMode: MobilePairingConnectionMode + }): Promise { + if (args.connectionMode === 'local-only') { + this.mobilePairingOfferGeneration += 1 + return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration) + } + const address = args.address ?? null + const rotate = args.rotate === true + const inFlight = this.mobileRelayPairingOfferInFlight + if ( + inFlight?.generation === this.mobilePairingOfferGeneration && + inFlight.address === address && + (inFlight.rotate || !rotate) + ) { + return inFlight.request + } + // Why: every request that is not coalesced above supersedes the older one, rotating or not. + const generation = ++this.mobilePairingOfferGeneration + const request = this.mobileRelayPairingOfferQueue.then(() => + generation === this.mobilePairingOfferGeneration + ? this.createMobilePairingOfferSerial(args, generation) + : this.relayPairingRequestSuperseded() + ) + this.mobileRelayPairingOfferQueue = request.then( + () => undefined, + () => undefined + ) + this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request } + void request.then( + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } + }, + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } } - > { + ) + return request + } + + private async createMobilePairingOfferSerial( + args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }, + generation: number + ): Promise { // Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning. const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' const pending = this.deviceRegistry?.getPendingDevice('mobile') @@ -694,7 +764,12 @@ export class OrcaRuntimeRpcServer { if (args.rotate || switchingPendingMode) { if (pending?.relayBinding) { // Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR. - this.queueRelayDeviceRevoke(pending.relayBinding) + if (!this.queueRelayDeviceRevoke(pending.relayBinding)) { + return pairingUnavailable( + 'device_registry_unavailable', + 'Could not persist Relay cleanup before rotating the pairing code.' + ) + } } } const direct = this.createPairingOffer({ @@ -705,42 +780,178 @@ export class OrcaRuntimeRpcServer { if (!direct.available) { return direct } - this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) - if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) { + const createdNewPendingDevice = pending?.deviceId !== direct.deviceId + let connectionModeStored = false + try { + connectionModeStored = + this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ?? + false + } catch (error) { + console.error('[runtime] Failed to persist the pairing connection mode:', error) + } + // Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one. + if (!connectionModeStored) { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + // Why: explicit LAN path never needs Relay; mint the direct-only offer as selected. + if (connectionMode === 'local-only') { return { ...direct, connectionMode: 'local-only' } } + // Why: Anywhere must not silently ship a LAN-only QR under the Relay label. + // Fail closed, drop the unused pending credential, and let the UI offer Use LAN. + const refuseAutomaticWithoutRelay = ( + relayFailure: MobileRelayMintFailure + ): PairingOfferUnavailable => { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return { + available: false, + reason: 'relay_mint_failed', + guidance: + 'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.', + relayFailure + } + } + const relayProvider = this.mobileRelayPairingProvider + if (!relayProvider) { + return refuseAutomaticWithoutRelay({ + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + }) + } const device = this.deviceRegistry?.getDevice(direct.deviceId) const publicKeyB64 = this.getE2EEPublicKey() if (!device || !publicKeyB64) { - return { ...direct, connectionMode: 'local-only' } + return refuseAutomaticWithoutRelay({ + code: 'e2ee_key_unavailable', + stage: 'e2ee_missing', + message: 'E2EE public key unavailable for Relay pairing' + }) + } + let relayPairing: Awaited> + try { + relayPairing = await relayProvider.createPairingRelay(device.deviceId) + } catch (error) { + // Why: the raw provider error can carry request metadata or credentials — log only the validated code. + const relayFailure = mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`) + return refuseAutomaticWithoutRelay(relayFailure) + } + const currentDevice = this.deviceRegistry?.getDevice(device.deviceId) + if ( + generation !== this.mobilePairingOfferGeneration || + relayProvider !== this.mobileRelayPairingProvider || + currentDevice?.token !== device.token || + this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic' + ) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return this.relayPairingRequestSuperseded() } try { - const relayPairing = await this.mobileRelayPairingProvider.createPairingRelay(device.deviceId) - if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) { - return { ...direct, connectionMode: 'local-only' } - } - this.mobileRelayPairingProvider.onDemandStateChanged?.() - return { - ...direct, - connectionMode: 'automatic', - pairingUrl: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: direct.endpoint, - deviceToken: device.token, - publicKeyB64, - scope: 'mobile', - relay: relayPairing.relay + if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' }) } - } catch { - // Why: relay is additive — a transient outage must still yield the valid LAN/Tailscale pairing offer. - return { ...direct, connectionMode: 'local-only' } + } catch (error) { + console.warn('[runtime] Failed to persist Relay pairing binding:', error) + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' + }) + } + return { + ...direct, + connectionMode: 'automatic', + pairingUrl: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: direct.endpoint, + deviceToken: device.token, + publicKeyB64, + scope: 'mobile', + relay: relayPairing.relay + }) } } - private queueRelayDeviceRevoke(binding: RelayDeviceBinding): void { - const item = this.relayRevokeOutbox.enqueue(binding) - this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + private relayPairingRequestSuperseded(): PairingOfferUnavailable { + return { + available: false, + reason: 'relay_mint_failed', + guidance: 'The Relay pairing request was replaced by a newer connection choice.', + relayFailure: { + code: 'relay_request_superseded', + stage: 'binding_failed', + message: 'Relay pairing request superseded' + } + } + } + + /** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */ + private discardPendingMobilePairingDevice(deviceId: string): void { + const device = this.deviceRegistry?.getDevice(deviceId) + if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) { + return + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return + } + } + try { + this.deviceRegistry?.removeDevice(deviceId) + } catch (error) { + console.error('[runtime] Failed to drop an unused mobile pairing credential:', error) + } + } + + /** + * Why: the outbox is the only durable cleanup record for a minted invite. When it can't be + * written, keep the binding on the device so cleanup keeps a reference instead of orphaning it. + */ + private queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { + if (this.queueRelayDeviceRevoke(binding)) { + return + } + try { + this.deviceRegistry?.setRelayBinding(deviceId, binding) + } catch (error) { + console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) + } + } + + private queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + console.error('[runtime] Failed to persist Relay device cleanup:', error) + return false + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch (error) { + console.warn('[runtime] Failed to notify Relay cleanup worker:', error) + } + return true } private registerBinaryStreamHandler( diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 900c8f830..3f5171108 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -47,6 +47,7 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, @@ -3573,7 +3574,12 @@ export type PreloadApi = { connectionMode?: MobilePairingConnectionMode rotate?: boolean }) => Promise< - | { available: false } + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } | { available: true qrDataUrl: string | null @@ -3581,7 +3587,7 @@ export type PreloadApi = { pairingUrl: string endpoint: string deviceId: string - /** Mode the QR actually encodes; 'local-only' when Relay could not be attached. */ + /** Mode the QR actually encodes. */ connectionMode: MobilePairingConnectionMode } > diff --git a/src/preload/index.ts b/src/preload/index.ts index ce1a52d11..3af7a2f01 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,7 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, @@ -4576,7 +4577,12 @@ const api = { connectionMode?: MobilePairingConnectionMode rotate?: boolean }): Promise< - | { available: false } + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } | { available: true qrDataUrl: string | null diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 6c0d16ee5..2303b6a9d 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -52,10 +52,12 @@ --m-mono: 'Menlo', 'SF Mono', monospace; position: relative; + container-type: inline-size; display: block; width: 100%; height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; color: var(--foreground); } @@ -72,8 +74,7 @@ gap: clamp(56px, 7vw, 112px); padding: 56px; width: 100%; - height: 100%; - overflow: hidden; + min-height: 100%; background-color: transparent; /* Why: the page should feel branded without putting copy over decorative blobs; a token-based dot texture stays quiet in both themes. */ @@ -612,6 +613,20 @@ font-weight: 600; } +/* Why: empty QR frames need readable copy without looking like a stuck loader overlay. */ +.mobile-page-root .mp-qr-empty { + position: absolute; + inset: 10px; + display: grid; + place-items: center; + border-radius: 4px; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 500; + line-height: 1.35; + text-wrap: balance; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -681,6 +696,14 @@ align-items: start; } +.mobile-page-root .mp-pairing-layout.has-failure { + grid-template-areas: + 'copy qr' + 'relay qr' + 'failure failure' + 'controls controls'; +} + .mobile-page-root .mp-pairing-copy { grid-area: copy; min-width: 0; @@ -703,6 +726,10 @@ min-width: 0; } +.mobile-page-root .mp-pairing-failure { + grid-area: failure; +} + /* Why: align the QR's top with the body copy ("Scan the QR…") instead of the eyebrow row above it. The eyebrow row is ~22px tall + 22px margin-bottom, and the h2 is ~38px line-height with no top margin — @@ -2102,3 +2129,33 @@ font-size: 17px; } } + +/* Why: the app sidebar narrows this page without changing viewport media + queries; keep this after viewport rules so real page width wins. */ +@container (max-width: 1080px) { + .mobile-page-root .mp-hero { + --mp-flow-card-padding: 20px; + + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + align-items: start; + gap: 24px; + padding: 52px 28px 28px; + } + + .mobile-page-root .mp-hero-copy { + min-height: 0; + } + + .mobile-page-root .mp-h1 { + font-size: 44px; + } + + .mobile-page-root .mp-stage { + height: min(420px, 42vh); + } + + .mobile-page-root .mp-phone-frame { + width: min(290px, 100%); + } +} diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 4d47bcdef..88db885a5 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/i18n/i18n', () => ({ @@ -27,6 +27,7 @@ vi.mock('./WindowsFirewallNotice', () => ({ })) import { HeroFlow, type StepIndex } from './MobileHero' +import { MobileHeroPairingStep } from './MobileHeroPairingStep' class MockResizeObserver { observe = vi.fn() @@ -75,7 +76,10 @@ describe('HeroFlow height', () => { pairQrDataUrl={null} pairingUrl={null} pairingQrError={false} - relayDegraded={false} + relayMintFailure={null} + onUseLan={vi.fn()} + onRetryRelay={vi.fn()} + onCopyRelayDiagnostics={vi.fn()} pairLoading={false} connectionMode="automatic" onConnectionModeChange={vi.fn()} @@ -115,7 +119,10 @@ describe('HeroFlow height', () => { pairQrDataUrl={null} pairingUrl={null} pairingQrError={false} - relayDegraded={false} + relayMintFailure={null} + onUseLan={vi.fn()} + onRetryRelay={vi.fn()} + onCopyRelayDiagnostics={vi.fn()} pairLoading={false} connectionMode="automatic" onConnectionModeChange={vi.fn()} @@ -137,23 +144,69 @@ describe('HeroFlow height', () => { expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert') }) - it('flags a degraded Anywhere code and always shows the Relay beta note', () => { + it('shows Relay mint failure with no QR and the beta note', () => { renderFlow(1, { - pairQrDataUrl: 'data:image/png;base64,qr', - relayDegraded: true + pairQrDataUrl: null, + relayMintFailure: { + code: 'relay offline', + stage: 'create_pairing_relay', + message: 'relay offline' + } }) - const notice = screen.getByTestId('relay-degraded-notice') - expect(notice).toHaveTextContent('only works on your LAN or Tailscale') - // Why: wrap-capable text item inside the fixed QR track (#9700); bare text - // nodes in a flex row cannot shrink below max-content and overflow the track. - expect(notice.querySelector('.min-w-0')).not.toBeNull() - expect(notice.className).toMatch(/\bmin-w-0\b/) + const notice = screen.getByTestId('relay-mint-failure-notice') + expect(notice).toHaveTextContent('Couldn’t create a Relay pairing code') + expect(notice).toHaveTextContent('Use LAN') + expect(screen.getByText('No pairing code available')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Generate code' })).not.toBeInTheDocument() expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument() }) - it('hides the degradation notice when the code encodes what was selected', () => { + it('explains an empty QR frame when no code has been generated yet', () => { + renderFlow(1, { pairQrDataUrl: null, canGeneratePairing: true }) + expect(screen.getByText('Generate a pairing code to continue')).toBeInTheDocument() + }) + + it('explains an empty QR frame when Relay sign-in is required', () => { + renderFlow(1, { + pairQrDataUrl: null, + canGeneratePairing: false, + connectionMode: 'automatic' + }) + expect(screen.getByText('Sign in to create a Relay pairing code')).toBeInTheDocument() + }) + + it('disables Relay recovery immediately and delays visible retry feedback', async () => { + renderFlow(1, { + pairLoading: true, + relayMintFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() + 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', () => { + renderFlow(1, { + relayMintFailure: { + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + } + }) + 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: 'Use LAN' })).toBeEnabled() + }) + + it('hides the mint-failure notice when a Relay QR is shown', () => { renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' }) - expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() }) it('shows an encoder error while keeping the copy fallback enabled', () => { @@ -165,4 +218,83 @@ describe('HeroFlow height', () => { expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code') expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled() }) + + it('moves focus to the pairing-code action after recovery succeeds', () => { + const props: React.ComponentProps = { + pairQrDataUrl: null, + pairingUrl: null, + pairingQrError: false, + relayMintFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }, + onUseLan: vi.fn(), + onRetryRelay: vi.fn(), + onCopyRelayDiagnostics: vi.fn(), + pairLoading: false, + connectionMode: 'automatic', + onConnectionModeChange: vi.fn(), + onRegeneratePairing: vi.fn(), + canGeneratePairing: true, + onCopyPairingCode: vi.fn(), + networkInterfaces: [], + selectedAddress: undefined, + onSelectedAddressChange: vi.fn(), + beforeCustomAddressChange: vi.fn().mockResolvedValue(true), + onRefreshNetworkInterfaces: vi.fn(), + refreshingNetworkInterfaces: false + } + const { rerender } = render() + screen.getByRole('button', { name: 'Retry Relay' }).focus() + + rerender( + + ) + + expect(screen.getByRole('button', { name: 'Copy pairing code' })).toHaveFocus() + }) + + it('keeps focus on a persistent control when an ordinary remint finishes', () => { + const props: React.ComponentProps = { + pairQrDataUrl: null, + pairingUrl: null, + pairingQrError: false, + relayMintFailure: null, + onUseLan: vi.fn(), + onRetryRelay: vi.fn(), + onCopyRelayDiagnostics: vi.fn(), + pairLoading: true, + connectionMode: 'local-only', + onConnectionModeChange: vi.fn(), + onRegeneratePairing: vi.fn(), + canGeneratePairing: true, + onCopyPairingCode: vi.fn(), + networkInterfaces: [], + selectedAddress: undefined, + onSelectedAddressChange: vi.fn(), + beforeCustomAddressChange: vi.fn().mockResolvedValue(true), + onRefreshNetworkInterfaces: vi.fn(), + refreshingNetworkInterfaces: false + } + const { rerender } = render() + const refresh = screen.getByRole('button', { name: 'Refresh network interfaces' }) + refresh.focus() + + rerender( + + ) + + expect(refresh).toHaveFocus() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 291a6ffcb..078ee1571 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,14 +1,12 @@ import { useLayoutEffect, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, CircleAlert, Copy, RefreshCw } from 'lucide-react' +import { ArrowLeft, ArrowRight, Copy } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' -import { NetworkInterfacePicker } from './NetworkInterfacePicker' -import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' -import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' -import { WindowsFirewallNotice } from './WindowsFirewallNotice' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { MobileHeroPairingStep } from './MobileHeroPairingStep' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' @@ -16,18 +14,6 @@ import { translate } from '@/i18n/i18n' export type Platform = 'ios' | 'android' export type StepIndex = 0 | 1 -// Why: header copy needs to refer to the *user's* device by its native name. -function getDeviceLabel(): string { - const ua = navigator.userAgent - if (ua.includes('Mac')) { - return 'Mac' - } - if (ua.includes('Windows')) { - return 'PC' - } - return 'computer' -} - type HeroFlowProps = { stepIdx: StepIndex platform: Platform @@ -41,8 +27,10 @@ type HeroFlowProps = { pairQrDataUrl: string | null pairingUrl: string | null pairingQrError: boolean - /** True when the shown QR degraded to local-only under an Anywhere selection. */ - relayDegraded: boolean + relayMintFailure: MobileRelayMintFailure | null + onUseLan: () => void + onRetryRelay: () => void + onCopyRelayDiagnostics: () => void pairLoading: boolean connectionMode: MobilePairingConnectionMode onConnectionModeChange: (mode: MobilePairingConnectionMode) => void @@ -73,7 +61,10 @@ export function HeroFlow({ pairQrDataUrl, pairingUrl, pairingQrError, - relayDegraded, + relayMintFailure, + onUseLan, + onRetryRelay, + onCopyRelayDiagnostics, pairLoading, connectionMode, onConnectionModeChange, @@ -202,13 +193,7 @@ export function HeroFlow({ -
+
{installQrUrl ? ( -
-
-
-
2
- - {translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')} - -
-

- {translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '} - {getDeviceLabel()}. -

-

- {translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '} - - {translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')} - - {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')} -

-
-
- - -
-
-
- {pairQrDataUrl ? ( - {translate('auto.components.mobile.MobileHero.27735e5f4e', - ) : null} - {pairLoading ? ( - - {translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')} - - ) : null} -
- - {relayDegraded ? ( -

- - {/* Why: min-w-0 so the flex text item can wrap inside the fixed QR track (#9700). */} - - {translate( - 'auto.components.mobile.MobileHero.relayDegradedNotice', - 'Relay couldn’t be reached — this code only works on your LAN or Tailscale.' - )} - -

- ) : null} - {pairingQrError ? ( -

- - - {translate( - 'auto.components.mobile.MobileHero.pairingQrError', - 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.' - )} - -

- ) : null} -
-
-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
- -
- - {translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")} - - -
- -
-
+
diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx new file mode 100644 index 000000000..80bc79399 --- /dev/null +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -0,0 +1,268 @@ +import { useEffect, useRef } from 'react' +import { CircleAlert, Copy, RefreshCw } from 'lucide-react' +import { cn } from '../../lib/utils' +import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' +import { NetworkInterfacePicker } from './NetworkInterfacePicker' +import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' +import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice' +import { MobileRelayMintFailureNotice } from './mobile-relay-mint-failure-notice' +import { WindowsFirewallNotice } from './WindowsFirewallNotice' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { translate } from '@/i18n/i18n' + +/** Why: one full sentence per device kind so translators own word order and punctuation. */ +function pairDeviceHeading(): string { + const ua = navigator.userAgent + if (ua.includes('Mac')) { + return translate('auto.components.mobile.MobileHero.pairThisMac', 'Pair this Mac.') + } + if (ua.includes('Windows')) { + return translate('auto.components.mobile.MobileHero.pairThisPc', 'Pair this PC.') + } + return translate('auto.components.mobile.MobileHero.pairThisComputer', 'Pair this computer.') +} + +/** Short copy for the QR frame when no image can be shown. */ +function emptyPairingQrMessage(args: { + relayMintFailure: MobileRelayMintFailure | null + canGeneratePairing: boolean + connectionMode: MobilePairingConnectionMode + pairingQrError: boolean + pairingUrl: string | null +}): string { + if (args.relayMintFailure != null) { + return translate('auto.components.mobile.MobileHero.noRelayCode', 'No pairing code available') + } + if (!args.canGeneratePairing && args.connectionMode === 'automatic') { + return translate( + 'auto.components.mobile.MobileHero.qrSignInRequired', + 'Sign in to create a Relay pairing code' + ) + } + if (args.pairingQrError && args.pairingUrl != null) { + return translate( + 'auto.components.mobile.MobileHero.qrRenderFailed', + 'QR couldn’t be rendered — copy the code below' + ) + } + if (!args.canGeneratePairing) { + return translate('auto.components.mobile.MobileHero.noPairingCode', 'No pairing code available') + } + return translate( + 'auto.components.mobile.MobileHero.qrGeneratePrompt', + 'Generate a pairing code to continue' + ) +} + +export function MobileHeroPairingStep({ + pairQrDataUrl, + pairingUrl, + pairingQrError, + relayMintFailure, + onUseLan, + onRetryRelay, + onCopyRelayDiagnostics, + pairLoading, + connectionMode, + onConnectionModeChange, + onRegeneratePairing, + canGeneratePairing, + onCopyPairingCode, + networkInterfaces, + selectedAddress, + onSelectedAddressChange, + beforeCustomAddressChange, + onRefreshNetworkInterfaces, + refreshingNetworkInterfaces +}: { + pairQrDataUrl: string | null + pairingUrl: string | null + pairingQrError: boolean + relayMintFailure: MobileRelayMintFailure | null + onUseLan: () => void + onRetryRelay: () => void + onCopyRelayDiagnostics: () => void + pairLoading: boolean + connectionMode: MobilePairingConnectionMode + onConnectionModeChange: (mode: MobilePairingConnectionMode) => void + onRegeneratePairing: () => void + canGeneratePairing: boolean + onCopyPairingCode: () => void + networkInterfaces: readonly MobileNetworkInterface[] + selectedAddress: string | undefined + onSelectedAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise + onRefreshNetworkInterfaces: () => void + refreshingNetworkInterfaces: boolean +}): React.JSX.Element { + const copyPairingCodeRef = useRef(null) + const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading) + const emptyQrMessage = + !pairLoading && pairQrDataUrl == null + ? emptyPairingQrMessage({ + relayMintFailure, + canGeneratePairing, + connectionMode, + pairingQrError, + pairingUrl + }) + : null + + useEffect(() => { + const pairingReady = pairingUrl != null && !pairLoading + const becameReady = !pairingWasReadyRef.current && pairingReady + pairingWasReadyRef.current = pairingReady + if (becameReady && document.activeElement === document.body) { + copyPairingCodeRef.current?.focus() + } + }, [pairLoading, pairingUrl]) + + return ( +
+
+
+
2
+ + {translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')} + +
+

{pairDeviceHeading()}

+

+ {translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '} + + {translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')} + + {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')} +

+
+
+ + +
+ {relayMintFailure != null ? ( + + ) : null} +
+
+ {pairQrDataUrl ? ( + {translate('auto.components.mobile.MobileHero.27735e5f4e', + ) : null} + {pairLoading ? ( + + {translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')} + + ) : null} + {emptyQrMessage != null ? ( + + {emptyQrMessage} + + ) : null} +
+ + {pairQrDataUrl != null && !pairLoading + ? translate('auto.components.mobile.MobileHero.pairingCodeReady', 'Pairing code ready') + : ''} + + {relayMintFailure == null ? ( + + ) : null} + {pairingQrError ? ( +

+ + + {translate( + 'auto.components.mobile.MobileHero.pairingQrError', + 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.' + )} + +

+ ) : null} +
+
+
+ + {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} + + + +
+ +
+ + {translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")} + + +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index 9c2b76288..a070d813e 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' type StoreState = { closeMobilePage: () => void @@ -48,6 +49,8 @@ vi.mock('./MobilePageContent', () => ({ pairQrDataUrl: string | null pairingUrl: string | null pairingQrError: boolean + relayMintFailure: MobileRelayMintFailure | null + onRetryRelay: () => void stage: string | null stepIdx: number }) => ( @@ -59,6 +62,7 @@ vi.mock('./MobilePageContent', () => ({ {props.pairQrDataUrl ?? 'none'} {props.pairingUrl ?? 'none'} {String(props.pairingQrError)} + {props.relayMintFailure?.stage ?? 'none'} @@ -71,6 +75,9 @@ vi.mock('./MobilePageContent', () => ({ + @@ -310,7 +317,90 @@ describe('MobilePage pairing connection mode', () => { expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback') }) - it('does not commit a custom address when its real QR preflight fails', async () => { + it('surfaces Relay failure and retries with a rotated credential', async () => { + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_control_not_active', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => + expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay') + ) + + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: 'data:image/png;base64,retried', + pairingUrl: 'orca://pair#retried', + endpoint: 'ws://host', + connectionMode: 'automatic' + }) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + connectionMode: 'automatic', + rotate: true + }) + ) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('retried')) + expect(screen.getByTestId('relay-failure')).toHaveTextContent('none') + }) + + it('switches to LAN while a Relay retry is still unresolved', async () => { + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => + expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay') + ) + + let resolveRetry: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve + }) + ) + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://pair#local', + endpoint: 'ws://host', + connectionMode: 'local-only' + }) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + await user.click(screen.getByRole('button', { name: 'LAN' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ connectionMode: 'local-only' }) + ) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local')) + resolveRetry?.({ + available: true, + qrDataUrl: 'data:image/png;base64,stale-relay', + pairingUrl: 'orca://pair#stale-relay', + endpoint: 'ws://relay', + connectionMode: 'automatic' + }) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local')) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('does not commit a custom direct address when its QR preflight fails', async () => { const user = userEvent.setup() await openPairingStep() await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1)) @@ -331,7 +421,6 @@ describe('MobilePage pairing connection mode', () => { connectionMode: 'automatic' }) ) - await new Promise((resolve) => setTimeout(resolve, 20)) expect(getPairingQR).toHaveBeenCalledTimes(2) }) }) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index f058f5198..d45083bc1 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -21,6 +21,7 @@ import { useMobilePairingGeneration } from './use-mobile-pairing-generation' import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalidation' import { useMobileInstallActions } from './use-mobile-install-actions' import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' export default function MobilePage(): React.JSX.Element { const [stepIdx, setStepIdx] = useState(0) @@ -33,10 +34,7 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) const [pairingQrError, setPairingQrError] = useState(false) - // Mode the displayed QR actually encodes; can be 'local-only' under an - // Anywhere selection when Relay provisioning degraded server-side. - const [encodedConnectionMode, setEncodedConnectionMode] = - useState(null) + const [relayMintFailure, setRelayMintFailure] = useState(null) const [pairLoading, setPairLoading] = useState(false) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() @@ -77,7 +75,7 @@ export default function MobilePage(): React.JSX.Element { setPairingUrl, setPairingQrError, setPairLoading, - setEncodedConnectionMode + setRelayMintFailure }) const handleConnectionModeChange = useCallback( @@ -88,12 +86,43 @@ export default function MobilePage(): React.JSX.Element { // Why: persist the pick and update local state. The QR invalidation + // rotate-regenerate is handled centrally by useMobilePairingQrInvalidation // (below), which also covers cross-window preference syncs. + setRelayMintFailure(null) setConnectionMode(nextMode) void updateSettings({ mobilePairingConnectionMode: nextMode }) }, [connectionMode, updateSettings, setConnectionMode] ) + const copyRelayDiagnostics = useCallback(async (): Promise => { + if (relayMintFailure == null) { + return + } + // Why: users share this payload — the selected address would leak a LAN/Tailscale IP or hostname. + const payload = { + kind: 'mobile_pairing_relay_failure', + preferredConnectionMode: connectionMode, + failure: relayMintFailure, + at: new Date().toISOString() + } + try { + await window.api.ui.writeClipboardText(JSON.stringify(payload, null, 2)) + if (mountedRef.current) { + toast.success( + translate('auto.components.mobile.MobilePage.diagnosticsCopied', 'Diagnostics copied') + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.mobile.MobilePage.diagnosticsCopyFailed', + 'Failed to copy diagnostics' + ) + ) + } + } + }, [connectionMode, mountedRef, relayMintFailure]) + useMobilePairingQrInvalidation({ connectionMode, signedIn, @@ -104,6 +133,7 @@ export default function MobilePage(): React.JSX.Element { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode) }) @@ -168,8 +198,6 @@ export default function MobilePage(): React.JSX.Element { // not snap it back to a tailnet/LAN fallback. const isManual = !networkInterfaces.some((iface) => iface.address === address) setAddressIsManual(isManual) - // Switching network must remint so the QR encodes the new endpoint — - // but only when the selected path may honestly mint (not signed-out Anywhere). if (canMintMobilePairingOffer({ connectionMode, signedIn })) { void generatePairing(true, address) } @@ -237,7 +265,7 @@ export default function MobilePage(): React.JSX.Element { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) + setRelayMintFailure(null) showFirstPairingFlow() } @@ -248,7 +276,7 @@ export default function MobilePage(): React.JSX.Element { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) + setRelayMintFailure(null) showPairAnotherDeviceFlow() } @@ -299,11 +327,12 @@ export default function MobilePage(): React.JSX.Element { pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} pairingQrError={pairingQrError} - relayDegraded={ - pairQrDataUrl != null && - connectionMode === 'automatic' && - encodedConnectionMode === 'local-only' + relayMintFailure={ + connectionMode === 'automatic' && pairQrDataUrl == null ? relayMintFailure : null } + onUseLan={() => handleConnectionModeChange('local-only')} + onRetryRelay={() => void generatePairing(true)} + onCopyRelayDiagnostics={() => void copyRelayDiagnostics()} platform={platform} refreshingNetworkInterfaces={refreshingNetworkInterfaces} revokeDevice={(id) => void revokeDevice(id)} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index 5e63d57fd..a5f4078b0 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -13,6 +13,7 @@ import type { MobilePageStage } from './mobile-page-stage' import { MobilePageToolbar } from './MobilePageToolbar' import { PhoneCarousel } from './PhoneCarousel' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' type MobilePageContentProps = { closeMobilePage: () => void @@ -39,7 +40,10 @@ type MobilePageContentProps = { pairQrDataUrl: string | null pairingUrl: string | null pairingQrError: boolean - relayDegraded: boolean + relayMintFailure: MobileRelayMintFailure | null + onUseLan: () => void + onRetryRelay: () => void + onCopyRelayDiagnostics: () => void platform: Platform refreshingNetworkInterfaces: boolean revokeDevice: (id: string) => void @@ -78,7 +82,10 @@ export function MobilePageContent({ pairQrDataUrl, pairingUrl, pairingQrError, - relayDegraded, + relayMintFailure, + onUseLan, + onRetryRelay, + onCopyRelayDiagnostics, platform, refreshingNetworkInterfaces, revokeDevice, @@ -92,7 +99,7 @@ export function MobilePageContent({ toggleMobileSidebarButton }: MobilePageContentProps): React.JSX.Element { return ( -
+
void + onRetry: () => void + onCopyDiagnostics: () => void + className?: string + compact?: boolean + busy?: boolean +}): React.JSX.Element { + const providerMissing = failure.stage === 'provider_missing' + const [showBusyFeedback, setShowBusyFeedback] = useState(false) + useEffect(() => { + if (!busy) { + setShowBusyFeedback(false) + return + } + const timer = window.setTimeout(() => setShowBusyFeedback(true), 200) + return () => window.clearTimeout(timer) + }, [busy]) + const visibleBusy = busy && showBusyFeedback + const title = visibleBusy + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.retryingTitle', + 'Retrying Orca Relay…' + ) + : providerMissing + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableTitle', + 'Orca Relay isn’t available on this desktop.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.title', + 'Couldn’t create a Relay pairing code.' + ) + const body = visibleBusy + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody', + 'Creating a new pairing code. This can take a moment over a remote connection.' + ) + : providerMissing + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody', + '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 ( +
+ {visibleBusy ? ( + + ) : ( + + )} +
+

+ {title} {body} +

+
+ + {!providerMissing ? ( + + ) : null} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts index 2d6f57c65..92db1931f 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts @@ -5,13 +5,15 @@ import { canMintMobilePairingOffer, type MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' type MutableRef = { current: T } /** * Mints (or rotates) a pairing QR. Every caller must go through this path so * signed-out Anywhere is refused rather than silently degraded to a local-only - * code under the Relay label. + * code under the Relay label. Anywhere mint failures surface relayFailure and + * clear any QR. */ export function useMobilePairingGeneration(params: { connectionMode: MobilePairingConnectionMode @@ -24,9 +26,7 @@ export function useMobilePairingGeneration(params: { setPairingUrl: (value: string | null) => void setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void - /** Mode the minted QR actually encodes (degraded Relay mints report - * 'local-only'); null while no QR is shown. */ - setEncodedConnectionMode: (value: MobilePairingConnectionMode | null) => void + setRelayMintFailure: (value: MobileRelayMintFailure | null) => void }): { generatePairing: ( rotate: boolean, @@ -45,7 +45,7 @@ export function useMobilePairingGeneration(params: { setPairingUrl, setPairingQrError, setPairLoading, - setEncodedConnectionMode + setRelayMintFailure } = params const generatePairing = useCallback( @@ -55,22 +55,16 @@ export function useMobilePairingGeneration(params: { connectionModeOverride?: MobilePairingConnectionMode ) => { const preferredMode = connectionModeOverride ?? connectionMode - // Why: every mint path (auto-generate, regenerate, address change, path - // invalidation) must refuse signed-out Anywhere rather than degrade to a - // local-only QR under the Relay label. if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) { return } const requestId = ++pairingRequestIdRef.current - // Mark the request synchronously so state changes cannot make the - // Step 2 auto-generate effect start a second offer in parallel. hasGeneratedRef.current = true if (mountedRef.current) { setPairLoading(true) } try { const address = addressOverride ?? selectedAddress - // canMint already requires sign-in for Anywhere, so preferred is honest. const result = await window.api.mobile.getPairingQR({ ...(address ? { address } : {}), connectionMode: preferredMode, @@ -84,21 +78,28 @@ export function useMobilePairingGeneration(params: { setPairQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) setPairingQrError(result.qrDataUrl === null) - setEncodedConnectionMode(result.connectionMode) + setRelayMintFailure(null) } } else { - hasGeneratedRef.current = false + // Why: keep hasGenerated so step-2 auto-mint does not loop on failure. if (mountedRef.current) { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) - toast.error( - translate( - 'auto.components.mobile.MobilePage.b353e18de1', - 'WebSocket transport is not running' + if (result.reason === 'relay_mint_failed' && result.relayFailure) { + setRelayMintFailure(result.relayFailure) + } else { + setRelayMintFailure(null) + // Why: IPC now forwards reason/guidance for all unavailability paths; + // prefer that copy over a hard-coded WebSocket-only message. + toast.error( + result.guidance ?? + translate( + 'auto.components.mobile.MobilePage.b353e18de1', + 'WebSocket transport is not running' + ) ) - ) + } } } } catch { @@ -107,7 +108,7 @@ export function useMobilePairingGeneration(params: { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) + setRelayMintFailure(null) toast.error( translate( 'auto.components.mobile.MobilePage.4c8bd11c1a', @@ -127,11 +128,11 @@ export function useMobilePairingGeneration(params: { mountedRef, pairingRequestIdRef, selectedAddress, - setEncodedConnectionMode, setPairLoading, setPairQrDataUrl, setPairingUrl, setPairingQrError, + setRelayMintFailure, signedIn ] ) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts index 77ae56418..69cf51eb7 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts @@ -23,6 +23,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl: (value: string | null) => void setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void + setRelayMintFailure?: (value: null) => void regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void }): void { const { @@ -35,6 +36,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate } = params const wasSignedInRef = useRef(signedIn) @@ -57,6 +59,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl(null) setPairingQrError(false) setPairQrDataUrl(null) + setRelayMintFailure?.(null) if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: rotate on the sign-in edge — the token behind the QR cleared at // sign-out may have been exposed, so the fresh session mints fresh. @@ -73,6 +76,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate ]) @@ -93,6 +97,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl(null) setPairingQrError(false) setPairQrDataUrl(null) + setRelayMintFailure?.(null) if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: no rotate here — the main process rotates exactly once when the // requested mode differs from the pending token's minted mode, so the @@ -113,6 +118,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate ]) } diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx index 41d16c398..ece7b2c56 100644 --- a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx @@ -123,7 +123,9 @@ describe('MobilePairingConnectionOptions', () => { screen.getByText('Phone can be on cellular or any Wi‑Fi. Sign-in required.') ).toBeVisible() expect( - screen.getByText('Phone must be on this Wi‑Fi or your Tailscale. No sign-in.') + screen.getByText( + 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + ) ).toBeVisible() await user.click(screen.getByRole('radio', { name: /Orca Relay/i })) @@ -163,4 +165,34 @@ describe('MobilePairingConnectionOptions', () => { expect(onChange).toHaveBeenCalledWith('local-only') statusListener?.('standby') }) + + it('keeps LAN available while Relay is retrying', async () => { + mocks.state = { + ...mocks.state, + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted' + } + } + const onChange = vi.fn() + const user = userEvent.setup() + render( + + ) + + expect(screen.getByText('Retrying')).toBeVisible() + const relay = screen.getByRole('radio', { name: /Orca Relay/i }) + const lan = screen.getByRole('radio', { name: /^LAN\b/i }) + expect(relay).toHaveAttribute('aria-disabled', 'true') + expect(lan).toHaveAttribute('aria-disabled', 'false') + await user.click(lan) + expect(onChange).toHaveBeenCalledWith('local-only') + }) }) diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx index 3314ea70f..a1a885487 100644 --- a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx @@ -43,6 +43,7 @@ type PathOptionProps = { description: string trailing?: ReactNode tabIndex: number + disabled?: boolean optionRef?: (el: HTMLDivElement | null) => void } @@ -58,6 +59,7 @@ function PathOption({ description, trailing, tabIndex, + disabled = false, optionRef }: PathOptionProps): React.JSX.Element { return ( @@ -66,8 +68,12 @@ function PathOption({ role="radio" tabIndex={tabIndex} aria-checked={selected} - onClick={onSelect} + aria-disabled={disabled} + onClick={disabled ? undefined : onSelect} onKeyDown={(event) => { + if (disabled) { + return + } if (event.key === ' ' || event.key === 'Enter') { event.preventDefault() onSelect() @@ -78,6 +84,7 @@ function PathOption({ // Why: match SettingsFormControls focus ring so keyboard focus is visible // even when the selected row already uses bg-accent/40. 'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50', + disabled && 'cursor-not-allowed opacity-60', selected ? 'bg-accent/40' : 'hover:bg-accent/20' )} > @@ -104,11 +111,16 @@ function PathOption({ export function MobilePairingConnectionOptions({ value, onChange, - compact = false + compact = false, + relayMintFailed = false, + relayMintRetrying = false }: { value: MobilePairingConnectionMode onChange: (value: MobilePairingConnectionMode) => void compact?: boolean + /** When true, show Unavailable on the Relay row (mint failed; no QR). */ + relayMintFailed?: boolean + relayMintRetrying?: boolean }): React.JSX.Element { const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) const connecting = useAppStore((state) => state.orcaProfileConnecting) @@ -134,8 +146,12 @@ export function MobilePairingConnectionOptions({ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return } + if (relayMintRetrying && value !== 'automatic') { + return + } event.preventDefault() - const next: MobilePairingConnectionMode = value === 'automatic' ? 'local-only' : 'automatic' + const next: MobilePairingConnectionMode = + relayMintRetrying || value === 'automatic' ? 'local-only' : 'automatic' onChange(next) optionRefs.current[next]?.focus() } @@ -182,7 +198,8 @@ export function MobilePairingConnectionOptions({ > { optionRefs.current.automatic = el }} @@ -198,7 +215,17 @@ export function MobilePairingConnectionOptions({ trailing={ signedIn && value === 'automatic' ? ( - {relayStatusLabel(relayStatus)} + {relayMintRetrying + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.retrying', + 'Retrying' + ) + : relayMintFailed + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + ) + : relayStatusLabel(relayStatus)} ) : null } @@ -206,7 +233,7 @@ export function MobilePairingConnectionOptions({
{ optionRefs.current['local-only'] = el }} @@ -217,7 +244,7 @@ export function MobilePairingConnectionOptions({ )} description={translate( 'auto.components.settings.MobilePairingConnectionOptions.localDescription', - 'Phone must be on this Wi‑Fi or your Tailscale. No sign-in.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' )} />
diff --git a/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx b/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx index 832f7571b..9d62603a9 100644 --- a/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx @@ -35,7 +35,67 @@ describe('MobilePairingQrSection', () => { ) expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code') - await userEvent.click(screen.getByRole('button', { name: /copy-fallback/ })) + await userEvent.click(screen.getByRole('button', { name: 'Copy pairing code' })) expect(writeClipboardText).toHaveBeenCalledWith('orca://pair?code=copy-fallback') }) + + it('moves focus to the copy action when a pairing code becomes ready', () => { + const props = { + qrDataUrl: null, + qrError: false, + pairingUrl: null, + endpoint: null, + qrEnlarged: false, + codeCopied: false, + onQrEnlargedChange: vi.fn(), + onCodeCopiedChange: vi.fn(), + onClearCodeCopiedTimer: vi.fn() + } + const { rerender } = render() + + rerender( + + ) + + expect(screen.getByRole('button', { name: 'Copy pairing code' })).toHaveFocus() + }) + + it('does not steal focus from a control that remains mounted', () => { + const props = { + qrDataUrl: null, + qrError: false, + pairingUrl: null, + endpoint: null, + qrEnlarged: false, + codeCopied: false, + onQrEnlargedChange: vi.fn(), + onCodeCopiedChange: vi.fn(), + onClearCodeCopiedTimer: vi.fn() + } + const { rerender } = render( + <> + + + + ) + const persistentAction = screen.getByRole('button', { name: 'Persistent action' }) + persistentAction.focus() + + rerender( + <> + + + + ) + + expect(persistentAction).toHaveFocus() + }) }) diff --git a/src/renderer/src/components/settings/MobilePairingQrSection.tsx b/src/renderer/src/components/settings/MobilePairingQrSection.tsx index b9741c027..b444d3412 100644 --- a/src/renderer/src/components/settings/MobilePairingQrSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingQrSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { Check, CircleAlert, Copy, Maximize2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '../ui/button' @@ -29,18 +29,38 @@ export function MobilePairingQrSection({ onClearCodeCopiedTimer }: MobilePairingQrSectionProps): React.JSX.Element | null { const pairingCodeButtonMountedRef = useRef(false) + const pairingCodeButtonRef = useRef(null) + const hadPairingUrlRef = useRef(pairingUrl != null) const codeCopiedResetTimerRef = useRef(null) + // Why: the reset timeout is owned here, so clearing only the parent's timer would leave it running. + const clearCodeCopiedResetTimer = useCallback(() => { + if (codeCopiedResetTimerRef.current !== null) { + window.clearTimeout(codeCopiedResetTimerRef.current) + codeCopiedResetTimerRef.current = null + } + onClearCodeCopiedTimer() + }, [onClearCodeCopiedTimer]) + const setPairingCodeButtonRef = useCallback( (node: HTMLButtonElement | null) => { pairingCodeButtonMountedRef.current = node !== null + pairingCodeButtonRef.current = node if (node === null) { - onClearCodeCopiedTimer() + clearCodeCopiedResetTimer() } }, - [onClearCodeCopiedTimer] + [clearCodeCopiedResetTimer] ) + useEffect(() => { + const becameReady = !hadPairingUrlRef.current && pairingUrl != null + hadPairingUrlRef.current = pairingUrl != null + if (becameReady && document.activeElement === document.body) { + pairingCodeButtonRef.current?.focus() + } + }, [pairingUrl]) + async function copyPairingCode() { if (!pairingUrl) { return @@ -50,7 +70,7 @@ export function MobilePairingQrSection({ if (!pairingCodeButtonMountedRef.current) { return } - onClearCodeCopiedTimer() + clearCodeCopiedResetTimer() onCodeCopiedChange(true) codeCopiedResetTimerRef.current = window.setTimeout(() => { codeCopiedResetTimerRef.current = null @@ -119,6 +139,10 @@ export function MobilePairingQrSection({ variant="outline" size="sm" onClick={() => void copyPairingCode()} + aria-label={translate( + 'auto.components.settings.MobilePane.copyPairingCode', + 'Copy pairing code' + )} className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3" > {pairingUrl} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index 59ed1b9a3..30ce241be 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -64,9 +64,17 @@ describe('MobilePairingSetupSection', () => { expect(screen.getByText(/must be able to reach this address/i)).toBeVisible() }) - it('describes address role when Anywhere is selected', () => { + it('explains the direct address when Orca Relay is selected', () => { renderSection({ connectionMode: 'automatic' }) + expect(screen.getByText('This computer’s address')).toBeVisible() + expect(screen.getByRole('combobox')).toBeVisible() expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled() + }) + + it('can move retry recovery into the persistent failure notice', () => { + renderSection({ showGenerateAction: false }) + expect(screen.queryByRole('button', { name: 'Generate QR code' })).toBeNull() }) it('disables generate when sign-in is required', () => { diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 2f5453013..12d63791f 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -19,6 +19,7 @@ type MobilePairingSetupSectionProps = { onRefreshNetworkInterfaces: () => void loading: boolean hasQrCode: boolean + showGenerateAction?: boolean onGenerateQr: () => void } @@ -33,6 +34,7 @@ export function MobilePairingSetupSection({ onRefreshNetworkInterfaces, loading, hasQrCode, + showGenerateAction = true, onGenerateQr }: MobilePairingSetupSectionProps): React.JSX.Element { const usingRelay = connectionMode === 'automatic' @@ -106,31 +108,33 @@ export function MobilePairingSetupSection({ ) : translate( 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', - 'The phone must be able to reach this address on Wi‑Fi or Tailscale.' + 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' )}

-
- -
+ {showGenerateAction ? ( +
+ +
+ ) : null} ) } diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 9c0fa0066..03bc81318 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -161,7 +161,8 @@ describe('MobilePane pairing connection mode', () => { listDevices: mocks.listDevices, listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice - } + }, + ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } } }) }) @@ -197,39 +198,49 @@ describe('MobilePane pairing connection mode', () => { expect(getPairingQR).not.toHaveBeenCalled() }) - it('flags an Anywhere mint that degraded to a local-only code', async () => { + it('surfaces Relay mint failure without a QR and offers Use LAN', async () => { getPairingQR.mockResolvedValue({ - available: true, - qrDataUrl: 'data:image/png;base64,qr', - pairingUrl: 'orca://pair#degraded', - endpoint: 'ws://host', - // Relay provisioning failed server-side; the offer encodes local-only. - connectionMode: 'local-only' + available: false, + reason: 'relay_mint_failed', + guidance: 'Use LAN or retry', + relayFailure: { + code: 'relay offline', + stage: 'create_pairing_relay', + message: 'relay offline' + } }) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Generate' })) await waitFor(() => - expect(screen.getByTestId('relay-degraded-notice')).toHaveTextContent( - 'only works on your LAN or Tailscale' + expect(screen.getByTestId('relay-mint-failure-notice')).toHaveTextContent( + 'Couldn’t create a Relay pairing code' ) ) + expect(screen.getByTestId('qr')).toHaveTextContent('none') - // Switching to LAN clears the mismatch along with the QR. - await user.click(screen.getByRole('button', { name: 'choose-local' })) + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://pair#local', + endpoint: 'ws://host', + connectionMode: 'local-only' + }) + await user.click(screen.getByRole('button', { name: 'Use LAN' })) + await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only')) await waitFor(() => - expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() ) }) - it('does not flag an honest Relay mint', async () => { + it('does not show mint failure after an honest Relay mint', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Generate' })) await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) - expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() }) it('keeps the copy fallback when QR encoding fails', async () => { @@ -257,6 +268,103 @@ describe('MobilePane pairing connection mode', () => { await user.click(screen.getByRole('button', { name: 'choose-local' })) expect(updateSettings).toHaveBeenCalledWith({ mobilePairingConnectionMode: 'local-only' }) expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + expect(getPairingQR).not.toHaveBeenCalled() + }) + + it('disables Relay recovery while a retry is in flight', async () => { + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await screen.findByTestId('relay-mint-failure-notice') + + let resolveRetry: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve + }) + ) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() + await user.dblClick(screen.getByRole('button', { name: 'Retry Relay' })) + expect(getPairingQR).toHaveBeenCalledTimes(2) + await waitFor(() => expect(screen.getByRole('button', { name: /Retrying/ })).toBeDisabled()) + + resolveRetry?.({ + available: true, + qrDataUrl: 'data:image/png;base64,relay', + pairingUrl: 'orca://relay', + endpoint: 'ws://relay', + connectionMode: 'automatic' + }) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,relay')) + expect(screen.getByRole('status')).toHaveTextContent('Pairing code ready') + }) + + it('lets LAN recover immediately while a Relay retry is unresolved', async () => { + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + await user.click(screen.getByRole('button', { name: 'Generate' })) + await screen.findByTestId('relay-mint-failure-notice') + + let resolveRetry: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve + }) + ) + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://local', + endpoint: 'ws://10.0.0.2', + connectionMode: 'local-only' + }) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + await user.click(screen.getByRole('button', { name: 'Use LAN' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + address: '10.0.0.2', + connectionMode: 'local-only' + }) + ) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,local')) + act(() => { + resolveRetry?.({ + available: true, + qrDataUrl: 'data:image/png;base64,stale-relay', + pairingUrl: 'orca://stale-relay', + endpoint: 'ws://relay', + connectionMode: 'automatic' + }) + }) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,local')) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') }) it('restores a saved local-only preference without user interaction', () => { @@ -366,7 +474,7 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('qr')).toHaveTextContent('none') }) - it('discards a QR that resolves after switching path mid-generate', async () => { + it('discards a Relay QR that resolves after switching path mid-generate', async () => { const user = userEvent.setup() let resolveQr: ((value: Record) => void) | undefined getPairingQR.mockImplementationOnce( @@ -379,18 +487,27 @@ describe('MobilePane pairing connection mode', () => { await user.click(screen.getByRole('button', { name: 'Generate' })) await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) - // Switch to LAN before the Relay mint resolves. + // Switch to LAN before the Relay mint resolves — LAN may auto-mint a new code. + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://pair#local', + endpoint: 'ws://host', + connectionMode: 'local-only' + }) await user.click(screen.getByRole('button', { name: 'choose-local' })) resolveQr?.({ available: true, qrDataUrl: 'data:image/png;base64,relay', pairingUrl: 'orca://relay', - endpoint: 'ws://relay' + endpoint: 'ws://relay', + connectionMode: 'automatic' }) - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(screen.getByTestId('qr')).toHaveTextContent('none') - expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only')) + await waitFor(() => + expect(screen.getByTestId('pairing-url')).not.toHaveTextContent('orca://relay') + ) }) }) diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 56e9350c0..f7f1ae0c8 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { CircleAlert } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '../../store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -18,12 +17,14 @@ import { MobilePairedDevicesSection } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions' import { MobilePairingSetupSection } from './MobilePairingSetupSection' +import { MobileRelayMintFailureNotice } from '../mobile/mobile-relay-mint-failure-notice' import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' import { canMintMobilePairingOffer, type MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' export { getMobilePaneSearchEntries } from './mobile-pane-search' @@ -33,9 +34,7 @@ export function MobilePane(): React.JSX.Element { const [qrDataUrl, setQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) const [qrError, setQrError] = useState(false) - // Mode the displayed QR actually encodes; can be 'local-only' under an - // Anywhere selection when Relay provisioning degraded server-side. - const [qrEncodedMode, setQrEncodedMode] = useState(null) + const [relayMintFailure, setRelayMintFailure] = useState(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [qrEnlarged, setQrEnlarged] = useState(false) @@ -87,7 +86,7 @@ export function MobilePane(): React.JSX.Element { setQrDataUrl(null) setPairingUrl(null) setQrError(false) - setQrEncodedMode(null) + setRelayMintFailure(null) setEndpoint(null) // Why: a superseded in-flight generate no longer clears loading in its // finally (the epoch bump skips it), so drop the spinner here or Generate @@ -167,10 +166,16 @@ export function MobilePane(): React.JSX.Element { ) const generateQR = useCallback( - async (opts: { rotate?: boolean } = {}) => { + async ( + opts: { + rotate?: boolean + connectionModeOverride?: MobilePairingConnectionMode + } = {} + ) => { + const preferredMode = opts.connectionModeOverride ?? connectionMode // Why: refuse signed-out Anywhere rather than degrading to a local-only QR // under the Relay label (canMint is the shared honesty gate). - if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { + if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) { return } const requestId = ++pairingRequestIdRef.current @@ -179,9 +184,7 @@ export function MobilePane(): React.JSX.Element { try { const result = await window.api.mobile.getPairingQR({ ...(selectedAddress ? { address: selectedAddress } : {}), - // canMint already requires sign-in for Anywhere, so the preferred path - // is the honest encoded mode. - connectionMode, + connectionMode: preferredMode, ...(opts.rotate || rotateNextQr ? { rotate: true } : {}) }) // Why: sign-out, a mode switch, or an address change bump the epoch. @@ -196,7 +199,7 @@ export function MobilePane(): React.JSX.Element { setQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) setQrError(result.qrDataUrl === null) - setQrEncodedMode(result.connectionMode) + setRelayMintFailure(null) setEndpoint(result.endpoint) setDeviceCountAtQr(getPairedMobileDevicesSnapshot().length) clearCodeCopiedResetTimer() @@ -204,18 +207,29 @@ export function MobilePane(): React.JSX.Element { setRotateNextQr(false) void loadDevices() } - } else { - if (mountedRef.current) { + } else if (mountedRef.current) { + setQrDataUrl(null) + setPairingUrl(null) + setQrError(false) + setEndpoint(null) + if (result.reason === 'relay_mint_failed' && result.relayFailure) { + setRelayMintFailure(result.relayFailure) + } else { + setRelayMintFailure(null) + // Why: IPC now forwards reason/guidance for all unavailability paths; + // prefer that copy over a hard-coded WebSocket-only message. toast.error( - translate( - 'auto.components.settings.MobilePane.cb9067c1c1', - 'WebSocket transport is not running' - ) + result.guidance ?? + translate( + 'auto.components.settings.MobilePane.cb9067c1c1', + 'WebSocket transport is not running' + ) ) } } } catch { if (mountedRef.current && requestId === pairingRequestIdRef.current) { + setRelayMintFailure(null) toast.error( translate( 'auto.components.settings.MobilePane.e3c427e020', @@ -250,13 +264,66 @@ export function MobilePane(): React.JSX.Element { handledModeRef.current = nextMode setConnectionMode(nextMode) void updateSettings({ mobilePairingConnectionMode: nextMode }) + // Why: after a Relay mint failure, LAN should mint immediately — including + // when the renderer has not chosen an address yet (main picks the default). + const shouldRecoverWithLan = relayMintFailure != null && nextMode === 'local-only' // A displayed or in-flight code encodes the old connection policy. The // main process rotates on the mode mismatch, so don't arm a second rotate. invalidatePairing({ armRotate: false }) + // Why: switching to LAN after a Relay failure should mint immediately. + if ( + shouldRecoverWithLan && + canMintMobilePairingOffer({ connectionMode: nextMode, signedIn }) + ) { + void generateQR({ rotate: false, connectionModeOverride: 'local-only' }) + } }, - [connectionMode, invalidatePairing, updateSettings, setConnectionMode] + [ + connectionMode, + generateQR, + invalidatePairing, + relayMintFailure, + signedIn, + updateSettings, + setConnectionMode + ] ) + const copyRelayDiagnostics = useCallback(async (): Promise => { + if (relayMintFailure == null) { + return + } + try { + await window.api.ui.writeClipboardText( + JSON.stringify( + { + kind: 'mobile_pairing_relay_failure', + preferredConnectionMode: connectionMode, + failure: relayMintFailure, + selectedAddress: selectedAddress ?? null, + at: new Date().toISOString() + }, + null, + 2 + ) + ) + if (mountedRef.current) { + toast.success( + translate('auto.components.settings.MobilePane.diagnosticsCopied', 'Diagnostics copied') + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.settings.MobilePane.diagnosticsCopyFailed', + 'Failed to copy diagnostics' + ) + ) + } + } + }, [connectionMode, mountedRef, relayMintFailure, selectedAddress]) + const handleSelectedAddressChange = useCallback( (address: string): void => { setSelectedAddress(address) @@ -332,7 +399,14 @@ export function MobilePane(): React.JSX.Element { connectionMode={connectionMode} canGenerate={canMintMobilePairingOffer({ connectionMode, signedIn })} connectionPathControl={ - + } networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} @@ -341,26 +415,26 @@ export function MobilePane(): React.JSX.Element { onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })} loading={loading} hasQrCode={qrDataUrl != null} + showGenerateAction={relayMintFailure == null} onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })} /> - {qrDataUrl != null && connectionMode === 'automatic' && qrEncodedMode === 'local-only' ? ( - // Why: an Anywhere mint can degrade server-side when Relay provisioning - // fails; say so instead of letting the Relay label overclaim the code. -
- -

- {translate( - 'auto.components.settings.MobilePane.relayDegradedNotice', - 'Relay couldn’t be reached — this code only works on your LAN or Tailscale. Regenerate to try again.' - )} -

-
+ {relayMintFailure != null && connectionMode === 'automatic' ? ( + changeConnectionMode('local-only')} + onRetry={() => void generateQR({ rotate: true })} + onCopyDiagnostics={() => void copyRelayDiagnostics()} + busy={loading} + /> ) : null} + + {pairingUrl != null && !loading + ? translate('auto.components.settings.MobilePane.pairingCodeReady', 'Pairing code ready') + : ''} + + { + it('keeps known machine-readable Relay codes for diagnostics', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: new Error('relay_control_not_active'), + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_control_not_active', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) + + it('keeps known codes carried on structured error objects', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: { code: 'relay_control_not_active' }, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_control_not_active', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) + + it('falls back for error values that are neither objects nor Errors', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: 'relay_control_not_active', + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) + + it('redacts free-form error messages', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: new Error('request failed for https://relay.example/token/secret'), + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) +}) diff --git a/src/shared/mobile-relay-mint-failure.ts b/src/shared/mobile-relay-mint-failure.ts new file mode 100644 index 000000000..431990a79 --- /dev/null +++ b/src/shared/mobile-relay-mint-failure.ts @@ -0,0 +1,41 @@ +/** + * Structured failure when an Anywhere (Orca Relay) pairing mint cannot attach Relay. + * Returned instead of silently degrading to a LAN-only QR under the Relay label. + */ +export type MobileRelayMintFailureStage = + | 'provider_missing' + | 'e2ee_missing' + | 'binding_failed' + | 'create_pairing_relay' + +export type MobileRelayMintFailure = { + code: string + stage: MobileRelayMintFailureStage + message: string +} + +export function mobileRelayMintFailureFromUnknown(args: { + stage: MobileRelayMintFailureStage + error: unknown + fallbackCode: string + fallbackMessage: string +}): MobileRelayMintFailure { + const candidateCode = + typeof args.error === 'object' && + args.error !== null && + 'code' in args.error && + typeof args.error.code === 'string' + ? args.error.code + : args.error instanceof Error + ? args.error.message + : null + const code = + candidateCode != null && /^relay_[a-z0-9_]{1,74}$/.test(candidateCode) + ? candidateCode + : args.fallbackCode + return { + code, + stage: args.stage, + message: args.fallbackMessage + } +}