fix(relay): refuse silent fallback when pairing invite fails (#11528)

* fix(relay): refuse silent fallback when pairing invite fails

When Orca Relay pairing fails, don't silently degrade to a LAN-only QR under the Relay label. Instead, surface structured failure information so the UI can clearly inform the user and offer recovery options.

* fix issues
This commit is contained in:
Jinjing 2026-07-30 02:13:47 -07:00 committed by GitHub
parent 0fe1278244
commit 9eede0084d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2442 additions and 414 deletions

View File

@ -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' }]

View File

@ -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 WiFi 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
}
}

View File

@ -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)
}
}

View File

@ -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<typeof SecureFileModule>()
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])
})
})

View File

@ -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)
}
}

View File

@ -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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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'
)

View File

@ -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<void> = Promise.resolve()
private mobileRelayPairingOfferInFlight: {
generation: number
address: string | null
rotate: boolean
request: Promise<MobilePairingOffer>
} | 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<MobilePairingOffer> {
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<MobilePairingOffer> {
// 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 WiFi) 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<ReturnType<MobileRelayPairingProvider['createPairingRelay']>>
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(

View File

@ -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
}
>

View File

@ -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

View File

@ -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%);
}
}

View File

@ -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('Couldnt 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 isnt 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('couldnt 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<typeof MobileHeroPairingStep> = {
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(<MobileHeroPairingStep {...props} />)
screen.getByRole('button', { name: 'Retry Relay' }).focus()
rerender(
<MobileHeroPairingStep
{...props}
pairQrDataUrl="data:image/png;base64,qr"
pairingUrl="orca://pair#ready"
relayMintFailure={null}
/>
)
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<typeof MobileHeroPairingStep> = {
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(<MobileHeroPairingStep {...props} />)
const refresh = screen.getByRole('button', { name: 'Refresh network interfaces' })
refresh.focus()
rerender(
<MobileHeroPairingStep
{...props}
pairQrDataUrl="data:image/png;base64,qr"
pairingUrl="orca://pair#ready"
pairLoading={false}
/>
)
expect(refresh).toHaveFocus()
})
})

View File

@ -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({
</button>
</div>
</div>
<div
className="mp-qr mp-qr-large"
aria-label={translate(
'auto.components.mobile.MobileHero.7af266b80d',
'Install QR code'
)}
>
<div className="mp-qr mp-qr-large">
{installQrUrl ? (
<img
src={installQrUrl}
@ -227,155 +212,27 @@ export function HeroFlow({
aria-hidden={stepIdx !== 1}
inert={stepIdx !== 1}
>
<div className="mp-pairing-layout">
<div className="mp-step2-copy mp-pairing-copy">
<div className="mp-eyebrow-row">
<div className="mp-step-num">2</div>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
</span>
</div>
<h2 className="mp-h2">
{translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '}
{getDeviceLabel()}.
</h2>
<p className="mp-lead-sm">
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
<strong>
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
</strong>
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
</p>
</div>
<div className="mp-pairing-relay">
<MobilePairingConnectionOptions
value={connectionMode}
onChange={onConnectionModeChange}
compact
/>
<MobileRelayBetaNotice className="mt-1.5" />
</div>
<div className="mp-qr-stack mp-pairing-qr">
<div
className="mp-qr mp-qr-large"
aria-label={translate(
'auto.components.mobile.MobileHero.bb0074ce11',
'Pairing QR code'
)}
aria-busy={pairLoading}
>
{pairQrDataUrl ? (
<img
src={pairQrDataUrl}
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
className={cn(pairLoading && 'mp-qr-refreshing')}
/>
) : null}
{pairLoading ? (
<span className="mp-qr-loading">
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
</span>
) : null}
</div>
<button
type="button"
className="mp-link-under"
onClick={onRegeneratePairing}
// Why: signed-out Anywhere can't serve Relay; disabling avoids
// minting a local-only QR under the Relay label. Sign in or pick
// LAN (shown in the path options above) to enable it.
disabled={pairLoading || !canGeneratePairing}
>
{pairLoading
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
: pairQrDataUrl
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
</button>
{relayDegraded ? (
<p
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
data-testid="relay-degraded-notice"
>
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{/* Why: min-w-0 so the flex text item can wrap inside the fixed QR track (#9700). */}
<span className="min-w-0">
{translate(
'auto.components.mobile.MobileHero.relayDegradedNotice',
'Relay couldnt be reached — this code only works on your LAN or Tailscale.'
)}
</span>
</p>
) : null}
{pairingQrError ? (
<p
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-destructive"
role="alert"
>
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0">
{translate(
'auto.components.mobile.MobileHero.pairingQrError',
'This pairing code couldnt be rendered as a QR code. Copy it into Orca Mobile instead.'
)}
</span>
</p>
) : null}
</div>
<div className="mp-pairing-controls">
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
beforeCustomAddressChange={beforeCustomAddressChange}
// Why: direct-first and local-only pairing both advertise a
// local route; keeping it visible also prevents mode shifts.
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<div className="mp-inline-actions">
<span className="mp-action-divider">
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
</span>
<button
type="button"
className="mp-text-link"
onClick={onCopyPairingCode}
disabled={!pairingUrl || pairLoading}
>
<Copy className="size-3.5" />
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
</button>
</div>
<WindowsFirewallNotice
pairingReady={pairQrDataUrl != null}
address={selectedAddress}
className="mt-3"
/>
</div>
</div>
<MobileHeroPairingStep
pairQrDataUrl={pairQrDataUrl}
pairingUrl={pairingUrl}
pairingQrError={pairingQrError}
relayMintFailure={relayMintFailure}
onUseLan={onUseLan}
onRetryRelay={onRetryRelay}
onCopyRelayDiagnostics={onCopyRelayDiagnostics}
pairLoading={pairLoading}
connectionMode={connectionMode}
onConnectionModeChange={onConnectionModeChange}
onRegeneratePairing={onRegeneratePairing}
canGeneratePairing={canGeneratePairing}
onCopyPairingCode={onCopyPairingCode}
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
beforeCustomAddressChange={beforeCustomAddressChange}
onRefreshNetworkInterfaces={onRefreshNetworkInterfaces}
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
/>
</div>
</div>

View File

@ -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 couldnt 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<boolean>
onRefreshNetworkInterfaces: () => void
refreshingNetworkInterfaces: boolean
}): React.JSX.Element {
const copyPairingCodeRef = useRef<HTMLButtonElement | null>(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 (
<div className={cn('mp-pairing-layout', relayMintFailure != null && 'has-failure')}>
<div className="mp-step2-copy mp-pairing-copy">
<div className="mp-eyebrow-row">
<div className="mp-step-num">2</div>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
</span>
</div>
<h2 className="mp-h2">{pairDeviceHeading()}</h2>
<p className="mp-lead-sm">
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
<strong>
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
</strong>
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
</p>
</div>
<div className="mp-pairing-relay">
<MobilePairingConnectionOptions
value={connectionMode}
onChange={onConnectionModeChange}
compact
relayMintFailed={relayMintFailure != null}
relayMintRetrying={relayMintFailure != null && pairLoading}
/>
<MobileRelayBetaNotice className="mt-1.5" />
</div>
{relayMintFailure != null ? (
<MobileRelayMintFailureNotice
className="mp-pairing-failure"
failure={relayMintFailure}
onUseLan={onUseLan}
onRetry={onRetryRelay}
onCopyDiagnostics={onCopyRelayDiagnostics}
compact
busy={pairLoading}
/>
) : null}
<div className="mp-qr-stack mp-pairing-qr">
<div className="mp-qr mp-qr-large" aria-busy={pairLoading}>
{pairQrDataUrl ? (
<img
src={pairQrDataUrl}
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
className={cn(pairLoading && 'mp-qr-refreshing')}
/>
) : null}
{pairLoading ? (
<span className="mp-qr-loading">
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
</span>
) : null}
{emptyQrMessage != null ? (
<span className="mp-qr-empty text-center text-xs text-muted-foreground px-3">
{emptyQrMessage}
</span>
) : null}
</div>
<span className="sr-only" role="status" aria-live="polite">
{pairQrDataUrl != null && !pairLoading
? translate('auto.components.mobile.MobileHero.pairingCodeReady', 'Pairing code ready')
: ''}
</span>
{relayMintFailure == null ? (
<button
type="button"
className="mp-link-under"
onClick={onRegeneratePairing}
disabled={pairLoading || !canGeneratePairing}
>
{pairLoading
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
: pairQrDataUrl
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
</button>
) : null}
{pairingQrError ? (
<p
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-destructive"
role="alert"
>
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0">
{translate(
'auto.components.mobile.MobileHero.pairingQrError',
'This pairing code couldnt be rendered as a QR code. Copy it into Orca Mobile instead.'
)}
</span>
</p>
) : null}
</div>
<div className="mp-pairing-controls">
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
beforeCustomAddressChange={beforeCustomAddressChange}
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<div className="mp-inline-actions">
<span className="mp-action-divider">
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
</span>
<button
ref={copyPairingCodeRef}
type="button"
className="mp-text-link"
onClick={onCopyPairingCode}
disabled={!pairingUrl || pairLoading}
>
<Copy className="size-3.5" />
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
</button>
</div>
<WindowsFirewallNotice
pairingReady={pairQrDataUrl != null}
address={selectedAddress}
className="mt-3"
/>
</div>
</div>
)
}

View File

@ -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', () => ({
<span data-testid="pairing-qr">{props.pairQrDataUrl ?? 'none'}</span>
<span data-testid="pairing-url">{props.pairingUrl ?? 'none'}</span>
<span data-testid="pairing-qr-error">{String(props.pairingQrError)}</span>
<span data-testid="relay-failure">{props.relayMintFailure?.stage ?? 'none'}</span>
<button type="button" onClick={props.enterFlow}>
Enter flow
</button>
@ -71,6 +75,9 @@ vi.mock('./MobilePageContent', () => ({
<button type="button" onClick={() => props.handleConnectionModeChange('local-only')}>
LAN
</button>
<button type="button" onClick={props.onRetryRelay}>
Retry Relay
</button>
<button type="button" onClick={() => props.handleAddressChange('10.0.0.2')}>
Change address
</button>
@ -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<string, unknown>) => 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)
})
})

View File

@ -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<StepIndex>(0)
@ -33,10 +34,7 @@ export default function MobilePage(): React.JSX.Element {
const [pairQrDataUrl, setPairQrDataUrl] = useState<string | null>(null)
const [pairingUrl, setPairingUrl] = useState<string | null>(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<MobilePairingConnectionMode | null>(null)
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(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<void> => {
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)}

View File

@ -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 (
<div className="mobile-page-root">
<div className="mobile-page-root scrollbar-sleek">
<MobilePageToolbar
showMobileButton={showMobileButton}
onClose={closeMobilePage}
@ -123,7 +130,10 @@ export function MobilePageContent({
pairQrDataUrl={pairQrDataUrl}
pairingUrl={pairingUrl}
pairingQrError={pairingQrError}
relayDegraded={relayDegraded}
relayMintFailure={relayMintFailure}
onUseLan={onUseLan}
onRetryRelay={onRetryRelay}
onCopyRelayDiagnostics={onCopyRelayDiagnostics}
pairLoading={pairLoading}
connectionMode={connectionMode}
onConnectionModeChange={handleConnectionModeChange}

View File

@ -0,0 +1,129 @@
import { useEffect, useState } from 'react'
import { CircleAlert, Loader2 } from 'lucide-react'
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
import { cn } from '@/lib/utils'
export function MobileRelayMintFailureNotice({
failure,
onUseLan,
onRetry,
onCopyDiagnostics,
className,
compact = false,
busy = false
}: {
failure: MobileRelayMintFailure
onUseLan: () => 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 isnt available on this desktop.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.title',
'Couldnt 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 WiFi.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.body',
'Retry, or use LAN to pair over Tailscale or the same WiFi.'
)
return (
<div
className={cn(
'flex w-full min-w-0 items-start gap-2 rounded-lg border p-3 text-xs',
visibleBusy
? 'border-border bg-muted/40 text-foreground'
: 'border-destructive/30 bg-destructive/10 text-destructive',
className
)}
data-testid="relay-mint-failure-notice"
>
{visibleBusy ? (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin" aria-hidden />
) : (
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1 space-y-2">
<p
className="min-w-0"
role={visibleBusy ? 'status' : 'alert'}
aria-live={visibleBusy ? 'polite' : 'assertive'}
>
<span className="font-medium">{title}</span> {body}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
</Button>
{!providerMissing ? (
<Button
type="button"
size={compact ? 'xs' : 'sm'}
variant="outline"
onClick={onRetry}
disabled={busy}
className="w-28"
>
{visibleBusy ? <Loader2 className="animate-spin" /> : null}
{visibleBusy
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retrying',
'Retrying…'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retry',
'Retry Relay'
)}
</Button>
) : null}
<Button
type="button"
size={compact ? 'xs' : 'sm'}
variant="ghost"
onClick={onCopyDiagnostics}
>
{translate(
'auto.components.mobile.MobileRelayMintFailureNotice.copyDiagnostics',
'Copy diagnostics'
)}
</Button>
</div>
</div>
</div>
)
}

View File

@ -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<T> = { 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
]
)

View File

@ -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
])
}

View File

@ -123,7 +123,9 @@ describe('MobilePairingConnectionOptions', () => {
screen.getByText('Phone can be on cellular or any WiFi. Sign-in required.')
).toBeVisible()
expect(
screen.getByText('Phone must be on this WiFi or your Tailscale. No sign-in.')
screen.getByText(
'Phone must be on this WiFi 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(
<MobilePairingConnectionOptions
value="automatic"
onChange={onChange}
relayMintFailed
relayMintRetrying
/>
)
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')
})
})

View File

@ -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({
>
<PathOption
selected={value === 'automatic'}
tabIndex={value === 'automatic' ? 0 : -1}
tabIndex={value === 'automatic' && !relayMintRetrying ? 0 : -1}
disabled={relayMintRetrying}
optionRef={(el) => {
optionRefs.current.automatic = el
}}
@ -198,7 +215,17 @@ export function MobilePairingConnectionOptions({
trailing={
signedIn && value === 'automatic' ? (
<Badge variant="outline" className="text-[11px]">
{relayStatusLabel(relayStatus)}
{relayMintRetrying
? translate(
'auto.components.settings.MobilePairingConnectionOptions.retrying',
'Retrying'
)
: relayMintFailed
? translate(
'auto.components.settings.MobilePairingConnectionOptions.unavailable',
'Unavailable'
)
: relayStatusLabel(relayStatus)}
</Badge>
) : null
}
@ -206,7 +233,7 @@ export function MobilePairingConnectionOptions({
<div className="border-t border-border" />
<PathOption
selected={value === 'local-only'}
tabIndex={value === 'local-only' ? 0 : -1}
tabIndex={value === 'local-only' || relayMintRetrying ? 0 : -1}
optionRef={(el) => {
optionRefs.current['local-only'] = el
}}
@ -217,7 +244,7 @@ export function MobilePairingConnectionOptions({
)}
description={translate(
'auto.components.settings.MobilePairingConnectionOptions.localDescription',
'Phone must be on this WiFi or your Tailscale. No sign-in.'
'Phone must be on this WiFi or connected through Tailscale. No sign-in required.'
)}
/>
</div>

View File

@ -35,7 +35,67 @@ describe('MobilePairingQrSection', () => {
)
expect(screen.getByRole('alert')).toHaveTextContent('couldnt 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(<MobilePairingQrSection {...props} />)
rerender(
<MobilePairingQrSection
{...props}
qrDataUrl="data:image/png;base64,qr"
pairingUrl="orca://pair#ready"
/>
)
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(
<>
<button type="button">Persistent action</button>
<MobilePairingQrSection {...props} />
</>
)
const persistentAction = screen.getByRole('button', { name: 'Persistent action' })
persistentAction.focus()
rerender(
<>
<button type="button">Persistent action</button>
<MobilePairingQrSection
{...props}
qrDataUrl="data:image/png;base64,qr"
pairingUrl="orca://pair#ready"
/>
</>
)
expect(persistentAction).toHaveFocus()
})
})

View File

@ -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<HTMLButtonElement | null>(null)
const hadPairingUrlRef = useRef(pairingUrl != null)
const codeCopiedResetTimerRef = useRef<number | null>(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"
>
<span className="flex-1 text-left">{pairingUrl}</span>

View File

@ -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 computers 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', () => {

View File

@ -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 WiFi or Tailscale.'
'The phone must be able to reach this address on Tailscale or WiFi.'
)}
</p>
</div>
<div className="space-y-2">
<Button onClick={onGenerateQr} disabled={generateDisabled} size="sm" className="gap-1.5">
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : hasQrCode ? (
<RefreshCw className="size-3.5" />
) : (
<QrCode className="size-3.5" />
)}
{hasQrCode
? translate(
'auto.components.settings.MobilePairingSetupSection.regenerate',
'Regenerate QR code'
)
: translate(
'auto.components.settings.MobilePairingSetupSection.generate',
'Generate QR code'
)}
</Button>
</div>
{showGenerateAction ? (
<div className="space-y-2">
<Button onClick={onGenerateQr} disabled={generateDisabled} size="sm" className="gap-1.5">
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : hasQrCode ? (
<RefreshCw className="size-3.5" />
) : (
<QrCode className="size-3.5" />
)}
{hasQrCode
? translate(
'auto.components.settings.MobilePairingSetupSection.regenerate',
'Regenerate QR code'
)
: translate(
'auto.components.settings.MobilePairingSetupSection.generate',
'Generate QR code'
)}
</Button>
</div>
) : null}
</section>
)
}

View File

@ -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(<MobilePane />)
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(
'Couldnt 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(<MobilePane />)
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(<MobilePane />)
await user.click(screen.getByRole('button', { name: 'Generate' }))
await screen.findByTestId('relay-mint-failure-notice')
let resolveRetry: ((value: Record<string, unknown>) => 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(<MobilePane />)
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<string, unknown>) => 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<string, unknown>) => 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')
)
})
})

View File

@ -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<string | null>(null)
const [pairingUrl, setPairingUrl] = useState<string | null>(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<MobilePairingConnectionMode | null>(null)
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(null)
const [endpoint, setEndpoint] = useState<string | null>(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<void> => {
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={
<MobilePairingConnectionOptions value={connectionMode} onChange={changeConnectionMode} />
<MobilePairingConnectionOptions
value={connectionMode}
onChange={changeConnectionMode}
relayMintFailed={relayMintFailure != null && connectionMode === 'automatic'}
relayMintRetrying={
relayMintFailure != null && connectionMode === 'automatic' && loading
}
/>
}
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.
<div
className="flex items-start gap-2.5 rounded-lg border border-border bg-muted/40 p-3"
data-testid="relay-degraded-notice"
>
<CircleAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.MobilePane.relayDegradedNotice',
'Relay couldnt be reached — this code only works on your LAN or Tailscale. Regenerate to try again.'
)}
</p>
</div>
{relayMintFailure != null && connectionMode === 'automatic' ? (
<MobileRelayMintFailureNotice
failure={relayMintFailure}
onUseLan={() => changeConnectionMode('local-only')}
onRetry={() => void generateQR({ rotate: true })}
onCopyDiagnostics={() => void copyRelayDiagnostics()}
busy={loading}
/>
) : null}
<span className="sr-only" role="status" aria-live="polite">
{pairingUrl != null && !loading
? translate('auto.components.settings.MobilePane.pairingCodeReady', 'Pairing code ready')
: ''}
</span>
<MobilePairingQrSection
qrDataUrl={qrDataUrl}
qrError={qrError}

View File

@ -6268,7 +6268,11 @@
"1b1b70279a": "No devices paired yet.",
"1592afcc7a": "No devices paired yet. Scan the QR code with the Orca mobile app.",
"relayDegradedNotice": "Relay couldnt be reached — this code only works on your LAN or Tailscale. Regenerate to try again.",
"pairingQrError": "This pairing code couldnt be rendered as a QR code. Copy it into Orca Mobile instead."
"pairingQrError": "This pairing code couldnt be rendered as a QR code. Copy it into Orca Mobile instead.",
"pairingCodeReady": "Pairing code ready",
"copyPairingCode": "Copy pairing code",
"diagnosticsCopied": "Diagnostics copied",
"diagnosticsCopyFailed": "Failed to copy diagnostics"
},
"MobileSettingsPane": {
"9a3c280e49": "GitHub Releases",
@ -9582,7 +9586,8 @@
"signIn": "Sign in",
"signInAgain": "Sign in again",
"localTitle": "LAN",
"localDescription": "Phone must be on this WiFi or your Tailscale. No sign-in."
"localDescription": "Phone must be on this WiFi or connected through Tailscale. No sign-in required.",
"retrying": "Retrying"
},
"MobilePairingSetupSection": {
"title": "Pair a phone",
@ -11634,7 +11639,6 @@
"2f077ef4eb": ", and scan the code.",
"3aa7bb2d8b": "Pair Desktop",
"d1495e5e64": "Open Orca Mobile, tap",
"901c98bb93": "Pair this",
"3960f5c339": "Step 2 of 2",
"3241f3c26a": "Install QR",
"7af266b80d": "Install QR code",
@ -11665,7 +11669,16 @@
"stable": "Stable"
},
"relayDegradedNotice": "Relay couldnt be reached — this code only works on your LAN or Tailscale.",
"pairingQrError": "This pairing code couldnt be rendered as a QR code. Copy it into Orca Mobile instead."
"pairingQrError": "This pairing code couldnt be rendered as a QR code. Copy it into Orca Mobile instead.",
"noRelayCode": "No pairing code available",
"noPairingCode": "No pairing code available",
"qrSignInRequired": "Sign in to create a Relay pairing code",
"qrRenderFailed": "QR couldnt be rendered — copy the code below",
"qrGeneratePrompt": "Generate a pairing code to continue",
"pairingCodeReady": "Pairing code ready",
"pairThisMac": "Pair this Mac.",
"pairThisPc": "Pair this PC.",
"pairThisComputer": "Pair this computer."
},
"MobilePage": {
"e17393c6a3": "Phone preview",
@ -11678,7 +11691,9 @@
"4e1eb5d55c": "Failed to revoke device",
"255372e6e8": "Device revoked",
"1b4509a8a1": "paired",
"c5909374cf": "intro"
"c5909374cf": "intro",
"diagnosticsCopied": "Diagnostics copied",
"diagnosticsCopyFailed": "Failed to copy diagnostics"
},
"MobilePageToolbar": {
"ad2284a9e2": "Close · Esc",
@ -11809,6 +11824,18 @@
"blocked-title": "Windows may be blocking Orca Mobile",
"blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.",
"repair": "Repair firewall access"
},
"MobileRelayMintFailureNotice": {
"retryingTitle": "Retrying Orca Relay…",
"unavailableTitle": "Orca Relay isnt available on this desktop.",
"title": "Couldnt create a Relay pairing code.",
"retryingBody": "Creating a new pairing code. This can take a moment over a remote connection.",
"unavailableBody": "Use LAN to pair over Tailscale or the same WiFi.",
"body": "Retry, or use LAN to pair over Tailscale or the same WiFi.",
"useLan": "Use LAN",
"retrying": "Retrying…",
"retry": "Retry Relay",
"copyDiagnostics": "Copy diagnostics"
}
},
"gitlab": {

View File

@ -11602,7 +11602,6 @@
"2f077ef4eb": ", y escanea el código.",
"3aa7bb2d8b": "Emparejar escritorio",
"d1495e5e64": "Abre Orca Mobile, toca",
"901c98bb93": "Empareja este",
"3960f5c339": "Paso 2 de 2",
"3241f3c26a": "QR de instalación",
"7af266b80d": "Código QR de instalación",

View File

@ -11602,7 +11602,6 @@
"2f077ef4eb": "をクリックしてコードをスキャンします。",
"3aa7bb2d8b": "デスクトップのペアリング",
"d1495e5e64": "Orca モバイルを開き、 をタップします",
"901c98bb93": "これをペアリングします",
"3960f5c339": "ステップ 2/2",
"3241f3c26a": "QRをインストールする",
"7af266b80d": "QRコードをインストールする",

View File

@ -11602,7 +11602,6 @@
"2f077ef4eb": "을 클릭하고 코드를 스캔하세요.",
"3aa7bb2d8b": "데스크탑 페어링",
"d1495e5e64": "Orca Mobile을 열고",
"901c98bb93": "페어링",
"3960f5c339": "2/2단계",
"3241f3c26a": "QR 설치",
"7af266b80d": "QR코드 설치",

View File

@ -11602,7 +11602,6 @@
"2f077ef4eb": ",然后扫码。",
"3aa7bb2d8b": "配对桌面",
"d1495e5e64": "打开 Orca 手机端,点击",
"901c98bb93": "配对这个",
"3960f5c339": "第 2 步(共 2 步)",
"3241f3c26a": "安装二维码",
"7af266b80d": "安装二维码",

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { mobileRelayMintFailureFromUnknown } from './mobile-relay-mint-failure'
describe('mobileRelayMintFailureFromUnknown', () => {
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'
})
})
})

View File

@ -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
}
}