fix(mobile): use paired ports in firewall help (#36130)

* fix(mobile): use paired ports in firewall help

* fix(mobile): use a namespace type in diagnostics test
This commit is contained in:
Kelvin Amoaba
2026-10-29 22:55:38 +00:00
committed by GitHub
parent c97e6f16bc
commit 2e5711fcaa
18 changed files with 250 additions and 23 deletions
+17
View File
@@ -104,6 +104,23 @@ The mobile terminal has a dedicated **Terminal settings** screen (Settings → T
## Troubleshooting
### Firewalls and Orca Relay
For Orca Relay and sign-in, allow outbound TCP port `443` from the desktop and phone to these hosts:
| Host | Purpose | Protocol |
| -------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------------- |
| `login.onorca.dev` | Orca sign-in and Relay credentials | HTTPS |
| `auth.onorca.dev` | Hosted sign-in pages | HTTPS |
| `relay.onorca.dev` | Relay director, which assigns a regional cell | HTTPS and secure WebSockets (WSS) |
| `*.relay.onorca.dev`, including `c1.relay.onorca.dev` and `c27.relay.onorca.dev` | Regional Relay cells | HTTPS and secure WebSockets (WSS) |
Allow WebSocket upgrades and long-lived connections to both `relay.onorca.dev` and the Relay cells. Cell assignments can change, so allow the cell hostname pattern as well as the director. Relay uses outbound connections; it does not need an inbound port or router port forwarding.
For a direct LAN or Tailscale connection, allow the paired desktop's listening port instead. The default is `6768`, but a desktop using another port needs that port allowed. **Settings → Troubleshooting** shows the paired endpoint's port in its firewall advice.
### Common issues
- **Stuck spinner on a worktree** — the phone tracks a heartbeat from the desktop agent process. If the desktop says idle but the phone shows working, force-refresh the worktree row to sync state.
- **Pairing fails** — make sure your desktop and phone are signed into the same Orca account. Pairing codes expire after a few minutes; generate a fresh one if it's been sitting on the screen.
- **Authentication failed / pairing invalid** — the desktop no longer accepts this device token (for example after desktop cleared paired devices). Try **Retry** on the banner first (transient rejections can recover without re-pairing). If it keeps failing, use **Re-pair** from desktop, or **Remove** the host and pair again.
+3 -1
View File
@@ -10,12 +10,14 @@ const isDevelopmentBuild = typeof __DEV__ !== 'undefined' && __DEV__
export default function NativeTroubleshootRoute() {
const router = useRouter()
const { rootRef, diagnosticStatus, checks, runDiagnostics } = useTroubleshootDiagnostics()
const { rootRef, diagnosticStatus, checks, hostEndpoints, runDiagnostics } =
useTroubleshootDiagnostics()
return (
<TroubleshootView
rootRef={rootRef}
diagnosticStatus={diagnosticStatus}
checks={checks}
hostEndpoints={hostEndpoints}
runDiagnostics={() => void runDiagnostics()}
onBack={() => router.back()}
onConnectionLog={() => router.push('/connection-log')}
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SUPPORTED_UI_LOCALES } from '../../../src/shared/ui-locale'
import { activateMobileUiLocale, translate } from '../i18n/mobile-i18n'
import { troubleshootCommonIssues } from './troubleshoot-common-issues'
vi.mock('lucide-react-native', () => ({
WifiOff: () => null,
Shield: () => null,
Monitor: () => null,
Clock: () => null,
Globe: () => null,
Bell: () => null
}))
afterEach(() => activateMobileUiLocale('en'))
describe('troubleshooting firewall advice', () => {
it.each([
{ endpoints: ['ws://192.168.1.10:6769'], ports: ['6769'] },
{ endpoints: ['ws://[fd7a:115c:a1e0::1]:7777'], ports: ['7777'] },
{ endpoints: ['wss://desktop.example', 'ws://desktop.local'], ports: ['443', '80'] },
{ endpoints: [], ports: ['6768'] },
{ endpoints: ['invalid endpoint'], ports: ['6768'] },
{
endpoints: ['ws://first.local:6769', 'ws://second.local:7777', 'ws://third.local:6769'],
ports: ['6769', '7777']
}
])('uses paired ports for $endpoints', ({ endpoints, ports }) => {
const sections = troubleshootCommonIssues(translate, endpoints).filter((section) =>
section.id.startsWith('firewall')
)
expect(sections.map((section) => section.title)).toEqual(
ports.map((port) => `Firewall Blocking Port ${port}`)
)
expect(sections.map((section) => section.steps.find((step) => step.includes('ufw')))).toEqual(
ports.map((port) => `Linux: sudo ufw allow ${port}`)
)
expect(new Set(sections.map((section) => section.id)).size).toBe(ports.length)
})
it.each(SUPPORTED_UI_LOCALES)('interpolates the paired port in %s', async (locale) => {
await activateMobileUiLocale(locale)
const section = troubleshootCommonIssues(translate, ['ws://desktop.local:6769']).find((entry) =>
entry.id.startsWith('firewall')
)
expect(section?.title).toContain('6769')
expect(section?.title).not.toContain('6768')
expect(section?.steps.some((step) => step.includes('sudo ufw allow 6769'))).toBe(true)
})
})
@@ -1,5 +1,6 @@
import { WifiOff, Shield, Monitor, Clock, Globe, Bell, type LucideIcon } from 'lucide-react-native'
import type { Translate } from '../i18n/mobile-i18n'
import { endpointPort } from '../transport/host-endpoint'
export type TroubleshootSection = {
id: string
@@ -8,7 +9,33 @@ export type TroubleshootSection = {
steps: string[]
}
export function troubleshootCommonIssues(t: Translate): TroubleshootSection[] {
export function troubleshootCommonIssues(
t: Translate,
endpoints: readonly string[] = []
): TroubleshootSection[] {
const ports = new Set(
endpoints.map((endpoint) => {
const port = endpointPort(endpoint)
if (port) {
return port
}
try {
const url = new URL(endpoint)
if (url.protocol === 'wss:') {
return '443'
}
if (url.protocol === 'ws:') {
return '80'
}
} catch {
// Unreadable legacy endpoints keep the default firewall advice.
}
return '6768'
})
)
if (ports.size === 0) {
ports.add('6768')
}
return [
{
id: 'notifications',
@@ -45,10 +72,16 @@ export function troubleshootCommonIssues(t: Translate): TroubleshootSection[] {
)
]
},
{
id: 'firewall',
...Array.from(ports, (port) => ({
id: `firewall-${port}`,
icon: Shield,
title: t('diagnostics.troubleshootCommonIssues.firewallTitle', 'Firewall Blocking Port 6768'),
title: t(
'diagnostics.troubleshootCommonIssues.firewallTitle',
'Firewall Blocking Port {{port}}',
{
port
}
),
steps: [
t(
'diagnostics.troubleshootCommonIssues.macosFirewall',
@@ -58,13 +91,15 @@ export function troubleshootCommonIssues(t: Translate): TroubleshootSection[] {
'diagnostics.troubleshootCommonIssues.windowsFirewall',
'Windows: Defender Firewall → Allow app — enable Orca for Private networks.'
),
'Linux: sudo ufw allow 6768',
t('diagnostics.troubleshootCommonIssues.linuxFirewall', 'Linux: sudo ufw allow {{port}}', {
port
}),
t(
'diagnostics.troubleshootCommonIssues.restrictedNetwork',
'Corporate/school networks may block P2P — try a personal hotspot.'
)
]
},
})),
{
id: 'desktop',
icon: Monitor,
+3 -1
View File
@@ -40,6 +40,7 @@ export function TroubleshootView({
rootRef,
diagnosticStatus,
checks,
hostEndpoints,
runDiagnostics,
onBack,
onConnectionLog,
@@ -48,6 +49,7 @@ export function TroubleshootView({
rootRef?: (node: View | null) => void
diagnosticStatus: DiagnosticStatus
checks: CheckResult[]
hostEndpoints: readonly string[]
runDiagnostics: () => void
onBack: () => void
onConnectionLog: () => void
@@ -146,7 +148,7 @@ export function TroubleshootView({
</Text>
<View style={styles.section}>
{troubleshootCommonIssues(t).map((section, i) => (
{troubleshootCommonIssues(t, hostEndpoints).map((section, i) => (
<View key={section.id}>
{i > 0 && <View style={styles.separator} />}
<Pressable
@@ -0,0 +1,87 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as HostReachability from './host-reachability'
const hosts = vi.hoisted(() => ({
catalog: vi.fn<() => Promise<{ endpoint: string }[]>>(),
profiles: vi.fn<() => Promise<{ name: string; endpoint: string }[]>>()
}))
vi.mock('react-native', () => ({ Platform: { OS: 'ios', Version: '27' } }))
vi.mock('../transport/host-store', () => ({
loadHostCatalog: hosts.catalog,
loadHosts: hosts.profiles
}))
vi.mock('./host-reachability', async (importOriginal) => ({
...(await importOriginal<typeof HostReachability>()),
testHostReachability: async () => true
}))
import { useTroubleshootDiagnostics } from './use-troubleshoot-diagnostics'
let tree: ReactTestRenderer | undefined
const state: { current: ReturnType<typeof useTroubleshootDiagnostics> | null } = { current: null }
function Harness() {
state.current = useTroubleshootDiagnostics()
return null
}
afterEach(async () => {
await act(async () => tree?.unmount())
state.current = null
vi.resetAllMocks()
vi.unstubAllGlobals()
})
describe('troubleshooting paired endpoints', () => {
it('loads endpoint advice before the user runs diagnostics', async () => {
hosts.catalog.mockResolvedValue([{ endpoint: 'ws://desktop.local:6769' }])
await act(async () => {
tree = create(createElement(Harness))
})
expect(state.current?.diagnosticStatus).toBe('idle')
expect(state.current?.hostEndpoints).toEqual(['ws://desktop.local:6769'])
})
it('keeps the newly probed endpoints when the initial catalog read finishes later', async () => {
let finishCatalog: (value: { endpoint: string }[]) => void = () => {}
hosts.catalog
.mockReturnValueOnce(
new Promise((resolve) => {
finishCatalog = resolve
})
)
.mockResolvedValue([{ endpoint: 'ws://desktop.local:7777' }])
hosts.profiles.mockResolvedValue([{ name: 'Desktop', endpoint: 'ws://desktop.local:7777' }])
vi.stubGlobal('fetch', async () => ({ ok: true }))
await act(async () => {
tree = create(createElement(Harness))
})
await act(async () => {
await state.current?.runDiagnostics()
})
await act(async () => {
finishCatalog([{ endpoint: 'ws://desktop.local:6769' }])
})
expect(state.current?.hostEndpoints).toEqual(['ws://desktop.local:7777'])
expect(state.current?.checks).toContainEqual({
label: 'Desktop',
status: 'pass',
detail: 'Reachable at desktop.local:7777'
})
})
it('retains saved port advice after a run when the host credential is unavailable', async () => {
hosts.catalog.mockResolvedValue([{ endpoint: 'ws://desktop.local:6769' }])
hosts.profiles.mockResolvedValue([])
vi.stubGlobal('fetch', async () => ({ ok: true }))
await act(async () => {
tree = create(createElement(Harness))
})
expect(state.current?.hostEndpoints).toEqual(['ws://desktop.local:6769'])
await act(async () => {
await state.current?.runDiagnostics()
})
expect(state.current?.hostEndpoints).toEqual(['ws://desktop.local:6769'])
})
})
@@ -1,8 +1,8 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { View } from 'react-native'
import { Platform } from 'react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { loadHosts } from '../transport/host-store'
import { loadHostCatalog, loadHosts } from '../transport/host-store'
import {
startDiagnosticFetchTimeout,
type DiagnosticFetchTimeout
@@ -14,10 +14,29 @@ export function useTroubleshootDiagnostics() {
const t = useMobileTranslation()
const [diagnosticStatus, setDiagnosticStatus] = useState<DiagnosticStatus>('idle')
const [checks, setChecks] = useState<CheckResult[]>([])
const [hostEndpoints, setHostEndpoints] = useState<string[]>([])
const abortRef = useRef(false)
const diagnosticRunRef = useRef(0)
const activeInternetCheckRef = useRef<DiagnosticFetchTimeout | null>(null)
useEffect(() => {
let disposed = false
const initialRunId = diagnosticRunRef.current
void loadHostCatalog().then(
(hosts) => {
if (!disposed && diagnosticRunRef.current === initialRunId) {
setHostEndpoints(hosts.map((host) => host.endpoint))
}
},
() => {
// Keep default advice when the saved host catalog cannot be read.
}
)
return () => {
disposed = true
}
}, [])
const rootRef = useCallback((node: View | null): void => {
if (node !== null) {
return
@@ -42,7 +61,11 @@ export function useTroubleshootDiagnostics() {
const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId
try {
const hosts = await loadHosts()
const [hosts, catalog] = await Promise.all([loadHosts(), loadHostCatalog()])
if (!isCurrentRun()) {
return
}
setHostEndpoints(catalog.map((host) => host.endpoint))
results.push(
hosts.length > 0
? {
@@ -166,5 +189,5 @@ export function useTroubleshootDiagnostics() {
setDiagnosticStatus('done')
}, [t])
return { rootRef, diagnosticStatus, checks, runDiagnostics }
return { rootRef, diagnosticStatus, checks, hostEndpoints, runDiagnostics }
}
+2 -1
View File
@@ -1102,7 +1102,8 @@
"checkTailnet": "Prüfen, ob der Computer nicht im Ruhezustand ist und im Tailnet als verbunden angezeigt wird.",
"desktopTitle": "Desktop-App läuft nicht",
"disableVpn": "Dieses VPN deaktivieren oder Split-Tunneling / „Allow LAN“ aktivieren.",
"firewallTitle": "Firewall blockiert Port 6768",
"firewallTitle": "Firewall blockiert Port {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "Host-Adressen wie 100.x.x.x oder *.ts.net nutzen Tailscale – Tailscale eingeschaltet lassen.",
"macosFirewall": "macOS: System Settings → Network → Firewall – Orca zulassen.",
"notificationNetwork": "Mobilfunk oder ein anderes WLAN ausprobieren. Treffen Benachrichtigungen nach dem Wechsel ein, verzögert das ursprüngliche Netzwerk möglicherweise die Zustellung.",
+2 -1
View File
@@ -361,7 +361,8 @@
"sameNetwork": "Both devices must be on the same LAN (unless connected through Tailscale).",
"sameSubnet": "Ethernet and WiFi must share the same subnet.",
"reconnectWifi": "Try reconnecting WiFi on both devices.",
"firewallTitle": "Firewall Blocking Port 6768",
"firewallTitle": "Firewall Blocking Port {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"macosFirewall": "macOS: System Settings → Network → Firewall — allow Orca.",
"windowsFirewall": "Windows: Defender Firewall → Allow app — enable Orca for Private networks.",
"restrictedNetwork": "Corporate/school networks may block P2P — try a personal hotspot.",
+2 -1
View File
@@ -1145,7 +1145,8 @@
"checkTailnet": "Comprueba que el escritorio esté activo y aparezca conectado en tu red de Tailscale.",
"desktopTitle": "App de escritorio cerrada",
"disableVpn": "Desactiva esa VPN o activa la tunelización dividida / \"Allow LAN\".",
"firewallTitle": "El cortafuegos bloquea el puerto 6768",
"firewallTitle": "El cortafuegos bloquea el puerto {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "Las direcciones de host como 100.x.x.x o *.ts.net se conectan a través de Tailscale — mantenlo ACTIVADO.",
"macosFirewall": "macOS: Ajustes del Sistema → Red → Firewall — permite las conexiones de Orca.",
"notificationNetwork": "Prueba con datos móviles u otra red Wi-Fi. Si las alertas llegan tras cambiar de red, es posible que tu red esté retrasando la entrega.",
+2 -1
View File
@@ -1145,7 +1145,8 @@
"checkTailnet": "Vérifiez que l'ordinateur n'est pas en veille et apparaît connecté à votre tailnet.",
"desktopTitle": "Application de bureau arrêtée",
"disableVpn": "Désactivez ce VPN ou activez le routage fractionné / « Autoriser le LAN ».",
"firewallTitle": "Pare-feu bloquant le port 6768",
"firewallTitle": "Pare-feu bloquant le port {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "Les adresses d'hôte comme 100.x.x.x ou *.ts.net passent par Tailscale — laissez-le ACTIVÉ.",
"macosFirewall": "macOS : Réglages Système → Réseau → Pare-feu — autorisez Orca.",
"notificationNetwork": "Essayez les données mobiles ou un autre réseau Wi-Fi. Si les alertes arrivent après ce changement, votre réseau retarde peut-être leur réception.",
+2 -1
View File
@@ -1098,7 +1098,8 @@
"checkTailnet": "デスクトップがスリープ状態になっておらず、tailnet 内で接続済みと表示されていることを確認してください。",
"desktopTitle": "デスクトップアプリが未起動",
"disableVpn": "その VPN を無効にするか、スプリットトンネリングまたは「LAN を許可」を有効にしてください。",
"firewallTitle": "ファイアウォールがポート 6768 をブロック",
"firewallTitle": "ファイアウォールがポート {{port}} をブロック",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "100.x.x.x や *.ts.net のようなホストアドレスには Tailscale 経由で接続します。Tailscale をオンにしておいてください。",
"macosFirewall": "macOS: 「システム設定」→「ネットワーク」→「ファイアウォール」で Orca を許可してください。",
"notificationNetwork": "モバイルデータ通信か別の Wi-Fi ネットワークを試してください。切り替え後に通知が届く場合は、元のネットワークで配信が遅れている可能性があります。",
+2 -1
View File
@@ -1098,7 +1098,8 @@
"checkTailnet": "데스크톱이 절전 모드가 아니며 tailnet에 연결된 상태로 표시되는지 확인하세요.",
"desktopTitle": "데스크톱 앱이 실행되지 않음",
"disableVpn": "해당 VPN을 끄거나 분할 터널링 / \"LAN 허용\"을 켜세요.",
"firewallTitle": "방화벽에서 포트 6768 차단",
"firewallTitle": "방화벽에서 포트 {{port}} 차단",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "100.x.x.x나 *.ts.net 같은 호스트 주소는 Tailscale을 통해 연결되므로 Tailscale을 켜 두세요.",
"macosFirewall": "macOS: 시스템 설정 → 네트워크 → 방화벽에서 Orca를 허용하세요.",
"notificationNetwork": "모바일 데이터나 다른 Wi-Fi 네트워크를 사용해 보세요. 전환 후 알림이 도착한다면 기존 네트워크에서 알림 전달이 지연되고 있을 수 있습니다.",
+2 -1
View File
@@ -1145,7 +1145,8 @@
"checkTailnet": "Verifique se o desktop está fora do modo de suspensão e aparece como conectado na sua tailnet.",
"desktopTitle": "App desktop não está em execução",
"disableVpn": "Desative essa VPN ou ative o tunelamento dividido / \"Permitir LAN\".",
"firewallTitle": "Firewall bloqueando a porta 6768",
"firewallTitle": "Firewall bloqueando a porta {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "Hosts com endereços como 100.x.x.x ou *.ts.net se conectam via Tailscale — mantenha-o ATIVADO.",
"macosFirewall": "macOS: Ajustes do Sistema → Rede → Firewall — permita o Orca.",
"notificationNetwork": "Tente usar os dados móveis ou outra rede Wi-Fi. Se os alertas chegarem após a troca, sua rede pode estar atrasando a entrega.",
+2 -1
View File
@@ -1188,7 +1188,8 @@
"checkTailnet": "Убедитесь, что компьютер не спит и отображается как подключённый в вашей сети tailnet.",
"desktopTitle": "Приложение на компьютере не запущено",
"disableVpn": "Отключите этот VPN или включите раздельное туннелирование / «Разрешить LAN».",
"firewallTitle": "Брандмауэр блокирует порт 6768",
"firewallTitle": "Брандмауэр блокирует порт {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "Адреса серверов вида 100.x.x.x или *.ts.net доступны через Tailscale — оставьте его ВКЛЮЧЁННЫМ.",
"macosFirewall": "macOS: Системные настройки → Сеть → Брандмауэр — разрешите подключения для Orca.",
"notificationNetwork": "Переключитесь на мобильную сеть или другую сеть Wi-Fi. Если уведомления начнут приходить, возможно, ваша сеть задерживает их доставку.",
+2 -1
View File
@@ -1102,7 +1102,8 @@
"checkTailnet": "Bilgisayarınızın uyku modunda olmadığını ve tailnet ağınızda bağlı göründüğünü kontrol edin.",
"desktopTitle": "Masaüstü Uygulaması Çalışmıyor",
"disableVpn": "Bu VPN'i devre dışı bırakın veya bölünmüş tünellemeyi / \"Allow LAN\" seçeneğini etkinleştirin.",
"firewallTitle": "Güvenlik Duvarı 6768 Portunu Engelliyor",
"firewallTitle": "Güvenlik Duvarı {{port}} Portunu Engelliyor",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "100.x.x.x veya *.ts.net gibi sunucu adreslerine Tailscale üzerinden bağlanılır — Tailscale'i AÇIK tutun.",
"macosFirewall": "macOS: System Settings → Network → Firewall — Orca'ya izin verin.",
"notificationNetwork": "Hücresel bağlantıyı veya başka bir Wi-Fi ağını deneyin. Ağ değiştirdikten sonra bildirimler geliyorsa ağınız iletimi geciktiriyor olabilir.",
+2 -1
View File
@@ -1098,7 +1098,8 @@
"checkTailnet": "確認電腦未進入睡眠,且在您的 tailnet 中顯示為已連線。",
"desktopTitle": "桌面版應用程式未執行",
"disableVpn": "停用該 VPN,或啟用分割通道/「允許 LAN」。",
"firewallTitle": "防火牆封鎖連接埠 6768",
"firewallTitle": "防火牆封鎖連接埠 {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "100.x.x.x 或 *.ts.net 等主機位址會透過 Tailscale 連線,請保持 Tailscale 開啟。",
"macosFirewall": "macOS:「系統設定」→「網路」→「防火牆」,允許 Orca 連線。",
"notificationNetwork": "試著改用行動網路或其他 Wi-Fi 網路。若切換後能收到通知,原本的網路可能導致通知延遲送達。",
+2 -1
View File
@@ -1098,7 +1098,8 @@
"checkTailnet": "确认桌面未休眠,并在 tailnet 中显示为已连接。",
"desktopTitle": "桌面应用未运行",
"disableVpn": "关闭该 VPN,或启用分流功能/“允许 LAN”选项。",
"firewallTitle": "防火墙拦截端口 6768",
"firewallTitle": "防火墙拦截端口 {{port}}",
"linuxFirewall": "Linux: sudo ufw allow {{port}}",
"keepTailscaleOn": "100.x.x.x 或 *.ts.net 等主机地址通过 Tailscale 连接,请保持 Tailscale 开启。",
"macosFirewall": "macOS:系统设置 → 网络 → 防火墙,允许 Orca 通过。",
"notificationNetwork": "尝试切换到蜂窝网络或其他 Wi-Fi 网络。如果切换后能收到通知,则原网络可能导致通知延迟。",