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:
parent
0fe1278244
commit
9eede0084d
|
|
@ -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' }]
|
||||
|
|
|
|||
|
|
@ -126,7 +126,12 @@ export function registerMobileHandlers(
|
|||
// ZeroTier) where the default LAN IP isn't reachable from the phone.
|
||||
const ip = args?.address ?? getDefaultPairingAddress()
|
||||
if (!ip) {
|
||||
return { available: false as const }
|
||||
return {
|
||||
available: false as const,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance:
|
||||
'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: coalesce repeated QR regenerations onto a single never-scanned
|
||||
|
|
@ -143,7 +148,14 @@ export function registerMobileHandlers(
|
|||
name: `Mobile ${new Date().toLocaleDateString()}`
|
||||
})
|
||||
if (!offer.available) {
|
||||
return { available: false as const }
|
||||
// Why: surface Relay mint failures (and other pairing unavailability)
|
||||
// so the UI can refuse a silent LAN QR under the Relay label.
|
||||
return {
|
||||
available: false as const,
|
||||
reason: offer.reason,
|
||||
guidance: offer.guidance,
|
||||
...(offer.relayFailure ? { relayFailure: offer.relayFailure } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl)
|
||||
|
|
@ -155,9 +167,6 @@ export function registerMobileHandlers(
|
|||
pairingUrl: offer.pairingUrl,
|
||||
endpoint: offer.endpoint,
|
||||
deviceId: offer.deviceId,
|
||||
// Why: an automatic request can degrade to a local-only offer when
|
||||
// Relay provisioning fails; the UI needs the encoded mode to avoid
|
||||
// labeling a LAN-only code as Relay.
|
||||
connectionMode: offer.connectionMode
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 Wi‑Fi) or retry Relay.',
|
||||
relayFailure
|
||||
}
|
||||
}
|
||||
const relayProvider = this.mobileRelayPairingProvider
|
||||
if (!relayProvider) {
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'relay_provider_unavailable',
|
||||
stage: 'provider_missing',
|
||||
message: 'Orca Relay is not available on this desktop'
|
||||
})
|
||||
}
|
||||
const device = this.deviceRegistry?.getDevice(direct.deviceId)
|
||||
const publicKeyB64 = this.getE2EEPublicKey()
|
||||
if (!device || !publicKeyB64) {
|
||||
return { ...direct, connectionMode: 'local-only' }
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'e2ee_key_unavailable',
|
||||
stage: 'e2ee_missing',
|
||||
message: 'E2EE public key unavailable for Relay pairing'
|
||||
})
|
||||
}
|
||||
let relayPairing: Awaited<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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
|
|
@ -27,6 +27,7 @@ vi.mock('./WindowsFirewallNotice', () => ({
|
|||
}))
|
||||
|
||||
import { HeroFlow, type StepIndex } from './MobileHero'
|
||||
import { MobileHeroPairingStep } from './MobileHeroPairingStep'
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
|
|
@ -75,7 +76,10 @@ describe('HeroFlow height', () => {
|
|||
pairQrDataUrl={null}
|
||||
pairingUrl={null}
|
||||
pairingQrError={false}
|
||||
relayDegraded={false}
|
||||
relayMintFailure={null}
|
||||
onUseLan={vi.fn()}
|
||||
onRetryRelay={vi.fn()}
|
||||
onCopyRelayDiagnostics={vi.fn()}
|
||||
pairLoading={false}
|
||||
connectionMode="automatic"
|
||||
onConnectionModeChange={vi.fn()}
|
||||
|
|
@ -115,7 +119,10 @@ describe('HeroFlow height', () => {
|
|||
pairQrDataUrl={null}
|
||||
pairingUrl={null}
|
||||
pairingQrError={false}
|
||||
relayDegraded={false}
|
||||
relayMintFailure={null}
|
||||
onUseLan={vi.fn()}
|
||||
onRetryRelay={vi.fn()}
|
||||
onCopyRelayDiagnostics={vi.fn()}
|
||||
pairLoading={false}
|
||||
connectionMode="automatic"
|
||||
onConnectionModeChange={vi.fn()}
|
||||
|
|
@ -137,23 +144,69 @@ describe('HeroFlow height', () => {
|
|||
expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('flags a degraded Anywhere code and always shows the Relay beta note', () => {
|
||||
it('shows Relay mint failure with no QR and the beta note', () => {
|
||||
renderFlow(1, {
|
||||
pairQrDataUrl: 'data:image/png;base64,qr',
|
||||
relayDegraded: true
|
||||
pairQrDataUrl: null,
|
||||
relayMintFailure: {
|
||||
code: 'relay offline',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'relay offline'
|
||||
}
|
||||
})
|
||||
const notice = screen.getByTestId('relay-degraded-notice')
|
||||
expect(notice).toHaveTextContent('only works on your LAN or Tailscale')
|
||||
// Why: wrap-capable text item inside the fixed QR track (#9700); bare text
|
||||
// nodes in a flex row cannot shrink below max-content and overflow the track.
|
||||
expect(notice.querySelector('.min-w-0')).not.toBeNull()
|
||||
expect(notice.className).toMatch(/\bmin-w-0\b/)
|
||||
const notice = screen.getByTestId('relay-mint-failure-notice')
|
||||
expect(notice).toHaveTextContent('Couldn’t create a Relay pairing code')
|
||||
expect(notice).toHaveTextContent('Use LAN')
|
||||
expect(screen.getByText('No pairing code available')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Generate code' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the degradation notice when the code encodes what was selected', () => {
|
||||
it('explains an empty QR frame when no code has been generated yet', () => {
|
||||
renderFlow(1, { pairQrDataUrl: null, canGeneratePairing: true })
|
||||
expect(screen.getByText('Generate a pairing code to continue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains an empty QR frame when Relay sign-in is required', () => {
|
||||
renderFlow(1, {
|
||||
pairQrDataUrl: null,
|
||||
canGeneratePairing: false,
|
||||
connectionMode: 'automatic'
|
||||
})
|
||||
expect(screen.getByText('Sign in to create a Relay pairing code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Relay recovery immediately and delays visible retry feedback', async () => {
|
||||
renderFlow(1, {
|
||||
pairLoading: true,
|
||||
relayMintFailure: {
|
||||
code: 'relay_mint_failed',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
|
||||
await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible())
|
||||
})
|
||||
|
||||
it('does not offer a futile retry when Relay is unavailable on the desktop', () => {
|
||||
renderFlow(1, {
|
||||
relayMintFailure: {
|
||||
code: 'relay_provider_unavailable',
|
||||
stage: 'provider_missing',
|
||||
message: 'Orca Relay is not available on this desktop'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Orca Relay isn’t available on this desktop'
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('hides the mint-failure notice when a Relay QR is shown', () => {
|
||||
renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' })
|
||||
expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an encoder error while keeping the copy fallback enabled', () => {
|
||||
|
|
@ -165,4 +218,83 @@ describe('HeroFlow height', () => {
|
|||
expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code')
|
||||
expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('moves focus to the pairing-code action after recovery succeeds', () => {
|
||||
const props: React.ComponentProps<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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 couldn’t 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 couldn’t 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { CircleAlert, Copy, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
|
||||
import { NetworkInterfacePicker } from './NetworkInterfacePicker'
|
||||
import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions'
|
||||
import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice'
|
||||
import { MobileRelayMintFailureNotice } from './mobile-relay-mint-failure-notice'
|
||||
import { WindowsFirewallNotice } from './WindowsFirewallNotice'
|
||||
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** Why: one full sentence per device kind so translators own word order and punctuation. */
|
||||
function pairDeviceHeading(): string {
|
||||
const ua = navigator.userAgent
|
||||
if (ua.includes('Mac')) {
|
||||
return translate('auto.components.mobile.MobileHero.pairThisMac', 'Pair this Mac.')
|
||||
}
|
||||
if (ua.includes('Windows')) {
|
||||
return translate('auto.components.mobile.MobileHero.pairThisPc', 'Pair this PC.')
|
||||
}
|
||||
return translate('auto.components.mobile.MobileHero.pairThisComputer', 'Pair this computer.')
|
||||
}
|
||||
|
||||
/** Short copy for the QR frame when no image can be shown. */
|
||||
function emptyPairingQrMessage(args: {
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
canGeneratePairing: boolean
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
pairingQrError: boolean
|
||||
pairingUrl: string | null
|
||||
}): string {
|
||||
if (args.relayMintFailure != null) {
|
||||
return translate('auto.components.mobile.MobileHero.noRelayCode', 'No pairing code available')
|
||||
}
|
||||
if (!args.canGeneratePairing && args.connectionMode === 'automatic') {
|
||||
return translate(
|
||||
'auto.components.mobile.MobileHero.qrSignInRequired',
|
||||
'Sign in to create a Relay pairing code'
|
||||
)
|
||||
}
|
||||
if (args.pairingQrError && args.pairingUrl != null) {
|
||||
return translate(
|
||||
'auto.components.mobile.MobileHero.qrRenderFailed',
|
||||
'QR couldn’t be rendered — copy the code below'
|
||||
)
|
||||
}
|
||||
if (!args.canGeneratePairing) {
|
||||
return translate('auto.components.mobile.MobileHero.noPairingCode', 'No pairing code available')
|
||||
}
|
||||
return translate(
|
||||
'auto.components.mobile.MobileHero.qrGeneratePrompt',
|
||||
'Generate a pairing code to continue'
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileHeroPairingStep({
|
||||
pairQrDataUrl,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayMintFailure,
|
||||
onUseLan,
|
||||
onRetryRelay,
|
||||
onCopyRelayDiagnostics,
|
||||
pairLoading,
|
||||
connectionMode,
|
||||
onConnectionModeChange,
|
||||
onRegeneratePairing,
|
||||
canGeneratePairing,
|
||||
onCopyPairingCode,
|
||||
networkInterfaces,
|
||||
selectedAddress,
|
||||
onSelectedAddressChange,
|
||||
beforeCustomAddressChange,
|
||||
onRefreshNetworkInterfaces,
|
||||
refreshingNetworkInterfaces
|
||||
}: {
|
||||
pairQrDataUrl: string | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
onUseLan: () => void
|
||||
onRetryRelay: () => void
|
||||
onCopyRelayDiagnostics: () => void
|
||||
pairLoading: boolean
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
onConnectionModeChange: (mode: MobilePairingConnectionMode) => void
|
||||
onRegeneratePairing: () => void
|
||||
canGeneratePairing: boolean
|
||||
onCopyPairingCode: () => void
|
||||
networkInterfaces: readonly MobileNetworkInterface[]
|
||||
selectedAddress: string | undefined
|
||||
onSelectedAddressChange: (address: string) => void
|
||||
beforeCustomAddressChange: (address: string) => Promise<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 couldn’t 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 isn’t available on this desktop.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.title',
|
||||
'Couldn’t create a Relay pairing code.'
|
||||
)
|
||||
const body = visibleBusy
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody',
|
||||
'Creating a new pairing code. This can take a moment over a remote connection.'
|
||||
)
|
||||
: providerMissing
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
|
||||
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.body',
|
||||
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,9 @@ describe('MobilePairingConnectionOptions', () => {
|
|||
screen.getByText('Phone can be on cellular or any Wi‑Fi. Sign-in required.')
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.getByText('Phone must be on this Wi‑Fi or your Tailscale. No sign-in.')
|
||||
screen.getByText(
|
||||
'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /Orca Relay/i }))
|
||||
|
|
@ -163,4 +165,34 @@ describe('MobilePairingConnectionOptions', () => {
|
|||
expect(onChange).toHaveBeenCalledWith('local-only')
|
||||
statusListener?.('standby')
|
||||
})
|
||||
|
||||
it('keeps LAN available while Relay is retrying', async () => {
|
||||
mocks.state = {
|
||||
...mocks.state,
|
||||
orcaProfileAuthStatus: {
|
||||
activeProfileId: 'profile-1',
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
persistence: 'encrypted'
|
||||
}
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 Wi‑Fi or your Tailscale. No sign-in.'
|
||||
'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,67 @@ describe('MobilePairingQrSection', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code')
|
||||
await userEvent.click(screen.getByRole('button', { name: /copy-fallback/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Copy pairing code' }))
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('orca://pair?code=copy-fallback')
|
||||
})
|
||||
|
||||
it('moves focus to the copy action when a pairing code becomes ready', () => {
|
||||
const props = {
|
||||
qrDataUrl: null,
|
||||
qrError: false,
|
||||
pairingUrl: null,
|
||||
endpoint: null,
|
||||
qrEnlarged: false,
|
||||
codeCopied: false,
|
||||
onQrEnlargedChange: vi.fn(),
|
||||
onCodeCopiedChange: vi.fn(),
|
||||
onClearCodeCopiedTimer: vi.fn()
|
||||
}
|
||||
const { rerender } = render(<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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -64,9 +64,17 @@ describe('MobilePairingSetupSection', () => {
|
|||
expect(screen.getByText(/must be able to reach this address/i)).toBeVisible()
|
||||
})
|
||||
|
||||
it('describes address role when Anywhere is selected', () => {
|
||||
it('explains the direct address when Orca Relay is selected', () => {
|
||||
renderSection({ connectionMode: 'automatic' })
|
||||
expect(screen.getByText('This computer’s address')).toBeVisible()
|
||||
expect(screen.getByRole('combobox')).toBeVisible()
|
||||
expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('can move retry recovery into the persistent failure notice', () => {
|
||||
renderSection({ showGenerateAction: false })
|
||||
expect(screen.queryByRole('button', { name: 'Generate QR code' })).toBeNull()
|
||||
})
|
||||
|
||||
it('disables generate when sign-in is required', () => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type MobilePairingSetupSectionProps = {
|
|||
onRefreshNetworkInterfaces: () => void
|
||||
loading: boolean
|
||||
hasQrCode: boolean
|
||||
showGenerateAction?: boolean
|
||||
onGenerateQr: () => void
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ export function MobilePairingSetupSection({
|
|||
onRefreshNetworkInterfaces,
|
||||
loading,
|
||||
hasQrCode,
|
||||
showGenerateAction = true,
|
||||
onGenerateQr
|
||||
}: MobilePairingSetupSectionProps): React.JSX.Element {
|
||||
const usingRelay = connectionMode === 'automatic'
|
||||
|
|
@ -106,31 +108,33 @@ export function MobilePairingSetupSection({
|
|||
)
|
||||
: translate(
|
||||
'auto.components.settings.MobilePairingSetupSection.step2LocalDescription',
|
||||
'The phone must be able to reach this address on Wi‑Fi or Tailscale.'
|
||||
'The phone must be able to reach this address on Tailscale or Wi‑Fi.'
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'Couldn’t create a Relay pairing code'
|
||||
)
|
||||
)
|
||||
expect(screen.getByTestId('qr')).toHaveTextContent('none')
|
||||
|
||||
// Switching to LAN clears the mismatch along with the QR.
|
||||
await user.click(screen.getByRole('button', { name: 'choose-local' }))
|
||||
getPairingQR.mockResolvedValue({
|
||||
available: true,
|
||||
qrDataUrl: 'data:image/png;base64,local',
|
||||
pairingUrl: 'orca://pair#local',
|
||||
endpoint: 'ws://host',
|
||||
connectionMode: 'local-only'
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Use LAN' }))
|
||||
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only'))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not flag an honest Relay mint', async () => {
|
||||
it('does not show mint failure after an honest Relay mint', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<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')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 couldn’t 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}
|
||||
|
|
|
|||
|
|
@ -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 couldn’t be reached — this code only works on your LAN or Tailscale. Regenerate to try again.",
|
||||
"pairingQrError": "This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead."
|
||||
"pairingQrError": "This pairing code couldn’t 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 Wi‑Fi or your Tailscale. No sign-in."
|
||||
"localDescription": "Phone must be on this Wi‑Fi 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 couldn’t be reached — this code only works on your LAN or Tailscale.",
|
||||
"pairingQrError": "This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead."
|
||||
"pairingQrError": "This pairing code couldn’t 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 couldn’t 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 isn’t available on this desktop.",
|
||||
"title": "Couldn’t 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 Wi‑Fi.",
|
||||
"body": "Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.",
|
||||
"useLan": "Use LAN",
|
||||
"retrying": "Retrying…",
|
||||
"retry": "Retry Relay",
|
||||
"copyDiagnostics": "Copy diagnostics"
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11602,7 +11602,6 @@
|
|||
"2f077ef4eb": "をクリックしてコードをスキャンします。",
|
||||
"3aa7bb2d8b": "デスクトップのペアリング",
|
||||
"d1495e5e64": "Orca モバイルを開き、 をタップします",
|
||||
"901c98bb93": "これをペアリングします",
|
||||
"3960f5c339": "ステップ 2/2",
|
||||
"3241f3c26a": "QRをインストールする",
|
||||
"7af266b80d": "QRコードをインストールする",
|
||||
|
|
|
|||
|
|
@ -11602,7 +11602,6 @@
|
|||
"2f077ef4eb": "을 클릭하고 코드를 스캔하세요.",
|
||||
"3aa7bb2d8b": "데스크탑 페어링",
|
||||
"d1495e5e64": "Orca Mobile을 열고",
|
||||
"901c98bb93": "페어링",
|
||||
"3960f5c339": "2/2단계",
|
||||
"3241f3c26a": "QR 설치",
|
||||
"7af266b80d": "QR코드 설치",
|
||||
|
|
|
|||
|
|
@ -11602,7 +11602,6 @@
|
|||
"2f077ef4eb": ",然后扫码。",
|
||||
"3aa7bb2d8b": "配对桌面",
|
||||
"d1495e5e64": "打开 Orca 手机端,点击",
|
||||
"901c98bb93": "配对这个",
|
||||
"3960f5c339": "第 2 步(共 2 步)",
|
||||
"3241f3c26a": "安装二维码",
|
||||
"7af266b80d": "安装二维码",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue