mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(mobile): recover missing desktop relay provider on pairing
This commit is contained in:
@@ -10,6 +10,79 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "desktop-relay.initialization-recovery",
|
||||
"title": "Missing desktop Relay provider can recover without restart",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "desktop-runtime",
|
||||
"layer": "main-unit",
|
||||
"surfaces": ["desktop relay initialization", "mobile pairing"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["relay"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["relay"],
|
||||
"coverageNotes": "Injected startup and IPC contracts plus renderer interaction; no real cloud assignment.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/20005"],
|
||||
"invariant": "A failed initialization remains retryable; successful recovery installs one provider and LAN and quit never trigger recovery.",
|
||||
"oracle": "Fail construction and start, retry initialization, count installed providers and cleanup; verify automatic pairing and Retry invoke recovery before minting.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/startup/main-process-relay-startup.test.ts src/main/ipc/mobile-relay-recovery.test.ts src/main/ipc/mobile.test.ts src/renderer/src/components/mobile/MobileHero.test.tsx"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/startup/main-process-relay-startup.test.ts",
|
||||
"src/main/ipc/mobile-relay-recovery.test.ts",
|
||||
"src/renderer/src/components/mobile/MobileHero.test.tsx"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/startup/main-process-relay-startup.test.ts",
|
||||
"assertions": [
|
||||
"failed construction recovers once",
|
||||
"partial startup is cleaned up",
|
||||
"quit prevents recovery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/mobile-relay-recovery.test.ts",
|
||||
"assertions": ["automatic pairing recovers before minting", "LAN skips recovery"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/mobile/MobileHero.test.tsx",
|
||||
"assertions": ["missing-provider notice offers a working Retry button"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/startup/main-process-relay-startup.test.ts src/main/ipc/mobile-relay-recovery.test.ts src/main/ipc/mobile.test.ts src/renderer/src/components/mobile/MobileHero.test.tsx",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.01,
|
||||
"summary": "60 tests passed across four files."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 60,
|
||||
"scope": "focused unit tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Local focused runs only."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "UI test fails before Retry is exposed; IPC recovery test fails with callback disabled. Startup recovery tests pass with injected constructor/start failures."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Synchronous idempotent initialization; no new timers or polling."
|
||||
},
|
||||
"promotionCriteria": ["Exercise startup recovery against real Relay in CI."],
|
||||
"knownGaps": ["Live cloud assignment and physical mobile pairing not exercised."],
|
||||
"demotionRule": "Demote on duplicate provider installation or post-quit recovery."
|
||||
},
|
||||
{
|
||||
"id": "mobile-push.headless-startup-and-policy",
|
||||
"title": "Headless push lifecycle and mobile delivery policy",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { isPackaged: false },
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
},
|
||||
shell: { openExternal: vi.fn() }
|
||||
}))
|
||||
|
||||
import { registerMobileHandlers } from './mobile'
|
||||
|
||||
describe('mobile Relay recovery', () => {
|
||||
it('recovers Relay before automatic pairing, including Retry, but never for LAN', async () => {
|
||||
const events: string[] = []
|
||||
const onBeforeRelayPairing = vi.fn(() => {
|
||||
events.push('recover')
|
||||
})
|
||||
const createMobilePairingOffer = vi.fn(async () => {
|
||||
events.push('pair')
|
||||
return { available: false, reason: 'relay_mint_failed' }
|
||||
})
|
||||
registerMobileHandlers({ createMobilePairingOffer } as never, { onBeforeRelayPairing })
|
||||
const request = handlers.get('mobile:getPairingQR')!
|
||||
await request({}, { address: '127.0.0.1' })
|
||||
await request({}, { address: '127.0.0.1', connectionMode: 'automatic', rotate: true })
|
||||
await request({}, { address: '127.0.0.1', connectionMode: 'local-only' })
|
||||
expect(events).toEqual(['recover', 'pair', 'recover', 'pair', 'pair'])
|
||||
expect(onBeforeRelayPairing).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
|
||||
export type MobileHandlerDependencies = {
|
||||
firewallEnvironment?: WindowsMobileFirewallEnvironment
|
||||
openWindowsNetworkSettings?: () => Promise<void>
|
||||
onBeforeRelayPairing?: () => void
|
||||
getRelayStatus?: () => MobileRelayStatusDetail
|
||||
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
|
||||
encodePairingQr?: (pairingUrl: string) => Promise<MobilePairingQrResult>
|
||||
@@ -110,6 +111,9 @@ export function registerMobileHandlers(
|
||||
// `rotate: true` (explicit "Regenerate" intent because the prior token
|
||||
// may have been exposed), we discard any pending token and mint a fresh
|
||||
// one so the new QR carries a different credential.
|
||||
if (args?.connectionMode !== 'local-only') {
|
||||
dependencies.onBeforeRelayPairing?.()
|
||||
}
|
||||
const offer = await rpcServer.createMobilePairingOffer({
|
||||
address: ip,
|
||||
connectionMode: args?.connectionMode,
|
||||
|
||||
@@ -73,6 +73,27 @@ function service(): DesktopRelayService {
|
||||
}
|
||||
|
||||
describe('DesktopRelayService broker liveness', () => {
|
||||
it('pairs after signing in without replacing the service created while signed out', async () => {
|
||||
const relayService = service()
|
||||
const signedIn = await fakes.readRelayAuthContext()
|
||||
fakes.readRelayAuthContext.mockResolvedValue(null)
|
||||
try {
|
||||
relayService.start()
|
||||
await expect(relayService.createPairingRelay('device-1')).rejects.toThrow(
|
||||
'relay_control_not_active'
|
||||
)
|
||||
expect(fakes.brokers).toHaveLength(0)
|
||||
fakes.readRelayAuthContext.mockResolvedValue(signedIn)
|
||||
relayService.authMutated()
|
||||
await expect(relayService.createPairingRelay('device-1')).resolves.toMatchObject({
|
||||
binding: { relayDeviceId: 'device-1' }
|
||||
})
|
||||
expect(fakes.brokers).toHaveLength(1)
|
||||
} finally {
|
||||
relayService.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs through a replacement when the owned broker control died', async () => {
|
||||
// Why: ownership stays 'valid' after a control socket dies, so the stale
|
||||
// handle otherwise reaches create_pairing_relay and fails the pairing.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fakes = vi.hoisted(() => ({
|
||||
state: { isQuitting: false, desktopRelayService: null as unknown },
|
||||
configured: true,
|
||||
construct: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
authMutated: vi.fn(),
|
||||
createPairingRelay: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({ app: { getVersion: () => 'test' } }))
|
||||
vi.mock('./main-process-state', () => ({ mainProcessState: fakes.state }))
|
||||
vi.mock('./main-process-relay-status', () => ({ publishDesktopRelayStatus: vi.fn() }))
|
||||
vi.mock('../orca-profiles/profile-storage-paths', () => ({
|
||||
getProfileUserDataPath: () => '/unused'
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-cloud-auth-config', () => ({
|
||||
getOrcaCloudAuthConfig: () => ({ configured: fakes.configured, config: {} })
|
||||
}))
|
||||
vi.mock('../runtime/relay/desktop-relay-service', () => ({
|
||||
DesktopRelayService: class {
|
||||
constructor() {
|
||||
fakes.construct()
|
||||
}
|
||||
start = fakes.start
|
||||
stop = fakes.stop
|
||||
authMutated = fakes.authMutated
|
||||
createPairingRelay = fakes.createPairingRelay
|
||||
}
|
||||
}))
|
||||
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
|
||||
describe('desktop relay initialization recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
fakes.state.isQuitting = false
|
||||
fakes.state.desktopRelayService = null
|
||||
fakes.configured = true
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
function runtime() {
|
||||
return { setMobileRelayPairingProvider: vi.fn() } as unknown as OrcaRuntimeRpcServer
|
||||
}
|
||||
|
||||
it('waits for desktop startup, recovers a failed construction, and installs one provider', async () => {
|
||||
const { ensureDesktopRelayService, startDesktopRelayService } =
|
||||
await import('./main-process-relay-startup')
|
||||
const rpc = runtime()
|
||||
expect(ensureDesktopRelayService()).toBeNull()
|
||||
expect(fakes.construct).not.toHaveBeenCalled()
|
||||
fakes.construct.mockImplementationOnce(() => {
|
||||
throw new Error('mobile_runtime_not_ready')
|
||||
})
|
||||
startDesktopRelayService(rpc)
|
||||
expect(fakes.state.desktopRelayService).toBeNull()
|
||||
expect(rpc.setMobileRelayPairingProvider).toHaveBeenLastCalledWith(null)
|
||||
|
||||
ensureDesktopRelayService()?.authMutated()
|
||||
const service = fakes.state.desktopRelayService
|
||||
expect(service).not.toBeNull()
|
||||
expect(fakes.authMutated).toHaveBeenCalledOnce()
|
||||
expect(ensureDesktopRelayService()).toBe(service)
|
||||
expect(fakes.construct).toHaveBeenCalledTimes(2)
|
||||
expect(fakes.start).toHaveBeenCalledOnce()
|
||||
const provider = vi.mocked(rpc.setMobileRelayPairingProvider).mock.calls.at(-1)![0]!
|
||||
fakes.createPairingRelay.mockResolvedValue({ relay: 'test-offer' })
|
||||
await expect(provider.createPairingRelay('phone')).resolves.toEqual({ relay: 'test-offer' })
|
||||
expect(fakes.createPairingRelay).toHaveBeenCalledWith('phone')
|
||||
})
|
||||
|
||||
it('removes a partially started provider before allowing another attempt', async () => {
|
||||
const { ensureDesktopRelayService, startDesktopRelayService } =
|
||||
await import('./main-process-relay-startup')
|
||||
const rpc = runtime()
|
||||
fakes.start.mockImplementationOnce(() => {
|
||||
throw new Error('start failed')
|
||||
})
|
||||
startDesktopRelayService(rpc)
|
||||
expect(fakes.stop).toHaveBeenCalledOnce()
|
||||
expect(rpc.setMobileRelayPairingProvider).toHaveBeenLastCalledWith(null)
|
||||
expect(fakes.state.desktopRelayService).toBeNull()
|
||||
expect(ensureDesktopRelayService()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips unconfigured builds and never resurrects a quitting desktop', async () => {
|
||||
const { ensureDesktopRelayService, startDesktopRelayService } =
|
||||
await import('./main-process-relay-startup')
|
||||
fakes.configured = false
|
||||
startDesktopRelayService(runtime())
|
||||
ensureDesktopRelayService()
|
||||
expect(fakes.construct).not.toHaveBeenCalled()
|
||||
fakes.configured = true
|
||||
fakes.state.isQuitting = true
|
||||
expect(ensureDesktopRelayService()).toBeNull()
|
||||
expect(fakes.construct).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { app } from 'electron'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import { publishDesktopRelayStatus } from './main-process-relay-status'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
|
||||
let relayRuntime: OrcaRuntimeRpcServer | null = null
|
||||
|
||||
export function startDesktopRelayService(runtimeRpc: OrcaRuntimeRpcServer): void {
|
||||
// Auth and pairing can arrive before startup has applied the proxy and started RPC.
|
||||
relayRuntime = runtimeRpc
|
||||
ensureDesktopRelayService()
|
||||
}
|
||||
|
||||
export function ensureDesktopRelayService(): DesktopRelayService | null {
|
||||
if (state.isQuitting || !relayRuntime) {
|
||||
return null
|
||||
}
|
||||
if (state.desktopRelayService) {
|
||||
return state.desktopRelayService
|
||||
}
|
||||
const cloudAuth = getOrcaCloudAuthConfig()
|
||||
if (!cloudAuth.configured) {
|
||||
return null
|
||||
}
|
||||
|
||||
let service: DesktopRelayService | null = null
|
||||
try {
|
||||
service = new DesktopRelayService({
|
||||
authConfig: cloudAuth.config,
|
||||
userDataPath: getProfileUserDataPath(),
|
||||
appVersion: app.getVersion(),
|
||||
runtimeRpc: relayRuntime,
|
||||
onStatus: publishDesktopRelayStatus
|
||||
})
|
||||
const relayService = service
|
||||
relayRuntime.setMobileRelayPairingProvider({
|
||||
createPairingRelay: (id) => relayService.createPairingRelay(id),
|
||||
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
|
||||
onDemandStateChanged: () => relayService.demandStateChanged(),
|
||||
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
|
||||
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
|
||||
})
|
||||
relayService.start()
|
||||
state.desktopRelayService = relayService
|
||||
return relayService
|
||||
} catch {
|
||||
relayRuntime.setMobileRelayPairingProvider(null)
|
||||
service?.stop()
|
||||
console.warn('[relay] Desktop relay initialization failed; retry pairing to recover')
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { app, powerMonitor, 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,8 @@ 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 { getDesktopRelayStatus, publishDesktopRelayStatus } from './main-process-relay-status'
|
||||
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
|
||||
import { getDesktopRelayStatus } from './main-process-relay-status'
|
||||
import { ensureDesktopRelayService, startDesktopRelayService } from './main-process-relay-startup'
|
||||
import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve'
|
||||
import {
|
||||
bindTerminalRuntimeStartupServices,
|
||||
@@ -94,6 +92,9 @@ function installRuntimeRpc(
|
||||
state.runtimeRpc = runtimeRpc
|
||||
registerMobileHandlers(runtimeRpc, {
|
||||
getRelayStatus: getDesktopRelayStatus,
|
||||
onBeforeRelayPairing: () => {
|
||||
ensureDesktopRelayService()
|
||||
},
|
||||
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
|
||||
if (
|
||||
!state.mainWindow ||
|
||||
@@ -249,35 +250,8 @@ async function launchDesktopMode(
|
||||
// Why after the proxy await: the push gateway client is an app-owned fetcher, so it must not
|
||||
// issue its first request ahead of the persisted proxy.
|
||||
startDesktopPushService(runtimeRpc)
|
||||
const cloudAuth = getOrcaCloudAuthConfig()
|
||||
if (cloudAuth.configured) {
|
||||
try {
|
||||
const relayService = new DesktopRelayService({
|
||||
authConfig: cloudAuth.config,
|
||||
userDataPath: getProfileUserDataPath(),
|
||||
appVersion: app.getVersion(),
|
||||
runtimeRpc,
|
||||
onStatus: publishDesktopRelayStatus
|
||||
})
|
||||
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)
|
||||
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
|
||||
// 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.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation'
|
||||
import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch'
|
||||
import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags'
|
||||
import { ensureDesktopRelayService } from './main-process-relay-startup'
|
||||
import { RELAY_HOST_CLOSE_REASON } from '../../shared/relay-host-close-reason'
|
||||
|
||||
export function attachMainWindowCoreServices(
|
||||
@@ -90,7 +91,7 @@ export function attachMainWindowCoreServices(
|
||||
store
|
||||
})
|
||||
},
|
||||
onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(),
|
||||
onOrcaProfileAuthMutation: () => ensureDesktopRelayService()?.authMutated(),
|
||||
// Sign-out is the one fence a paired phone can be told about; quit and
|
||||
// relaunch above stay reasonless so a restart never reads as signed out.
|
||||
onBeforeOrcaProfileSignOut: () =>
|
||||
|
||||
@@ -214,18 +214,21 @@ describe('HeroFlow height', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible())
|
||||
})
|
||||
|
||||
it('does not offer a futile retry when Relay is unavailable on the desktop', () => {
|
||||
it('lets the user retry desktop Relay initialization', async () => {
|
||||
const onRetryRelay = vi.fn()
|
||||
renderFlow(1, {
|
||||
relayMintFailure: {
|
||||
code: 'relay_provider_unavailable',
|
||||
stage: 'provider_missing',
|
||||
message: 'Orca Relay is not available on this desktop'
|
||||
}
|
||||
},
|
||||
onRetryRelay
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Orca Relay isn’t available on this desktop'
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry Relay' }))
|
||||
expect(onRetryRelay).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
|
||||
})
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function MobileRelayMintFailureNotice({
|
||||
: providerMissing
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
|
||||
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
'Retry Relay. If it still fails, restart Orca or use LAN over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: reconnectRequired
|
||||
? translate(
|
||||
@@ -105,7 +105,7 @@ export function MobileRelayMintFailureNotice({
|
||||
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
|
||||
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
|
||||
</Button>
|
||||
{!providerMissing && !reconnectRequired ? (
|
||||
{!reconnectRequired ? (
|
||||
<Button
|
||||
type="button"
|
||||
size={compact ? 'xs' : 'sm'}
|
||||
|
||||
@@ -13868,7 +13868,7 @@
|
||||
"unavailableTitle": "Orca Relay isn’t available on this desktop.",
|
||||
"title": "Couldn’t create a Relay pairing code.",
|
||||
"retryingBody": "Creating a new pairing code. This can take a moment over a remote connection.",
|
||||
"unavailableBody": "Use LAN to pair over Tailscale or the same Wi‑Fi.",
|
||||
"unavailableBody": "Retry Relay. If it still fails, restart Orca or use LAN over Tailscale or the same Wi‑Fi.",
|
||||
"body": "Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.",
|
||||
"useLan": "Use LAN",
|
||||
"retrying": "Retrying…",
|
||||
|
||||
@@ -13345,7 +13345,7 @@
|
||||
"unavailableTitle": "Orca Relay n'est pas disponible sur ce poste de bureau.",
|
||||
"title": "Impossible de créer un code d'appairage Relay.",
|
||||
"retryingBody": "Création d'un nouveau code d'appairage. Cela peut prendre un moment via une connexion distante.",
|
||||
"unavailableBody": "Utilisez le LAN pour appairer via Tailscale ou le même Wi-Fi.",
|
||||
"unavailableBody": "Réessayez Orca Relay. Si le problème persiste, redémarrez Orca ou utilisez le LAN via Tailscale ou le même Wi-Fi.",
|
||||
"body": "Réessayez, ou utilisez le LAN pour appairer via Tailscale ou le même Wi-Fi.",
|
||||
"useLan": "Utiliser le LAN",
|
||||
"retrying": "Nouvelle tentative…",
|
||||
|
||||
Reference in New Issue
Block a user