diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts index 263f0853a83..742af991c7e 100644 --- a/mobile/src/transport/pairing-relay-candidate.test.ts +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -107,6 +107,7 @@ describe('recovering pairing relay candidate', () => { ['wrong cell', new RelayOuterError(4409)], ['planned drain', new RelayOuterError(4503)], ['opaque close', new RelayOuterError(1006)], + ['HTTP 502', new Error('HTTP 502')], ['HTTP 503', new Error('HTTP 503')], ['HTTP 504', new Error('HTTP 504')], ['transport failure', new Error('relay transport error')] diff --git a/src/main/index.ts b/src/main/index.ts index a127db089cc..096f2aa09b4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2209,6 +2209,7 @@ app.whenReady().then(async () => { runtimeRpc.setMobileRelayPairingProvider({ createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + onDemandStateChanged: () => relayService.demandStateChanged(), getEndpoints: (context, params) => relayService.getEndpoints(context, params), provisionRelay: (context, params) => relayService.provisionRelay(context, params) }) diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index b436b888938..7d74a799a7f 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -33,7 +33,10 @@ function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding ? { relayHostId: binding.relayHostId, relayDeviceId: binding.relayDeviceId, - ownerIdentityKey: binding.ownerIdentityKey + ownerIdentityKey: binding.ownerIdentityKey, + ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) + ? { inviteExpiresAt: binding.inviteExpiresAt } + : {}) } : undefined } diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index d2430d3c6fd..76e176a9d98 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -16,6 +16,8 @@ import type { RelayRevokeOutboxItem } from './relay-revoke-outbox' import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' +import { deriveRelayHostId } from './relay-http-client' +import { RelayDemandLedger } from './relay-demand-ledger' type DesktopRelayServiceOptions = { authConfig: OrcaCloudAuthConfig @@ -44,6 +46,9 @@ export class DesktopRelayService { private readonly coordinator: RelayAuthCoordinator private readonly revokeOutbox: RelayRevokeOutbox private readonly runtimeRpc: OrcaRuntimeRpcServer + private readonly demandLedger: RelayDemandLedger + private demandExpiryTimer: ReturnType | null = null + private stopped = false constructor(options: DesktopRelayServiceOptions) { const keypair = options.runtimeRpc.getE2EEKeypair() @@ -53,8 +58,17 @@ export class DesktopRelayService { } this.runtimeRpc = options.runtimeRpc this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox() + this.demandLedger = new RelayDemandLedger({ + deviceRegistry: options.runtimeRpc.getDeviceRegistry()!, + revokeOutbox: this.revokeOutbox, + relayHostId: deriveRelayHostId(keypair.publicKey) + }) this.coordinator = new RelayAuthCoordinator({ readContext: () => readRelayAuthContext(options.authConfig, options.userDataPath), + hasDemand: ({ identity }) => + this.demandLedger.hasDemand( + `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` + ), openBroker: async ({ context, isCurrent, refreshAccessToken }) => { const broker = await RelaySessionBroker.connect({ authConfig: options.authConfig, @@ -75,11 +89,11 @@ export class DesktopRelayService { } start(): void { - this.coordinator.reconcile() + this.refreshDemand() } authMutated(): void { - this.coordinator.reconcile() + this.refreshDemand() } fenceAndCloseNow(): void { @@ -89,21 +103,23 @@ export class DesktopRelayService { async createPairingRelay( relayDeviceId: string ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> { - const broker = this.coordinator.getActiveBroker() - if (!(broker instanceof RelaySessionBroker)) { - throw new Error('relay_control_not_active') - } - return { - relay: await broker.createPairingRelay(relayDeviceId), - binding: { - relayHostId: broker.hostId, - relayDeviceId, - ownerIdentityKey: broker.ownerIdentityKey + return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => { + const broker = await this.requireActiveBroker() + const relay = await broker.createPairingRelay(relayDeviceId) + return { + relay, + binding: { + relayHostId: broker.hostId, + relayDeviceId, + ownerIdentityKey: broker.ownerIdentityKey, + inviteExpiresAt: relay.inviteExpiresAt + } } - } + }) } onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void { + this.refreshDemand() const broker = this.coordinator.getActiveBroker() if ( broker instanceof RelaySessionBroker && @@ -119,31 +135,33 @@ export class DesktopRelayService { params: PairingGetEndpointsParams ): Promise { this.requireMobileDevice(context.deviceId) - const broker = this.coordinator.getActiveBroker() - if (!(broker instanceof RelaySessionBroker) || !broker.endpoint) { - return { v: 1, relay: null } - } - this.assertRelayHost(context, broker) - const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint } - if (params.installReqId) { - result.installStatus = await broker.credentialInstallStatus( - context.deviceId, - params.installReqId - ) - } - if (params.resumeConfirmReqId) { - if ( - context.transport.transport !== 'relay' || - context.transport.credentialKind !== 'resume' - ) { - throw new Error('resume_confirmation_unavailable') + return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => { + const broker = await this.activeBrokerForDemand() + if (!broker?.endpoint) { + return { v: 1, relay: null } } - result.resumeConfirmation = await broker.confirmResume( - context.transport.basisConnId, - params.resumeConfirmReqId - ) - } - return result + this.assertRelayHost(context, broker) + const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint } + if (params.installReqId) { + result.installStatus = await broker.credentialInstallStatus( + context.deviceId, + params.installReqId + ) + } + if (params.resumeConfirmReqId) { + if ( + context.transport.transport !== 'relay' || + context.transport.credentialKind !== 'resume' + ) { + throw new Error('resume_confirmation_unavailable') + } + result.resumeConfirmation = await broker.confirmResume( + context.transport.basisConnId, + params.resumeConfirmReqId + ) + } + return result + }) } async provisionRelay( @@ -151,30 +169,42 @@ export class DesktopRelayService { params: PairingProvisionRelayParams ): Promise { this.requireMobileDevice(context.deviceId) - const broker = this.coordinator.getActiveBroker() - if (!(broker instanceof RelaySessionBroker) || !broker.endpoint) { - throw new Error('relay_control_not_active') - } - this.assertRelayHost(context, broker) - const authorization = pairingAuthorizationForContext(context, broker.hostId) - if (!authorization) { - // Why: a resume splice proves renewal through confirmation; it cannot be - // repurposed as either of the two initial-install authorization modes. - throw new Error('relay_provision_authorization_unavailable') - } - if ( - !this.runtimeRpc.setMobileRelayBinding(context.deviceId, { - relayHostId: broker.hostId, - relayDeviceId: context.deviceId, - ownerIdentityKey: broker.ownerIdentityKey - }) - ) { - throw new Error('mobile_device_not_found') - } - return await broker.installCredential(context.deviceId, params, authorization) + return await this.withTransientDemand(`provision:${context.deviceId}`, async () => { + const broker = await this.requireActiveBroker() + if (!broker.endpoint) { + throw new Error('relay_control_not_active') + } + this.assertRelayHost(context, broker) + const authorization = pairingAuthorizationForContext(context, broker.hostId) + if (!authorization) { + // Why: a resume splice proves renewal through confirmation; it cannot be + // repurposed as either of the two initial-install authorization modes. + throw new Error('relay_provision_authorization_unavailable') + } + if ( + !this.runtimeRpc.setMobileRelayBinding(context.deviceId, { + relayHostId: broker.hostId, + relayDeviceId: context.deviceId, + ownerIdentityKey: broker.ownerIdentityKey + }) + ) { + throw new Error('mobile_device_not_found') + } + this.refreshDemand() + return await broker.installCredential(context.deviceId, params, authorization) + }) + } + + demandStateChanged(): void { + this.refreshDemand() } stop(): void { + this.stopped = true + if (this.demandExpiryTimer) { + clearTimeout(this.demandExpiryTimer) + this.demandExpiryTimer = null + } this.coordinator.stop() } @@ -209,9 +239,55 @@ export class DesktopRelayService { try { await broker.revokeDevice(item.relayDeviceId, item.reqId) this.revokeOutbox.remove(item.reqId) + this.refreshDemand() } catch { // Why: the durable item is the source of truth; reconnecting the same // account/control retries this stable reqId without delaying local revoke. } } + + private async withTransientDemand(key: string, operation: () => Promise): Promise { + const release = this.demandLedger.acquireTransient(key) + this.refreshDemand() + try { + return await operation() + } finally { + release() + this.refreshDemand() + } + } + + private async activeBrokerForDemand(): Promise { + const broker = + this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker()) + return broker instanceof RelaySessionBroker ? broker : null + } + + private async requireActiveBroker(): Promise { + const broker = await this.activeBrokerForDemand() + if (!broker) { + throw new Error('relay_control_not_active') + } + return broker + } + + private refreshDemand(): void { + if (this.stopped) { + return + } + if (this.demandExpiryTimer) { + clearTimeout(this.demandExpiryTimer) + this.demandExpiryTimer = null + } + this.coordinator.reconcile() + const expiresAt = this.demandLedger.nextPendingExpiry() + if (expiresAt !== null) { + // Why: an unscanned QR must stop holding a standing control when its + // server invite expires, even if no renderer survives to report closure. + this.demandExpiryTimer = setTimeout( + () => this.refreshDemand(), + Math.max(1, expiresAt - Date.now() + 1) + ) + } + } } diff --git a/src/main/runtime/relay/relay-auth-coordinator.test.ts b/src/main/runtime/relay/relay-auth-coordinator.test.ts index 3160202eeac..9911d837e7e 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.test.ts @@ -16,6 +16,82 @@ const context: RelayAuthContext = { } describe('RelayAuthCoordinator', () => { + it('stays signed-in but does not open a broker without relay demand', async () => { + const openBroker = vi.fn() + const statuses: string[] = [] + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + hasDemand: () => false, + openBroker, + onStatus: (status) => statuses.push(status) + }) + coordinator.reconcile() + await coordinator.waitForActiveBroker() + expect(openBroker).not.toHaveBeenCalled() + expect(statuses.at(-1)).toBe('standby') + }) + + it('opens on demand and lingers before closing the last control', async () => { + let demanded = false + const broker = { closeNow: vi.fn() } + const statuses: string[] = [] + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + hasDemand: () => demanded, + openBroker: async () => broker, + onStatus: (status) => statuses.push(status), + lingerMs: 250 + }) + coordinator.reconcile() + await coordinator.waitForActiveBroker() + demanded = true + coordinator.reconcile() + await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + demanded = false + coordinator.reconcile() + await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby')) + expect(broker.closeNow).not.toHaveBeenCalled() + await vi.waitFor(() => expect(broker.closeNow).toHaveBeenCalledOnce()) + }) + + it('cancels linger when demand returns', async () => { + let demanded = true + const broker = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + hasDemand: () => demanded, + openBroker: async () => broker, + onStatus: vi.fn(), + lingerMs: 20 + }) + coordinator.reconcile() + await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + demanded = false + coordinator.reconcile() + demanded = true + coordinator.reconcile() + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(broker.closeNow).not.toHaveBeenCalled() + }) + + it('does not carry old-profile demand through an identity switch', async () => { + let current = context + const broker = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => current, + hasDemand: ({ identity }) => identity.profileId === 'profile-1', + openBroker: async () => broker, + onStatus: vi.fn(), + lingerMs: 10_000 + }) + coordinator.reconcile() + await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } } + coordinator.reconcile() + await coordinator.waitForActiveBroker() + expect(broker.closeNow).toHaveBeenCalledOnce() + }) + it('fences a session read that finishes after sign-out', async () => { const read = deferred() const openBroker = vi.fn() diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index 7d244afe76f..f197edcc2b1 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -18,12 +18,14 @@ export type CoordinatedRelayBroker = { type RelayAuthCoordinatorOptions = { readContext: () => Promise + hasDemand?: (context: RelayAuthContext) => boolean openBroker: (input: { context: RelayAuthContext isCurrent: () => boolean refreshAccessToken: () => Promise }) => Promise onStatus: (status: RelayBrokerStatus) => void + lingerMs?: number } type BrokerOwnership = { @@ -41,6 +43,8 @@ export class RelayAuthCoordinator { private authEpoch = 0 private ownership: BrokerOwnership | null = null private readonly pendingOwnerships = new Set() + private latestReconcile: Promise = Promise.resolve() + private lingerTimer: ReturnType | null = null private stopped = false constructor(options: RelayAuthCoordinatorOptions) { @@ -53,11 +57,14 @@ export class RelayAuthCoordinator { } const epoch = ++this.authEpoch this.invalidatePendingOwnerships() - void this.reconcileEpoch(epoch) + const reconcile = this.reconcileEpoch(epoch) + this.latestReconcile = reconcile + void reconcile } fenceAndCloseNow(): void { ++this.authEpoch + this.cancelLinger() this.invalidatePendingOwnerships() this.invalidateOwnership() this.options.onStatus('offline') @@ -67,6 +74,21 @@ export class RelayAuthCoordinator { return this.ownership?.valid ? this.ownership.broker : null } + async waitForActiveBroker(): Promise { + while (!this.stopped) { + const broker = this.getActiveBroker() + if (broker) { + return broker + } + const pending = this.latestReconcile + await pending + if (pending === this.latestReconcile) { + return this.getActiveBroker() + } + } + return null + } + stop(): void { this.stopped = true this.fenceAndCloseNow() @@ -79,12 +101,25 @@ export class RelayAuthCoordinator { return } if (!context || !context.relayEntitled) { + this.cancelLinger() this.invalidateOwnership() this.options.onStatus('offline') return } const nextIdentityKey = identityKey(context.identity) + if (!(this.options.hasDemand?.(context) ?? true)) { + if (this.ownership?.valid && this.ownership.identityKey !== nextIdentityKey) { + this.cancelLinger() + this.invalidateOwnership() + } else if (this.ownership?.valid) { + this.scheduleLinger(context, this.ownership) + } + this.options.onStatus('standby') + return + } + this.cancelLinger() if (this.ownership?.valid && this.ownership.identityKey === nextIdentityKey) { + this.options.onStatus('registered') return } this.invalidateOwnership() @@ -152,6 +187,31 @@ export class RelayAuthCoordinator { } } + private scheduleLinger(context: RelayAuthContext, ownership: BrokerOwnership): void { + if (this.lingerTimer) { + return + } + const lingerMs = this.options.lingerMs ?? 10 * 60_000 + this.lingerTimer = setTimeout(() => { + this.lingerTimer = null + if ( + this.ownership === ownership && + ownership.valid && + !(this.options.hasDemand?.(context) ?? true) + ) { + this.invalidateOwnership() + this.options.onStatus('standby') + } + }, lingerMs) + } + + private cancelLinger(): void { + if (this.lingerTimer) { + clearTimeout(this.lingerTimer) + this.lingerTimer = null + } + } + private invalidatePendingOwnerships(): void { for (const ownership of this.pendingOwnerships) { ownership.valid = false diff --git a/src/main/runtime/relay/relay-demand-ledger.test.ts b/src/main/runtime/relay/relay-demand-ledger.test.ts new file mode 100644 index 00000000000..b8227e7d42d --- /dev/null +++ b/src/main/runtime/relay/relay-demand-ledger.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { DeviceRegistry } from '../device-registry' +import { RelayDemandLedger } from './relay-demand-ledger' +import { RelayRevokeOutbox, type RelayDeviceBinding } from './relay-revoke-outbox' + +const ownerIdentityKey = 'user-1\0profile-1\0org-1' +const relayHostId = 'relay-host-1' + +function fixture(now: number) { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-relay-demand-')) + const deviceRegistry = new DeviceRegistry(userDataPath) + const revokeOutbox = new RelayRevokeOutbox(userDataPath) + const ledger = new RelayDemandLedger({ + deviceRegistry, + revokeOutbox, + relayHostId, + now: () => now + }) + return { userDataPath, deviceRegistry, revokeOutbox, ledger } +} + +function binding(relayDeviceId: string, inviteExpiresAt?: number): RelayDeviceBinding { + return { relayDeviceId, relayHostId, ownerIdentityKey, inviteExpiresAt } +} + +describe('RelayDemandLedger', () => { + it('reference-counts concurrent main-process work', () => { + const { ledger } = fixture(1_000) + const releaseFirst = ledger.acquireTransient('pairing:device-1') + const releaseSecond = ledger.acquireTransient('pairing:device-1') + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + releaseFirst() + releaseFirst() + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + releaseSecond() + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) + + it('holds pending QR demand only through invite expiry', () => { + const { userDataPath, deviceRegistry, ledger } = fixture(1_000) + const pending = deviceRegistry.addDevice('Pending phone') + deviceRegistry.setRelayBinding(pending.deviceId, binding(pending.deviceId, 2_000)) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + expect(ledger.nextPendingExpiry()).toBe(2_000) + const restarted = new RelayDemandLedger({ + deviceRegistry: new DeviceRegistry(userDataPath), + revokeOutbox: new RelayRevokeOutbox(userDataPath), + relayHostId, + now: () => 1_500 + }) + expect(restarted.hasDemand(ownerIdentityKey)).toBe(true) + + const expiredFixture = fixture(3_000) + const expired = expiredFixture.deviceRegistry.addDevice('Expired phone') + expiredFixture.deviceRegistry.setRelayBinding( + expired.deviceId, + binding(expired.deviceId, 2_000) + ) + expect(expiredFixture.ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) + + it('does not promote a scanned invite to provisioned demand before install', () => { + const { deviceRegistry, ledger } = fixture(3_000) + const scanned = deviceRegistry.addDevice('Scanned phone') + deviceRegistry.setRelayBinding(scanned.deviceId, binding(scanned.deviceId, 2_000)) + deviceRegistry.updateLastSeen(scanned.deviceId) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) + + it('keeps provisioned devices and revoke outbox work authoritative', () => { + const { deviceRegistry, revokeOutbox, ledger } = fixture(5_000) + const paired = deviceRegistry.addDevice('Paired phone') + deviceRegistry.setRelayBinding(paired.deviceId, binding(paired.deviceId)) + deviceRegistry.updateLastSeen(paired.deviceId) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + + deviceRegistry.removeDevice(paired.deviceId) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + revokeOutbox.enqueue(binding(paired.deviceId)) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + }) + + it('does not activate another signed-in identity or relay host', () => { + const { deviceRegistry, ledger } = fixture(1_000) + const pending = deviceRegistry.addDevice('Other phone') + deviceRegistry.setRelayBinding(pending.deviceId, { + ...binding(pending.deviceId, 2_000), + ownerIdentityKey: 'other-user\0profile\0org' + }) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) +}) diff --git a/src/main/runtime/relay/relay-demand-ledger.ts b/src/main/runtime/relay/relay-demand-ledger.ts new file mode 100644 index 00000000000..8bcc1a6a2df --- /dev/null +++ b/src/main/runtime/relay/relay-demand-ledger.ts @@ -0,0 +1,72 @@ +import type { DeviceRegistry } from '../device-registry' +import type { RelayRevokeOutbox } from './relay-revoke-outbox' + +type RelayDemandLedgerOptions = { + deviceRegistry: DeviceRegistry + revokeOutbox: RelayRevokeOutbox + relayHostId: string + now?: () => number +} + +export class RelayDemandLedger { + private readonly options: RelayDemandLedgerOptions + private readonly transientRefs = new Map() + + constructor(options: RelayDemandLedgerOptions) { + this.options = options + } + + acquireTransient(key: string): () => void { + this.transientRefs.set(key, (this.transientRefs.get(key) ?? 0) + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const count = this.transientRefs.get(key) ?? 0 + if (count <= 1) { + this.transientRefs.delete(key) + } else { + this.transientRefs.set(key, count - 1) + } + } + } + + hasDemand(ownerIdentityKey: string): boolean { + if (this.transientRefs.size > 0) { + return true + } + if (this.options.revokeOutbox.pendingFor(ownerIdentityKey, this.options.relayHostId).length) { + return true + } + const now = (this.options.now ?? Date.now)() + return this.options.deviceRegistry.listDevices().some((device) => { + const binding = device.relayBinding + if ( + device.scope !== 'mobile' || + !binding || + binding.ownerIdentityKey !== ownerIdentityKey || + binding.relayHostId !== this.options.relayHostId + ) { + return false + } + // Why: E2EE authentication marks a scanned DeviceEntry as seen before + // relay credential install commits. Only removing the invite expiry at + // the durable install boundary promotes it to standing device demand. + return binding.inviteExpiresAt === undefined || binding.inviteExpiresAt > now + }) + } + + nextPendingExpiry(): number | null { + const now = (this.options.now ?? Date.now)() + let next: number | null = null + for (const device of this.options.deviceRegistry.listDevices()) { + const expiresAt = device.relayBinding?.inviteExpiresAt + if (expiresAt && expiresAt > now && (next === null || expiresAt < next)) { + next = expiresAt + } + } + return next + } +} diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index fbcdb2edc33..8a7e8cc19bb 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -7,6 +7,7 @@ export type RelayDeviceBinding = { relayHostId: string relayDeviceId: string ownerIdentityKey: string + inviteExpiresAt?: number } export type RelayRevokeOutboxItem = RelayDeviceBinding & { @@ -26,6 +27,8 @@ function isItem(value: unknown): value is RelayRevokeOutboxItem { typeof item.relayHostId === 'string' && typeof item.relayDeviceId === 'string' && typeof item.ownerIdentityKey === 'string' && + (item.inviteExpiresAt === undefined || + (typeof item.inviteExpiresAt === 'number' && Number.isFinite(item.inviteExpiresAt))) && typeof item.createdAt === 'number' && Number.isFinite(item.createdAt) ) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6aec53e908a..a04a35b2278 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -66,6 +66,7 @@ type MobileRelayPairingProvider = { relayDeviceId: string ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void + onDemandStateChanged?(): void getEndpoints( context: MobilePairingConnectionContext, params: PairingGetEndpointsParams @@ -526,7 +527,11 @@ export class OrcaRuntimeRpcServer { // strand the old cloud credential family even if that account is offline. this.queueRelayDeviceRevoke(current.relayBinding) } - return this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false + const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false + if (updated) { + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + } + return updated } setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { @@ -544,6 +549,7 @@ export class OrcaRuntimeRpcServer { if (!this.deviceRegistry?.removeDevice(deviceId)) { return false } + this.mobileRelayPairingProvider?.onDemandStateChanged?.() this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -632,6 +638,7 @@ export class OrcaRuntimeRpcServer { if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) { return direct } + this.mobileRelayPairingProvider.onDemandStateChanged?.() return { ...direct, pairingUrl: encodePairingOffer({ @@ -849,6 +856,7 @@ export class OrcaRuntimeRpcServer { ) }, onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), + onReady: () => this.mobileRelayPairingProvider?.onDemandStateChanged?.(), onClose: (socket, hasOtherConnections) => { if (!socket) { return diff --git a/src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx b/src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx index b054222f2c4..990bbc9f3ba 100644 --- a/src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx +++ b/src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx @@ -87,6 +87,8 @@ describe('MobileRelayStatusSection', () => { await waitFor(() => expect(screen.getByText('Registered')).toBeVisible()) statusListener?.('offline') await waitFor(() => expect(screen.getByText('Offline')).toBeVisible()) + statusListener?.('standby') + await waitFor(() => expect(screen.getByText('Standby — no relay devices')).toBeVisible()) expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument() }) }) diff --git a/src/renderer/src/components/settings/MobileRelayStatusSection.tsx b/src/renderer/src/components/settings/MobileRelayStatusSection.tsx index c73b2c74a51..b6612fc2497 100644 --- a/src/renderer/src/components/settings/MobileRelayStatusSection.tsx +++ b/src/renderer/src/components/settings/MobileRelayStatusSection.tsx @@ -13,6 +13,12 @@ function relayStatusLabel(status: MobileRelayStatus): string { if (status === 'connecting') { return translate('auto.components.settings.MobileRelayStatusSection.connecting', 'Connecting') } + if (status === 'standby') { + return translate( + 'auto.components.settings.MobileRelayStatusSection.standby', + 'Standby — no relay devices' + ) + } if (status === 'draining') { return translate( 'auto.components.settings.MobileRelayStatusSection.reconnecting', @@ -64,7 +70,7 @@ export function MobileRelayStatusSection(): React.JSX.Element { {signedIn ? translate( 'auto.components.settings.MobileRelayStatusSection.automatic', - 'Connect from anywhere automatically' + 'Connect from anywhere when a phone is paired' ) : translate( 'auto.components.settings.MobileRelayStatusSection.signInPrompt', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bf7813ca16c..d8ee065bcd0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9039,12 +9039,13 @@ "reconnecting": "Reconnecting", "offline": "Offline", "title": "Orca Relay", - "automatic": "Connect from anywhere automatically", + "automatic": "Connect from anywhere when a phone is paired", "signInPrompt": "Sign in on this desktop to connect from anywhere", "directStillAvailable": "LAN and Tailscale connections remain available.", "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", "signIn": "Sign in", - "unavailable": "Unavailable" + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e50e25cc57e..f060bd1960a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9039,12 +9039,13 @@ "reconnecting": "Reconnecting", "offline": "Offline", "title": "Orca Relay", - "automatic": "Connect from anywhere automatically", + "automatic": "Connect from anywhere when a phone is paired", "signInPrompt": "Sign in on this desktop to connect from anywhere", "directStillAvailable": "LAN and Tailscale connections remain available.", "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", "signIn": "Sign in", - "unavailable": "Unavailable" + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c012ab4f537..23a3a0cf831 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9039,12 +9039,13 @@ "reconnecting": "Reconnecting", "offline": "Offline", "title": "Orca Relay", - "automatic": "Connect from anywhere automatically", + "automatic": "Connect from anywhere when a phone is paired", "signInPrompt": "Sign in on this desktop to connect from anywhere", "directStillAvailable": "LAN and Tailscale connections remain available.", "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", "signIn": "Sign in", - "unavailable": "Unavailable" + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index afe6827de0d..0bd5ffbdc05 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9039,12 +9039,13 @@ "reconnecting": "Reconnecting", "offline": "Offline", "title": "Orca Relay", - "automatic": "Connect from anywhere automatically", + "automatic": "Connect from anywhere when a phone is paired", "signInPrompt": "Sign in on this desktop to connect from anywhere", "directStillAvailable": "LAN and Tailscale connections remain available.", "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", "signIn": "Sign in", - "unavailable": "Unavailable" + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 37d61d1af98..a8b49c4a722 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9039,12 +9039,13 @@ "reconnecting": "Reconnecting", "offline": "Offline", "title": "Orca Relay", - "automatic": "Connect from anywhere automatically", + "automatic": "Connect from anywhere when a phone is paired", "signInPrompt": "Sign in on this desktop to connect from anywhere", "directStillAvailable": "LAN and Tailscale connections remain available.", "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", "signIn": "Sign in", - "unavailable": "Unavailable" + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" } }, "right": { diff --git a/src/shared/mobile-relay-status.ts b/src/shared/mobile-relay-status.ts index 09a9a3222de..d3697dea48b 100644 --- a/src/shared/mobile-relay-status.ts +++ b/src/shared/mobile-relay-status.ts @@ -1,3 +1,9 @@ -export const MOBILE_RELAY_STATUSES = ['connecting', 'registered', 'draining', 'offline'] as const +export const MOBILE_RELAY_STATUSES = [ + 'connecting', + 'registered', + 'standby', + 'draining', + 'offline' +] as const export type MobileRelayStatus = (typeof MOBILE_RELAY_STATUSES)[number]