mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(relay): apply the host LAN pairing mode to already-paired devices
settings.mobilePairingConnectionMode was only consulted when minting the next QR. The live question — may this desktop serve mobile over Relay right now — had no implementation, so a device paired as `automatic` kept the desktop on Relay forever after the user picked LAN (#18211). - src/shared/mobile-relay-policy.ts: isMobileRelayAllowed composes the host setting with the per-device pair-time mode, restrictive-only. - RelayDemandLedger consults the policy for standing bindings and for in-flight transient refs (now keyed with a device id) so in-flight work cannot outvote the policy. - DesktopRelayService gates every grant path at withTransientDemand, which is what finally covers createPairingRelay; the host mode is pulled through an optional callback so existing prototype-built tests keep working. - RelayAuthCoordinator.reconcile({ skipLinger }) closes the broker promptly on a deliberate policy change; pairing churn keeps its ten-minute linger. - desktop-relay-startup.ts wires the settings read and the settings-changed wake signal; the launch file shrinks below the line budget. - The Relay-mint-failure "Use LAN" recovery buttons no longer persist the host policy, since revoking Relay from every paired phone is not what they promise. Closes #18211
This commit is contained in:
@@ -102,6 +102,74 @@ describe('liveness safety net lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('host LAN policy applies to already-paired devices (#18211)', () => {
|
||||
function serviceUnderPolicy(
|
||||
hostMode: () => 'automatic' | 'local-only',
|
||||
deviceMode: 'automatic' | 'local-only' = 'automatic'
|
||||
) {
|
||||
const registry = {
|
||||
getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }),
|
||||
getMobilePairingConnectionMode: () => deviceMode
|
||||
}
|
||||
const coordinator = { reconcile: vi.fn(), stop: vi.fn() }
|
||||
const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService
|
||||
Object.assign(service, {
|
||||
coordinator,
|
||||
demandLedger: { nextPendingExpiry: () => null, acquireTransient: () => () => {} },
|
||||
hostMobilePairingConnectionMode: hostMode,
|
||||
stopped: false,
|
||||
livenessTimer: null,
|
||||
demandExpiryTimer: null
|
||||
})
|
||||
Object.defineProperty(service, 'runtimeRpc', {
|
||||
value: { getDeviceRegistry: () => registry }
|
||||
})
|
||||
return { service, coordinator }
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('refuses every relay grant for an automatic device once the host picks LAN', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { service } = serviceUnderPolicy(() => 'local-only')
|
||||
await expect(service.getEndpoints(context({ transport: 'direct' }), {})).resolves.toEqual({
|
||||
v: 1,
|
||||
relay: null
|
||||
})
|
||||
await expect(
|
||||
service.provisionRelay(context({ transport: 'direct' }), {
|
||||
reqId: 'install-1',
|
||||
newResumeTokenHash: 'A'.repeat(43)
|
||||
})
|
||||
).rejects.toThrow('relay_disabled_for_device')
|
||||
// Why: createPairingRelay had no per-device gate at all; the choke point
|
||||
// in withTransientDemand is what covers it.
|
||||
await expect(service.createPairingRelay('device-1')).rejects.toThrow(
|
||||
'relay_disabled_for_device'
|
||||
)
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('never grants Relay to a local-only device even when the host allows it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { service } = serviceUnderPolicy(() => 'automatic', 'local-only')
|
||||
await expect(service.createPairingRelay('device-1')).rejects.toThrow(
|
||||
'relay_disabled_for_device'
|
||||
)
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('pairingPolicyChanged reconciles without the pairing-churn linger', () => {
|
||||
vi.useFakeTimers()
|
||||
const { service, coordinator } = serviceUnderPolicy(() => 'local-only')
|
||||
service.pairingPolicyChanged()
|
||||
expect(coordinator.reconcile).toHaveBeenLastCalledWith({ skipLinger: true })
|
||||
service.demandStateChanged()
|
||||
expect(coordinator.reconcile).toHaveBeenLastCalledWith(undefined)
|
||||
service.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('local-only mobile pairing', () => {
|
||||
it('refuses endpoint discovery and provisioning without opening Relay demand', async () => {
|
||||
const registry = {
|
||||
|
||||
@@ -21,6 +21,9 @@ import { deriveRelayHostId } from './relay-http-client'
|
||||
import { RelayDemandLedger } from './relay-demand-ledger'
|
||||
import { createRelayRegionPreferenceReader } from './relay-region-preference'
|
||||
import { pairingAuthorizationForContext } from './relay-pairing-authorization'
|
||||
import { buildPairingEndpointsResult } from './relay-pairing-endpoints-result'
|
||||
import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode'
|
||||
import { isMobileRelayAllowed } from '../../../shared/mobile-relay-policy'
|
||||
|
||||
export { pairingAuthorizationForContext } from './relay-pairing-authorization'
|
||||
|
||||
@@ -30,6 +33,9 @@ type DesktopRelayServiceOptions = {
|
||||
appVersion: string
|
||||
runtimeRpc: OrcaRuntimeRpcServer
|
||||
onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void
|
||||
// Live host policy (settings.mobilePairingConnectionMode); read on every
|
||||
// demand decision so a LAN pick applies to already-paired devices.
|
||||
hostMobilePairingConnectionMode?: () => MobilePairingConnectionMode
|
||||
}
|
||||
|
||||
// Why: a broker that died without arming a retry (sleep past token expiry,
|
||||
@@ -42,6 +48,7 @@ export class DesktopRelayService {
|
||||
private readonly revokeOutbox: RelayRevokeOutbox
|
||||
private readonly runtimeRpc: OrcaRuntimeRpcServer
|
||||
private readonly demandLedger: RelayDemandLedger
|
||||
private readonly hostMobilePairingConnectionMode?: () => MobilePairingConnectionMode
|
||||
private demandExpiryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private livenessTimer: ReturnType<typeof setInterval> | null = null
|
||||
private stopped = false
|
||||
@@ -54,10 +61,12 @@ export class DesktopRelayService {
|
||||
}
|
||||
this.runtimeRpc = options.runtimeRpc
|
||||
this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox()
|
||||
this.hostMobilePairingConnectionMode = options.hostMobilePairingConnectionMode
|
||||
this.demandLedger = new RelayDemandLedger({
|
||||
deviceRegistry: options.runtimeRpc.getDeviceRegistry()!,
|
||||
revokeOutbox: this.revokeOutbox,
|
||||
relayHostId: deriveRelayHostId(keypair.publicKey)
|
||||
relayHostId: deriveRelayHostId(keypair.publicKey),
|
||||
isRelayAllowedForDevice: (deviceId) => this.isRelayAllowedForDevice(deviceId)
|
||||
})
|
||||
const regionPreference = createRelayRegionPreferenceReader(options)
|
||||
this.coordinator = new RelayAuthCoordinator({
|
||||
@@ -116,7 +125,7 @@ export class DesktopRelayService {
|
||||
async createPairingRelay(
|
||||
relayDeviceId: string
|
||||
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> {
|
||||
return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => {
|
||||
return await this.withTransientDemand('pairing', relayDeviceId, async () => {
|
||||
const broker = await this.requireActiveBroker()
|
||||
const relay = await broker.createPairingRelay(relayDeviceId)
|
||||
return {
|
||||
@@ -148,38 +157,21 @@ export class DesktopRelayService {
|
||||
params: PairingGetEndpointsParams
|
||||
): Promise<PairingGetEndpointsResult> {
|
||||
this.requireMobileDevice(context.deviceId)
|
||||
if (
|
||||
this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) ===
|
||||
'local-only'
|
||||
) {
|
||||
if (!this.isRelayAllowedForDevice(context.deviceId)) {
|
||||
return { v: 1, relay: null }
|
||||
}
|
||||
return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => {
|
||||
return await this.withTransientDemand('endpoints', context.deviceId, async () => {
|
||||
const broker = await this.activeBrokerForDemand()
|
||||
if (!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')
|
||||
}
|
||||
result.resumeConfirmation = await broker.confirmResume(
|
||||
context.transport.basisConnId,
|
||||
params.resumeConfirmReqId
|
||||
)
|
||||
}
|
||||
return result
|
||||
return await buildPairingEndpointsResult({
|
||||
broker,
|
||||
endpoint: broker.endpoint,
|
||||
context,
|
||||
params
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,13 +180,10 @@ export class DesktopRelayService {
|
||||
params: PairingProvisionRelayParams
|
||||
): Promise<DeviceCredentialInstalled> {
|
||||
this.requireMobileDevice(context.deviceId)
|
||||
if (
|
||||
this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) ===
|
||||
'local-only'
|
||||
) {
|
||||
if (!this.isRelayAllowedForDevice(context.deviceId)) {
|
||||
throw new Error('relay_disabled_for_device')
|
||||
}
|
||||
return await this.withTransientDemand(`provision:${context.deviceId}`, async () => {
|
||||
return await this.withTransientDemand('provision', context.deviceId, async () => {
|
||||
const broker = await this.requireActiveBroker()
|
||||
if (!broker.endpoint) {
|
||||
throw new Error('relay_control_not_active')
|
||||
@@ -224,6 +213,13 @@ export class DesktopRelayService {
|
||||
this.refreshDemand()
|
||||
}
|
||||
|
||||
// Wake signal for a host connection-mode change; the decision is the pull in
|
||||
// isRelayAllowedForDevice. Skips the pairing-churn linger: a deliberate LAN
|
||||
// pick that kept Relay open for ten more minutes would look like #18211.
|
||||
pairingPolicyChanged(): void {
|
||||
this.refreshDemand({ skipLinger: true })
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
if (this.demandExpiryTimer) {
|
||||
@@ -275,8 +271,27 @@ export class DesktopRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
private async withTransientDemand<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const release = this.demandLedger.acquireTransient(key)
|
||||
// Restrictive-only: the host setting withdraws Relay from an `automatic`
|
||||
// device, never grants it to a `local-only` one.
|
||||
private isRelayAllowedForDevice(deviceId: string): boolean {
|
||||
return isMobileRelayAllowed({
|
||||
hostConnectionMode: this.hostMobilePairingConnectionMode?.() ?? 'automatic',
|
||||
deviceConnectionMode:
|
||||
this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(deviceId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
// Why the gate lives here: every path that can grant Relay — including
|
||||
// createPairingRelay, which had no per-device check — funnels through it.
|
||||
private async withTransientDemand<T>(
|
||||
kind: 'pairing' | 'endpoints' | 'provision',
|
||||
deviceId: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (!this.isRelayAllowedForDevice(deviceId)) {
|
||||
throw new Error('relay_disabled_for_device')
|
||||
}
|
||||
const release = this.demandLedger.acquireTransient(`${kind}:${deviceId}`, deviceId)
|
||||
this.refreshDemand()
|
||||
try {
|
||||
return await operation()
|
||||
@@ -302,7 +317,7 @@ export class DesktopRelayService {
|
||||
return result.broker
|
||||
}
|
||||
|
||||
private refreshDemand(): void {
|
||||
private refreshDemand(options?: { skipLinger?: boolean }): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
@@ -313,7 +328,7 @@ export class DesktopRelayService {
|
||||
clearTimeout(this.demandExpiryTimer)
|
||||
this.demandExpiryTimer = null
|
||||
}
|
||||
this.coordinator.reconcile()
|
||||
this.coordinator.reconcile(options)
|
||||
const expiresAt = this.demandLedger.nextPendingExpiry()
|
||||
if (expiresAt !== null) {
|
||||
// Why: an unscanned QR must stop holding a standing control when its
|
||||
|
||||
@@ -39,3 +39,7 @@ export type RelayAuthCoordinatorOptions = {
|
||||
export type LiveBrokerWaitResult =
|
||||
| { broker: CoordinatedRelayBroker }
|
||||
| { broker: null; offlineReason: RelayOfflineReason | null }
|
||||
|
||||
// skipLinger: a deliberate policy change is not pairing churn, so a broker
|
||||
// that lost demand closes now instead of holding the ten-minute linger.
|
||||
export type RelayReconcileOptions = { skipLinger?: boolean }
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
LiveBrokerWaitResult,
|
||||
RelayAuthContext,
|
||||
RelayAuthCoordinatorOptions,
|
||||
RelayAuthIdentity
|
||||
RelayAuthIdentity,
|
||||
RelayReconcileOptions as ReconcileOptions
|
||||
} from './relay-auth-coordinator-contract'
|
||||
|
||||
export type {
|
||||
@@ -64,11 +65,15 @@ export class RelayAuthCoordinator {
|
||||
this.retry = new RelayRetrySchedule(options.random)
|
||||
}
|
||||
|
||||
reconcile(): void {
|
||||
this.beginReconcile(true)
|
||||
reconcile(options?: ReconcileOptions): void {
|
||||
this.beginReconcile(true, undefined, options)
|
||||
}
|
||||
|
||||
private beginReconcile(resetRetry: boolean, expectedIdentityKey?: string): void {
|
||||
private beginReconcile(
|
||||
resetRetry: boolean,
|
||||
expectedIdentityKey?: string,
|
||||
options?: ReconcileOptions
|
||||
): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
@@ -78,7 +83,7 @@ export class RelayAuthCoordinator {
|
||||
}
|
||||
const epoch = ++this.authEpoch
|
||||
this.invalidatePendingOwnerships()
|
||||
const reconcile = this.reconcileEpoch(epoch, expectedIdentityKey)
|
||||
const reconcile = this.reconcileEpoch(epoch, expectedIdentityKey, options)
|
||||
this.latestReconcile = reconcile
|
||||
void reconcile
|
||||
}
|
||||
@@ -184,7 +189,11 @@ export class RelayAuthCoordinator {
|
||||
this.fenceAndCloseNow()
|
||||
}
|
||||
|
||||
private async reconcileEpoch(epoch: number, expectedIdentityKey?: string): Promise<void> {
|
||||
private async reconcileEpoch(
|
||||
epoch: number,
|
||||
expectedIdentityKey?: string,
|
||||
options?: ReconcileOptions
|
||||
): Promise<void> {
|
||||
let retryIdentityKey: string | undefined
|
||||
try {
|
||||
const context = await this.options.readContext()
|
||||
@@ -210,7 +219,10 @@ export class RelayAuthCoordinator {
|
||||
}
|
||||
if (!(this.options.hasDemand?.(context) ?? true)) {
|
||||
this.retry.reset()
|
||||
if (this.ownership?.valid && this.ownership.identityKey !== nextIdentityKey) {
|
||||
if (
|
||||
this.ownership?.valid &&
|
||||
(options?.skipLinger || this.ownership.identityKey !== nextIdentityKey)
|
||||
) {
|
||||
this.cancelLinger()
|
||||
this.invalidateOwnership()
|
||||
} else if (this.ownership?.valid) {
|
||||
|
||||
@@ -29,8 +29,8 @@ function binding(relayDeviceId: string, inviteExpiresAt?: number): RelayDeviceBi
|
||||
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')
|
||||
const releaseFirst = ledger.acquireTransient('pairing:device-1', 'device-1')
|
||||
const releaseSecond = ledger.acquireTransient('pairing:device-1', 'device-1')
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
releaseFirst()
|
||||
releaseFirst()
|
||||
|
||||
@@ -5,37 +5,52 @@ type RelayDemandLedgerOptions = {
|
||||
deviceRegistry: DeviceRegistry
|
||||
revokeOutbox: RelayRevokeOutbox
|
||||
relayHostId: string
|
||||
// Why: the host-level pairing mode is a live policy, not a pair-time record;
|
||||
// a device it excludes contributes no demand even with a standing binding.
|
||||
isRelayAllowedForDevice?: (deviceId: string) => boolean
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
type TransientRef = { deviceId: string; count: number }
|
||||
|
||||
export class RelayDemandLedger {
|
||||
private readonly options: RelayDemandLedgerOptions
|
||||
private readonly transientRefs = new Map<string, number>()
|
||||
private readonly transientRefs = new Map<string, TransientRef>()
|
||||
|
||||
constructor(options: RelayDemandLedgerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
acquireTransient(key: string): () => void {
|
||||
this.transientRefs.set(key, (this.transientRefs.get(key) ?? 0) + 1)
|
||||
acquireTransient(key: string, deviceId: string): () => void {
|
||||
const ref = this.transientRefs.get(key)
|
||||
if (ref) {
|
||||
ref.count += 1
|
||||
} else {
|
||||
this.transientRefs.set(key, { deviceId, count: 1 })
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
const count = this.transientRefs.get(key) ?? 0
|
||||
if (count <= 1) {
|
||||
const current = this.transientRefs.get(key)
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
if (current.count <= 1) {
|
||||
this.transientRefs.delete(key)
|
||||
} else {
|
||||
this.transientRefs.set(key, count - 1)
|
||||
current.count -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasDemand(ownerIdentityKey: string): boolean {
|
||||
if (this.transientRefs.size > 0) {
|
||||
return true
|
||||
for (const ref of this.transientRefs.values()) {
|
||||
if (this.isRelayAllowed(ref.deviceId)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (this.options.revokeOutbox.pendingFor(ownerIdentityKey, this.options.relayHostId).length) {
|
||||
return true
|
||||
@@ -47,7 +62,8 @@ export class RelayDemandLedger {
|
||||
device.scope !== 'mobile' ||
|
||||
!binding ||
|
||||
binding.ownerIdentityKey !== ownerIdentityKey ||
|
||||
binding.relayHostId !== this.options.relayHostId
|
||||
binding.relayHostId !== this.options.relayHostId ||
|
||||
!this.isRelayAllowed(device.deviceId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -69,4 +85,8 @@ export class RelayDemandLedger {
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private isRelayAllowed(deviceId: string): boolean {
|
||||
return this.options.isRelayAllowedForDevice?.(deviceId) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MobilePairingConnectionContext } from '../runtime-rpc'
|
||||
import type {
|
||||
MobileRelayEndpoint,
|
||||
PairingGetEndpointsParams,
|
||||
PairingGetEndpointsResult
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import type { RelaySessionBroker } from './relay-session-broker'
|
||||
|
||||
// Assembles the endpoint answer for a device the host is willing to serve
|
||||
// over Relay; the caller owns the policy gate and the host/broker checks.
|
||||
export async function buildPairingEndpointsResult(args: {
|
||||
broker: RelaySessionBroker
|
||||
endpoint: MobileRelayEndpoint
|
||||
context: MobilePairingConnectionContext
|
||||
params: PairingGetEndpointsParams
|
||||
}): Promise<PairingGetEndpointsResult> {
|
||||
const { broker, context, params } = args
|
||||
const result: PairingGetEndpointsResult = { v: 1, relay: args.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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DeviceRegistry } from '../device-registry'
|
||||
import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode'
|
||||
import { RelayAuthCoordinator, type RelayAuthContext } from './relay-auth-coordinator'
|
||||
import { RelayDemandLedger } from './relay-demand-ledger'
|
||||
import { RelayRevokeOutbox } from './relay-revoke-outbox'
|
||||
|
||||
const ownerIdentityKey = 'user-1\0profile-1\0org-1'
|
||||
const relayHostId = 'relay-host-1'
|
||||
const context: RelayAuthContext = {
|
||||
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
|
||||
accessToken: 'access-1',
|
||||
relayEntitled: true
|
||||
}
|
||||
|
||||
function liveBroker() {
|
||||
return {
|
||||
closeNow: vi.fn(),
|
||||
isLive: () => true,
|
||||
endpoint: { cellUrl: 'https://c1.relay.example.test' }
|
||||
}
|
||||
}
|
||||
|
||||
// #18211: the host-level pairing connection mode must govern whether an
|
||||
// already-paired `automatic` device may keep the desktop on Relay.
|
||||
describe('#18211 LAN mode applies to already-paired devices', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('a standing relay binding stops being demand once the host picks LAN', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-repro-18211-'))
|
||||
const deviceRegistry = new DeviceRegistry(userDataPath)
|
||||
const revokeOutbox = new RelayRevokeOutbox(userDataPath)
|
||||
const phone = deviceRegistry.addDevice('Phone')
|
||||
deviceRegistry.setMobilePairingConnectionMode(phone.deviceId, 'automatic')
|
||||
deviceRegistry.setRelayBinding(phone.deviceId, {
|
||||
relayHostId,
|
||||
relayDeviceId: phone.deviceId,
|
||||
ownerIdentityKey
|
||||
})
|
||||
|
||||
let hostMode: MobilePairingConnectionMode = 'automatic'
|
||||
const ledger = new RelayDemandLedger({
|
||||
deviceRegistry,
|
||||
revokeOutbox,
|
||||
relayHostId,
|
||||
isRelayAllowedForDevice: () => hostMode !== 'local-only'
|
||||
})
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
|
||||
hostMode = 'local-only'
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
|
||||
// In-flight work for a device the policy now excludes cannot outvote it.
|
||||
const release = ledger.acquireTransient(`endpoints:${phone.deviceId}`, phone.deviceId)
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
release()
|
||||
|
||||
hostMode = 'automatic'
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('a policy change withdraws the open relay session promptly instead of lingering', async () => {
|
||||
vi.useFakeTimers()
|
||||
let demanded = true
|
||||
const broker = liveBroker()
|
||||
const statuses: string[] = []
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
hasDemand: () => demanded,
|
||||
openBroker: async () => broker,
|
||||
onStatus: (status) => statuses.push(status)
|
||||
})
|
||||
coordinator.reconcile()
|
||||
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
|
||||
|
||||
demanded = false
|
||||
coordinator.reconcile({ skipLinger: true })
|
||||
await coordinator.waitForLiveBroker()
|
||||
expect(statuses.at(-1)).toBe('standby')
|
||||
expect(broker.closeNow).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('pairing churn keeps the ten-minute linger', async () => {
|
||||
vi.useFakeTimers()
|
||||
let demanded = true
|
||||
const broker = liveBroker()
|
||||
const statuses: string[] = []
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
hasDemand: () => demanded,
|
||||
openBroker: async () => broker,
|
||||
onStatus: (status) => statuses.push(status)
|
||||
})
|
||||
coordinator.reconcile()
|
||||
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
|
||||
|
||||
demanded = false
|
||||
coordinator.reconcile()
|
||||
await coordinator.waitForLiveBroker()
|
||||
expect(statuses.at(-1)).toBe('standby')
|
||||
expect(broker.closeNow).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 - 1)
|
||||
expect(broker.closeNow).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(broker.closeNow).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fakes = vi.hoisted(() => ({
|
||||
configured: true,
|
||||
serviceOptions: null as null | { hostMobilePairingConnectionMode?: () => string },
|
||||
service: {
|
||||
start: vi.fn(),
|
||||
pairingPolicyChanged: vi.fn(),
|
||||
createPairingRelay: vi.fn(),
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
demandStateChanged: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn(),
|
||||
ensureLive: vi.fn()
|
||||
},
|
||||
settingsListeners: [] as ((updates: Record<string, unknown>) => void)[],
|
||||
settings: { mobilePairingConnectionMode: undefined as string | undefined }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getVersion: () => '0.0.0-test' },
|
||||
powerMonitor: { on: vi.fn() }
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-cloud-auth-config', () => ({
|
||||
getOrcaCloudAuthConfig: () => ({ configured: fakes.configured, config: {} })
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-storage-paths', () => ({
|
||||
getProfileUserDataPath: () => '/tmp/orca-test'
|
||||
}))
|
||||
vi.mock('../runtime/relay/desktop-relay-service', () => ({
|
||||
DesktopRelayService: class {
|
||||
constructor(options: typeof fakes.serviceOptions) {
|
||||
fakes.serviceOptions = options
|
||||
return fakes.service
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./main-process-state', () => ({
|
||||
mainProcessState: {
|
||||
store: {
|
||||
getSettings: () => fakes.settings,
|
||||
onSettingsChanged: (listener: (updates: Record<string, unknown>) => void) => {
|
||||
fakes.settingsListeners.push(listener)
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
desktopRelayService: null,
|
||||
mainWindow: null
|
||||
}
|
||||
}))
|
||||
|
||||
import { startDesktopRelayService } from './desktop-relay-startup'
|
||||
import { mainProcessState } from './main-process-state'
|
||||
|
||||
describe('startDesktopRelayService (#18211 host policy wiring)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fakes.configured = true
|
||||
fakes.serviceOptions = null
|
||||
fakes.settingsListeners.length = 0
|
||||
fakes.settings.mobilePairingConnectionMode = undefined
|
||||
mainProcessState.desktopRelayService = null
|
||||
startDesktopRelayService({ setMobileRelayPairingProvider: vi.fn() } as never)
|
||||
})
|
||||
|
||||
it('reads the live host pairing mode from settings, defaulting to automatic', () => {
|
||||
const read = fakes.serviceOptions?.hostMobilePairingConnectionMode
|
||||
expect(read?.()).toBe('automatic')
|
||||
fakes.settings.mobilePairingConnectionMode = 'local-only'
|
||||
expect(read?.()).toBe('local-only')
|
||||
})
|
||||
|
||||
it('wakes the relay service only when the pairing mode setting changes', () => {
|
||||
expect(fakes.settingsListeners).toHaveLength(1)
|
||||
fakes.settingsListeners[0]!({ theme: 'dark' })
|
||||
expect(fakes.service.pairingPolicyChanged).not.toHaveBeenCalled()
|
||||
fakes.settingsListeners[0]!({ mobilePairingConnectionMode: 'local-only' })
|
||||
expect(fakes.service.pairingPolicyChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { app, powerMonitor } from 'electron'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
|
||||
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
|
||||
// Desktop-mode relay bring-up; a no-op when cloud auth is not configured.
|
||||
export function startDesktopRelayService(runtimeRpc: OrcaRuntimeRpcServer): void {
|
||||
const cloudAuth = getOrcaCloudAuthConfig()
|
||||
if (!cloudAuth.configured) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const relayService = new DesktopRelayService({
|
||||
authConfig: cloudAuth.config,
|
||||
userDataPath: getProfileUserDataPath(),
|
||||
appVersion: app.getVersion(),
|
||||
runtimeRpc,
|
||||
hostMobilePairingConnectionMode: () =>
|
||||
state.store?.getSettings().mobilePairingConnectionMode ?? 'automatic',
|
||||
onStatus: (status, cellUrl) => {
|
||||
state.desktopRelayStatus = status
|
||||
state.desktopRelayCellUrl = cellUrl
|
||||
state.mainWindow?.webContents.send('mobile:relayStatusChanged', {
|
||||
status,
|
||||
...(cellUrl === undefined ? {} : { cellUrl })
|
||||
} satisfies MobileRelayStatusDetail)
|
||||
}
|
||||
})
|
||||
state.desktopRelayService = relayService
|
||||
// Wake signal only; the store already filters no-op writes, and the
|
||||
// decision is pulled through hostMobilePairingConnectionMode.
|
||||
state.store?.onSettingsChanged((updates) => {
|
||||
if ('mobilePairingConnectionMode' in updates) {
|
||||
state.desktopRelayService?.pairingPolicyChanged()
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
relayService.start()
|
||||
// Why: sleeping past relay-token expiry kills the broker with no retry
|
||||
// timer; resume is the moment that state becomes recoverable.
|
||||
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[relay] Desktop relay startup unavailable:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ describe('initial proxy application ordering', () => {
|
||||
|
||||
const windowIndex = desktop.indexOf('openMainWindow()')
|
||||
const proxyIndex = desktop.indexOf('await state.initialProxyApplicationReady')
|
||||
const relayIndex = desktop.indexOf('new DesktopRelayService(')
|
||||
const relayIndex = desktop.indexOf('startDesktopRelayService(')
|
||||
|
||||
expect(windowIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(proxyIndex).toBeGreaterThan(windowIndex)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { app, powerMonitor, type BrowserWindow } from 'electron'
|
||||
import { app, type BrowserWindow } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import {
|
||||
getCanonicalUserDataPath,
|
||||
migrateMobilePairingDataToCanonicalUserDataPath
|
||||
@@ -13,8 +11,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
|
||||
import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
|
||||
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
|
||||
import { startDesktopRelayService } from './desktop-relay-startup'
|
||||
import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve'
|
||||
import {
|
||||
bindTerminalRuntimeStartupServices,
|
||||
@@ -245,42 +242,7 @@ async function launchDesktopMode(
|
||||
// fetcher until the persisted proxy lands, so this only has to keep the launch phase itself
|
||||
// ordered ahead of the relay — it must not gate the renderer.
|
||||
await state.initialProxyApplicationReady
|
||||
const cloudAuth = getOrcaCloudAuthConfig()
|
||||
if (cloudAuth.configured) {
|
||||
try {
|
||||
const relayService = new DesktopRelayService({
|
||||
authConfig: cloudAuth.config,
|
||||
userDataPath: getProfileUserDataPath(),
|
||||
appVersion: app.getVersion(),
|
||||
runtimeRpc,
|
||||
onStatus: (status, cellUrl) => {
|
||||
state.desktopRelayStatus = status
|
||||
state.desktopRelayCellUrl = cellUrl
|
||||
state.mainWindow?.webContents.send('mobile:relayStatusChanged', {
|
||||
status,
|
||||
...(cellUrl === undefined ? {} : { cellUrl })
|
||||
} satisfies MobileRelayStatusDetail)
|
||||
}
|
||||
})
|
||||
state.desktopRelayService = relayService
|
||||
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)
|
||||
})
|
||||
relayService.start()
|
||||
// Why: sleeping past relay-token expiry kills the broker with no retry
|
||||
// timer; resume is the moment that state becomes recoverable.
|
||||
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[relay] Desktop relay startup unavailable:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
startDesktopRelayService(runtimeRpc)
|
||||
// Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window.
|
||||
win.once('show', () => {
|
||||
// Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener.
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock('./MobilePageContent', () => ({
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
onRetryRelay: () => void
|
||||
onUseLan: () => void
|
||||
selectedAddress: string | undefined
|
||||
loadNetworkInterfaces: () => void
|
||||
openAndroidInstallGuide: () => void
|
||||
@@ -98,6 +99,9 @@ vi.mock('./MobilePageContent', () => ({
|
||||
<button type="button" onClick={props.onRetryRelay}>
|
||||
Retry Relay
|
||||
</button>
|
||||
<button type="button" onClick={props.onUseLan}>
|
||||
Use LAN
|
||||
</button>
|
||||
<button type="button" onClick={() => props.handleAddressChange('10.0.0.2')}>
|
||||
Change address
|
||||
</button>
|
||||
@@ -416,6 +420,37 @@ describe('MobilePage pairing connection mode', () => {
|
||||
expect(screen.getByTestId('relay-failure')).toHaveTextContent('none')
|
||||
})
|
||||
|
||||
it('does not persist host policy from the Relay-failure Use LAN recovery button', 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')
|
||||
)
|
||||
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: 'Use LAN' }))
|
||||
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only'))
|
||||
// Why: the persisted mode is host policy that withdraws Relay from every
|
||||
// paired phone; this button only promises a LAN QR (#18211).
|
||||
expect(mocks.storeState.updateSettings).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mobilePairingConnectionMode: 'local-only' })
|
||||
)
|
||||
})
|
||||
|
||||
it('switches to LAN while a Relay retry is still unresolved', async () => {
|
||||
getPairingQR.mockResolvedValueOnce({
|
||||
available: false,
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
}, [connectionMode, generatePairing, pairLoading, signedIn])
|
||||
|
||||
const handleConnectionModeChange = useCallback(
|
||||
(nextMode: MobilePairingConnectionMode): void => {
|
||||
(nextMode: MobilePairingConnectionMode, options?: { persist?: boolean }): void => {
|
||||
if (nextMode === connectionMode) {
|
||||
return
|
||||
}
|
||||
@@ -138,7 +138,12 @@ export default function MobilePage(): React.JSX.Element {
|
||||
// (below), which also covers cross-window preference syncs.
|
||||
setRelayMintFailure(null)
|
||||
setConnectionMode(nextMode)
|
||||
void updateSettings({ mobilePairingConnectionMode: nextMode })
|
||||
// Why: the persisted setting is host policy — it withdraws Relay from
|
||||
// every paired phone. A mint-failure recovery button only promises a
|
||||
// LAN QR, so it must not persist.
|
||||
if (options?.persist !== false) {
|
||||
void updateSettings({ mobilePairingConnectionMode: nextMode })
|
||||
}
|
||||
},
|
||||
[connectionMode, updateSettings, setConnectionMode]
|
||||
)
|
||||
@@ -345,7 +350,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
relayMintFailure={
|
||||
connectionMode === 'automatic' && pairQrDataUrl == null ? relayMintFailure : null
|
||||
}
|
||||
onUseLan={() => handleConnectionModeChange('local-only')}
|
||||
onUseLan={() => handleConnectionModeChange('local-only', { persist: false })}
|
||||
onRetryRelay={() => void generatePairing(true)}
|
||||
onCopyRelayDiagnostics={() => void copyRelayDiagnostics()}
|
||||
platform={platform}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
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'
|
||||
@@ -164,6 +163,7 @@ vi.mock('../mobile/WindowsFirewallNotice', () => ({
|
||||
}))
|
||||
|
||||
import { MobilePane } from './MobilePane'
|
||||
import { pairedDevice, renderMobilePane, unmountMobilePaneRoots } from './mobile-pane-test-mount'
|
||||
|
||||
describe('MobilePane pairing connection mode', () => {
|
||||
const getPairingQR = mocks.getPairingQR
|
||||
@@ -271,6 +271,11 @@ describe('MobilePane pairing connection mode', () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
|
||||
)
|
||||
// Why: the persisted mode is host policy that withdraws Relay from every
|
||||
// paired phone; a mint-recovery button only promises a LAN QR (#18211).
|
||||
expect(updateSettings).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mobilePairingConnectionMode: 'local-only' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show mint failure after an honest Relay mint', async () => {
|
||||
@@ -750,35 +755,6 @@ describe('MobilePane pairing connection mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
function pairedDevice(deviceId: string): PairedDevice {
|
||||
return {
|
||||
deviceId,
|
||||
name: deviceId,
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 2
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMobilePane(): Promise<void> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(<MobilePane />)
|
||||
})
|
||||
}
|
||||
|
||||
async function unmountMobilePaneRoots(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobilePane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useAppStore } from '../../store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import {
|
||||
getPairedMobileDevicesSnapshot,
|
||||
replacePairedMobileDevices,
|
||||
usePairedMobileDevices
|
||||
} from '../mobile/paired-mobile-devices'
|
||||
import { revokePairedMobileDevice } from './mobile-pane-device-revoke'
|
||||
import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling'
|
||||
import type { MobileNetworkInterface } from './mobile-network-interface-selection'
|
||||
import { MobilePairingQrSection } from './MobilePairingQrSection'
|
||||
@@ -263,7 +263,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
)
|
||||
|
||||
const changeConnectionMode = useCallback(
|
||||
(nextMode: MobilePairingConnectionMode) => {
|
||||
(nextMode: MobilePairingConnectionMode, options?: { persist?: boolean }) => {
|
||||
if (nextMode === connectionMode) {
|
||||
return
|
||||
}
|
||||
@@ -271,7 +271,12 @@ export function MobilePane(): React.JSX.Element {
|
||||
// instead of snapping back to the default.
|
||||
handledModeRef.current = nextMode
|
||||
setConnectionMode(nextMode)
|
||||
void updateSettings({ mobilePairingConnectionMode: nextMode })
|
||||
// Why: the persisted setting is host policy — it withdraws Relay from
|
||||
// every paired phone. A mint-failure recovery button only promises a
|
||||
// LAN QR, so it must not persist.
|
||||
if (options?.persist !== false) {
|
||||
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'
|
||||
@@ -355,34 +360,12 @@ export function MobilePane(): React.JSX.Element {
|
||||
loadDevices
|
||||
})
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
try {
|
||||
const { revoked } = await window.api.mobile.revokeDevice({ deviceId })
|
||||
// Why: the backend can resolve revoked=false without removing the device;
|
||||
// surface that as an error instead of a false "Device revoked".
|
||||
if (!revoked) {
|
||||
throw new Error('mobile.revokeDevice returned revoked=false')
|
||||
}
|
||||
try {
|
||||
// Why: the backend may have learned about another phone while Settings
|
||||
// was open, so refresh from source-of-truth after mutating it.
|
||||
await refreshDevices({ force: true })
|
||||
} catch (err) {
|
||||
console.error('mobile.listDevices failed after revoke', err)
|
||||
const nextDevices = getPairedMobileDevicesSnapshot().filter((d) => d.deviceId !== deviceId)
|
||||
replacePairedMobileDevices(nextDevices)
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
toast.success(translate('auto.components.settings.MobilePane.2e3dd0bc29', 'Device revoked'))
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
translate('auto.components.settings.MobilePane.870e1b5ca5', 'Failed to revoke device')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const revokeDevice = (deviceId: string): Promise<void> =>
|
||||
revokePairedMobileDevice({
|
||||
deviceId,
|
||||
refreshDevices,
|
||||
isMounted: () => mountedRef.current
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -418,7 +401,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
{relayMintFailure != null && connectionMode === 'automatic' ? (
|
||||
<MobileRelayMintFailureNotice
|
||||
failure={relayMintFailure}
|
||||
onUseLan={() => changeConnectionMode('local-only')}
|
||||
onUseLan={() => changeConnectionMode('local-only', { persist: false })}
|
||||
onRetry={() => void generateQR({ rotate: true })}
|
||||
onCopyDiagnostics={() => void copyRelayDiagnostics()}
|
||||
busy={loading}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getPairedMobileDevicesSnapshot,
|
||||
replacePairedMobileDevices
|
||||
} from '../mobile/paired-mobile-devices'
|
||||
|
||||
export async function revokePairedMobileDevice(args: {
|
||||
deviceId: string
|
||||
refreshDevices: (opts: { force: true }) => Promise<unknown>
|
||||
isMounted: () => boolean
|
||||
}): Promise<void> {
|
||||
const { deviceId } = args
|
||||
try {
|
||||
const { revoked } = await window.api.mobile.revokeDevice({ deviceId })
|
||||
// Why: the backend can resolve revoked=false without removing the device;
|
||||
// surface that as an error instead of a false "Device revoked".
|
||||
if (!revoked) {
|
||||
throw new Error('mobile.revokeDevice returned revoked=false')
|
||||
}
|
||||
try {
|
||||
// Why: the backend may have learned about another phone while Settings
|
||||
// was open, so refresh from source-of-truth after mutating it.
|
||||
await args.refreshDevices({ force: true })
|
||||
} catch (err) {
|
||||
console.error('mobile.listDevices failed after revoke', err)
|
||||
const nextDevices = getPairedMobileDevicesSnapshot().filter((d) => d.deviceId !== deviceId)
|
||||
replacePairedMobileDevices(nextDevices)
|
||||
}
|
||||
if (args.isMounted()) {
|
||||
toast.success(translate('auto.components.settings.MobilePane.2e3dd0bc29', 'Device revoked'))
|
||||
}
|
||||
} catch {
|
||||
if (args.isMounted()) {
|
||||
toast.error(
|
||||
translate('auto.components.settings.MobilePane.870e1b5ca5', 'Failed to revoke device')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { PairedMobileDevice } from '../mobile/paired-mobile-devices'
|
||||
import { MobilePane } from './MobilePane'
|
||||
|
||||
// Why not RTL render: these tests unmount mid-flight to prove a late resolve
|
||||
// cannot toast after unmount, which needs a root handle RTL does not expose.
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
export function pairedDevice(deviceId: string): PairedMobileDevice {
|
||||
return {
|
||||
deviceId,
|
||||
name: deviceId,
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 2
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMobilePane(): Promise<void> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(<MobilePane />)
|
||||
})
|
||||
}
|
||||
|
||||
export async function unmountMobilePaneRoots(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMobileRelayAllowed } from './mobile-relay-policy'
|
||||
|
||||
describe('isMobileRelayAllowed', () => {
|
||||
it('lets the host setting withdraw Relay from an automatic device', () => {
|
||||
expect(
|
||||
isMobileRelayAllowed({ hostConnectionMode: 'local-only', deviceConnectionMode: 'automatic' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
isMobileRelayAllowed({ hostConnectionMode: 'automatic', deviceConnectionMode: 'automatic' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never lets the host setting grant Relay to a local-only device', () => {
|
||||
expect(
|
||||
isMobileRelayAllowed({ hostConnectionMode: 'automatic', deviceConnectionMode: 'local-only' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unplaceable device as having no opinion', () => {
|
||||
expect(
|
||||
isMobileRelayAllowed({ hostConnectionMode: 'automatic', deviceConnectionMode: null })
|
||||
).toBe(true)
|
||||
expect(
|
||||
isMobileRelayAllowed({ hostConnectionMode: 'local-only', deviceConnectionMode: null })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MobilePairingConnectionMode } from './mobile-pairing-connection-mode'
|
||||
|
||||
/**
|
||||
* May this desktop serve a paired mobile device over Relay right now?
|
||||
*
|
||||
* Restrictive-only: the host-level setting withdraws Relay from an `automatic`
|
||||
* device but never grants it to one paired `local-only`. A device the registry
|
||||
* cannot place (`null`) contributes no opinion. The mint question (what the next
|
||||
* QR encodes) stays in `mobile-pairing-connection-mode.ts`.
|
||||
*/
|
||||
export function isMobileRelayAllowed(args: {
|
||||
hostConnectionMode: MobilePairingConnectionMode
|
||||
deviceConnectionMode: MobilePairingConnectionMode | null
|
||||
}): boolean {
|
||||
return args.hostConnectionMode !== 'local-only' && args.deviceConnectionMode !== 'local-only'
|
||||
}
|
||||
Reference in New Issue
Block a user