feat(relay): gate desktop controls on mobile demand

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 07:05:53 -04:00
co-authored by Orca
parent 3cb8b28be4
commit fa5ba67b06
18 changed files with 487 additions and 73 deletions
@@ -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')]
+1
View File
@@ -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)
})
+4 -1
View File
@@ -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
}
+134 -58
View File
@@ -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<typeof setTimeout> | 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<PairingGetEndpointsResult> {
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<DeviceCredentialInstalled> {
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<T>(key: string, operation: () => Promise<T>): Promise<T> {
const release = this.demandLedger.acquireTransient(key)
this.refreshDemand()
try {
return await operation()
} finally {
release()
this.refreshDemand()
}
}
private async activeBrokerForDemand(): Promise<RelaySessionBroker | null> {
const broker =
this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker())
return broker instanceof RelaySessionBroker ? broker : null
}
private async requireActiveBroker(): Promise<RelaySessionBroker> {
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)
)
}
}
}
@@ -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<RelayAuthContext | null>()
const openBroker = vi.fn()
@@ -18,12 +18,14 @@ export type CoordinatedRelayBroker = {
type RelayAuthCoordinatorOptions = {
readContext: () => Promise<RelayAuthContext | null>
hasDemand?: (context: RelayAuthContext) => boolean
openBroker: (input: {
context: RelayAuthContext
isCurrent: () => boolean
refreshAccessToken: () => Promise<string | null>
}) => Promise<CoordinatedRelayBroker>
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<BrokerOwnership>()
private latestReconcile: Promise<void> = Promise.resolve()
private lingerTimer: ReturnType<typeof setTimeout> | 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<CoordinatedRelayBroker | null> {
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
@@ -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)
})
})
@@ -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<string, number>()
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
}
}
@@ -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)
)
+9 -1
View File
@@ -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
@@ -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()
})
})
@@ -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',
+3 -2
View File
@@ -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": {
+3 -2
View File
@@ -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": {
+3 -2
View File
@@ -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": {
+3 -2
View File
@@ -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": {
+3 -2
View File
@@ -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": {
+7 -1
View File
@@ -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]