merge PR 20065 mobile-relay-status-unreadable

This commit is contained in:
Neil
2026-09-11 22:12:24 -07:00
4 changed files with 92 additions and 18 deletions
@@ -152,12 +152,12 @@ export default function MobilePage(): React.JSX.Element {
if (relayMintFailure == null) {
return
}
// Why: users share this payload — an address (selected or relay cell) would leak a LAN/Tailscale IP or hostname.
const payload = await collectMobileRelayDiagnosticsPayload({
connectionMode,
failure: relayMintFailure
})
try {
// Why: users share this payload — an address (selected or relay cell) would leak a LAN/Tailscale IP or hostname.
const payload = await collectMobileRelayDiagnosticsPayload({
connectionMode,
failure: relayMintFailure
})
await window.api.ui.writeClipboardText(JSON.stringify(payload, null, 2))
if (mountedRef.current) {
toast.success(
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildMobileRelayDiagnosticsPayload } from './mobile-relay-diagnostics-payload'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildMobileRelayDiagnosticsPayload,
collectMobileRelayDiagnosticsPayload
} from './mobile-relay-diagnostics-payload'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
const failure: MobileRelayMintFailure = {
@@ -47,3 +50,51 @@ describe('buildMobileRelayDiagnosticsPayload', () => {
expect(payload).not.toHaveProperty('cellUrl')
})
})
// Why this matters beyond the field value: the payload is what a user pastes into a bug report,
// and both Copy-diagnostics buttons fire the collector as `void copyRelayDiagnostics()` with the
// await sitting ahead of their try/catch — so a rejection here is a click that writes no
// clipboard and shows no toast at all.
describe('collectMobileRelayDiagnosticsPayload', () => {
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
function stubWindow(mobile: unknown): void {
const api: Record<string, unknown> = {}
if (mobile !== undefined) {
api.mobile = mobile
}
Object.defineProperty(globalThis, 'window', { configurable: true, value: { api } })
}
it("reports 'unreadable' rather than the definite 'offline' when the status lookup fails", async () => {
stubWindow({ getRelayStatus: vi.fn().mockRejectedValue(new Error('ipc down')) })
const payload = await collectMobileRelayDiagnosticsPayload({
connectionMode: 'automatic',
failure
})
expect(payload.relayStatus).toBe('unreadable')
})
it('still resolves when the mobile bridge is missing entirely', async () => {
stubWindow(undefined)
await expect(
collectMobileRelayDiagnosticsPayload({ connectionMode: 'automatic', failure })
).resolves.toMatchObject({ relayStatus: 'unreadable' })
})
it('reports a host-answered offline as offline', async () => {
stubWindow({ getRelayStatus: vi.fn().mockResolvedValue({ status: 'offline' }) })
const payload = await collectMobileRelayDiagnosticsPayload({
connectionMode: 'automatic',
failure
})
expect(payload.relayStatus).toBe('offline')
})
})
@@ -3,11 +3,18 @@ import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-min
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
import { resolveClientEnvironmentInfo } from '@/lib/client-environment-info'
/** The status lookup's own failure, kept distinct from the host answering 'offline'. */
export const MOBILE_RELAY_DIAGNOSTICS_STATUS_UNREADABLE = 'unreadable'
export type MobileRelayDiagnosticsStatus =
| MobileRelayStatus
| typeof MOBILE_RELAY_DIAGNOSTICS_STATUS_UNREADABLE
export type MobileRelayDiagnosticsPayload = {
kind: 'mobile_pairing_relay_failure'
preferredConnectionMode: MobilePairingConnectionMode
failure: MobileRelayMintFailure
relayStatus: MobileRelayStatus
relayStatus: MobileRelayDiagnosticsStatus
appVersion: string
at: string
}
@@ -17,7 +24,7 @@ export type MobileRelayDiagnosticsPayload = {
export function buildMobileRelayDiagnosticsPayload(args: {
connectionMode: MobilePairingConnectionMode
failure: MobileRelayMintFailure
relayStatus: MobileRelayStatus
relayStatus: MobileRelayDiagnosticsStatus
appVersion: string
}): MobileRelayDiagnosticsPayload {
return {
@@ -30,6 +37,25 @@ export function buildMobileRelayDiagnosticsPayload(args: {
}
}
/**
* Why not 'offline' on failure: this payload is what a user pastes into a bug report, and
* 'offline' is a claim the broker was down. A status call this renderer could not complete
* observed nothing (docs/reference/ssh-execution-boundary.md), and reporting it as the definite
* neighbour points triage at the relay instead of at the lookup that actually failed.
*
* Why the whole call and not just a `.catch`: both Copy-diagnostics buttons fire this as
* `void copyRelayDiagnostics()`, and the await now sits ahead of their try/catch, so a bridge
* missing `mobile` — which throws where a rejected promise was expected — would leave the click
* with no clipboard write and no toast at all.
*/
async function readRelayStatusForDiagnostics(): Promise<MobileRelayDiagnosticsStatus> {
try {
return (await window.api.mobile.getRelayStatus()).status
} catch {
return MOBILE_RELAY_DIAGNOSTICS_STATUS_UNREADABLE
}
}
// Why here rather than at each call site: both "Copy diagnostics" buttons must
// fetch the same two fields the same way, and MobilePane sits at the line ceiling.
export async function collectMobileRelayDiagnosticsPayload(args: {
@@ -37,10 +63,7 @@ export async function collectMobileRelayDiagnosticsPayload(args: {
failure: MobileRelayMintFailure
}): Promise<MobileRelayDiagnosticsPayload> {
const [relayStatus, environment] = await Promise.all([
window.api.mobile
.getRelayStatus()
.then((detail) => detail.status)
.catch(() => 'offline' as const),
readRelayStatusForDiagnostics(),
resolveClientEnvironmentInfo()
])
return buildMobileRelayDiagnosticsPayload({
@@ -306,12 +306,12 @@ export function MobilePane(): React.JSX.Element {
if (relayMintFailure == null) {
return
}
// Why: users share this payload, so it carries no address (selected or relay cell).
const payload = await collectMobileRelayDiagnosticsPayload({
connectionMode,
failure: relayMintFailure
})
try {
// Why: users share this payload, so it carries no address (selected or relay cell).
const payload = await collectMobileRelayDiagnosticsPayload({
connectionMode,
failure: relayMintFailure
})
await window.api.ui.writeClipboardText(JSON.stringify(payload, null, 2))
if (mountedRef.current) {
toast.success(