Skip to content

Commit 78baf19

Browse files
murphyclaude
authored andcommitted
fix(settings): warn when a server share link is generated for loopback
The address picker under "Share this Orca server" defaults to 127.0.0.1, and its hint rendered as static muted copy that read the same whichever address was selected. Nothing distinguished the one selection that produces a link no other device can open, so pairing a second machine tended to fail only after the link had been copied and pasted. Make the hint track the selection: loopback gets warning emphasis, any other address states which host the peer has to reach. Generated links carry their own warning, keyed to the address the link was actually minted for rather than the current picker value, so moving the picker afterwards cannot make the warning lie. Loopback detection lives in shared/network so the picker and the link row agree; it covers 127.x, localhost, ::1, and ws(s):// forms. The warning uses typography and an icon rather than a new color, since a loopback address is a valid choice and the palette reserves color for selection, destructive, and git decorations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 36e3324 commit 78baf19

9 files changed

Lines changed: 127 additions & 15 deletions

File tree

src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { Loader2, RefreshCw } from 'lucide-react'
1+
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
22
import { Button } from '../ui/button'
33
import { Label } from '../ui/label'
44
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
55
import { AddressPicker, type AddressOption } from '../network/AddressPicker'
6-
import { parseServerShareAddress } from '../../../../shared/network/server-share-address'
6+
import {
7+
isLoopbackShareAddress,
8+
parseServerShareAddress
9+
} from '../../../../shared/network/server-share-address'
710
import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows'
811
import { translate } from '@/i18n/i18n'
912

@@ -15,6 +18,7 @@ type RuntimePairingGeneratorFormProps = {
1518
isGeneratingPairing: boolean
1619
webClientUrl: string | null
1720
runtimePairingUrl: string | null
21+
generatedAddress: string | null
1822
copiedTarget: 'web' | 'pairing' | null
1923
onSelectedAddressChange: (address: string) => void
2024
onRefreshNetworkInterfaces: () => void
@@ -30,12 +34,15 @@ export function RuntimePairingGeneratorForm({
3034
isGeneratingPairing,
3135
webClientUrl,
3236
runtimePairingUrl,
37+
generatedAddress,
3338
copiedTarget,
3439
onSelectedAddressChange,
3540
onRefreshNetworkInterfaces,
3641
onGenerate,
3742
onCopy
3843
}: RuntimePairingGeneratorFormProps): React.JSX.Element {
44+
const selectionIsLoopback = isLoopbackShareAddress(selectedAddress)
45+
const generatedForLoopback = generatedAddress !== null && isLoopbackShareAddress(generatedAddress)
3946
const options: AddressOption[] = [
4047
{
4148
value: loopbackAddress,
@@ -147,12 +154,23 @@ export function RuntimePairingGeneratorForm({
147154
</Tooltip>
148155
</div>
149156
</div>
150-
<p className="text-xs text-muted-foreground">
151-
{translate(
152-
'auto.components.settings.RuntimePairingUrlGenerator.279e0dcb57',
153-
'127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.'
154-
)}
155-
</p>
157+
{selectionIsLoopback ? (
158+
<p className="flex items-start gap-1.5 text-xs text-foreground">
159+
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
160+
{translate(
161+
'auto.components.settings.RuntimePairingUrlGenerator.279e0dcb57',
162+
'127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.'
163+
)}
164+
</p>
165+
) : (
166+
<p className="text-xs text-muted-foreground">
167+
{translate(
168+
'auto.components.settings.RuntimePairingUrlGenerator.reachable-address-hint',
169+
'The other device must be able to reach {{address}}.',
170+
{ address: selectedAddress }
171+
)}
172+
</p>
173+
)}
156174
<div className="flex justify-end">
157175
<Button
158176
type="button"
@@ -171,6 +189,17 @@ export function RuntimePairingGeneratorForm({
171189
</div>
172190
</div>
173191

192+
{generatedForLoopback && (webClientUrl || runtimePairingUrl) ? (
193+
<p className="flex items-start gap-1.5 text-xs text-foreground">
194+
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
195+
{translate(
196+
'auto.components.settings.RuntimePairingUrlGenerator.generated-loopback-warning',
197+
'These links were generated for {{address}}, so only this computer can open them. Pick a reachable address above and generate again to pair another device.',
198+
{ address: generatedAddress ?? '' }
199+
)}
200+
</p>
201+
) : null}
202+
174203
{webClientUrl ? (
175204
<GeneratedUrlRow
176205
label={translate(

src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ const runtimePairingUrlCache: {
1616
runtimePairingUrl: string | null
1717
webClientUrl: string | null
1818
runtimePairingDeviceId: string | null
19+
generatedAddress: string | null
1920
} = {
2021
selectedAddress: LOOPBACK_ADDRESS,
2122
runtimePairingUrl: null,
2223
webClientUrl: null,
23-
runtimePairingDeviceId: null
24+
runtimePairingDeviceId: null,
25+
// Why: the picker can move after generating, so the link warning tracks the
26+
// address the displayed link was actually minted for.
27+
generatedAddress: null
2428
}
2529

2630
type RuntimePairingUrlGeneratorProps = {
@@ -47,6 +51,9 @@ export function RuntimePairingUrlGenerator({
4751
const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState<string | null>(
4852
runtimePairingUrlCache.runtimePairingDeviceId
4953
)
54+
const [generatedAddress, setGeneratedAddress] = useState<string | null>(
55+
runtimePairingUrlCache.generatedAddress
56+
)
5057
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
5158
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
5259
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
@@ -165,10 +172,12 @@ export function RuntimePairingUrlGenerator({
165172
runtimePairingUrlCache.runtimePairingUrl = null
166173
runtimePairingUrlCache.webClientUrl = null
167174
runtimePairingUrlCache.runtimePairingDeviceId = null
175+
runtimePairingUrlCache.generatedAddress = null
168176
if (mountedRef.current) {
169177
setRuntimePairingUrl(null)
170178
setWebClientUrl(null)
171179
setRuntimePairingDeviceId(null)
180+
setGeneratedAddress(null)
172181
}
173182
}
174183

@@ -194,10 +203,12 @@ export function RuntimePairingUrlGenerator({
194203
runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl
195204
runtimePairingUrlCache.webClientUrl = result.webClientUrl
196205
runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId
206+
runtimePairingUrlCache.generatedAddress = selectedAddress
197207
if (mountedRef.current) {
198208
setRuntimePairingUrl(result.pairingUrl)
199209
setWebClientUrl(result.webClientUrl)
200210
setRuntimePairingDeviceId(result.deviceId)
211+
setGeneratedAddress(selectedAddress)
201212
}
202213
await loadRuntimeAccessGrants()
203214
if (mountedRef.current) {
@@ -356,6 +367,7 @@ export function RuntimePairingUrlGenerator({
356367
isGeneratingPairing={isGeneratingPairing}
357368
webClientUrl={webClientUrl}
358369
runtimePairingUrl={runtimePairingUrl}
370+
generatedAddress={generatedAddress}
359371
copiedTarget={copiedTarget}
360372
onSelectedAddressChange={updateSelectedAddress}
361373
onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ showToastOnError: true })}

src/renderer/src/i18n/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6718,7 +6718,9 @@
67186718
"custom-description": "Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.",
67196719
"custom-hint": "Enter a host, host:port, or a ws(s):// URL.",
67206720
"custom-cancel": "Cancel",
6721-
"custom-use": "Use address"
6721+
"custom-use": "Use address",
6722+
"reachable-address-hint": "The other device must be able to reach {{address}}.",
6723+
"generated-loopback-warning": "These links were generated for {{address}}, so only this computer can open them. Pick a reachable address above and generate again to pair another device."
67226724
},
67236725
"Settings": {
67246726
"3bf149e873": "Project Settings > {{value0}}",

src/renderer/src/i18n/locales/es.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6658,7 +6658,9 @@
66586658
"custom-description": "Anuncia una dirección que otro dispositivo pueda alcanzar: un host de LAN o Tailscale, o una URL ws(s):// completa.",
66596659
"custom-hint": "Introduce un host, host:puerto o una URL ws(s)://.",
66606660
"custom-cancel": "Cancelar",
6661-
"custom-use": "Usar dirección"
6661+
"custom-use": "Usar dirección",
6662+
"reachable-address-hint": "The other device must be able to reach {{address}}.",
6663+
"generated-loopback-warning": "These links were generated for {{address}}, so only this computer can open them. Pick a reachable address above and generate again to pair another device."
66626664
},
66636665
"Settings": {
66646666
"3bf149e873": "Configuración del proyecto > {{value0}}",

src/renderer/src/i18n/locales/ja.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6680,7 +6680,9 @@
66806680
"custom-description": "他のデバイスから到達できるアドレスを指定します。LAN や Tailscale のホスト、または完全な ws(s):// URL です。",
66816681
"custom-hint": "ホスト、host:port、または ws(s):// URL を入力してください。",
66826682
"custom-cancel": "キャンセル",
6683-
"custom-use": "アドレスを使用"
6683+
"custom-use": "アドレスを使用",
6684+
"reachable-address-hint": "The other device must be able to reach {{address}}.",
6685+
"generated-loopback-warning": "These links were generated for {{address}}, so only this computer can open them. Pick a reachable address above and generate again to pair another device."
66846686
},
66856687
"Settings": {
66866688
"3bf149e873": "プロジェクト設定 > {{value0}}",

src/renderer/src/i18n/locales/ko.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6643,7 +6643,9 @@
66436643
"custom-description": "다른 기기가 연결할 수 있는 주소를 알립니다. LAN 또는 Tailscale 호스트, 또는 전체 ws(s):// URL입니다.",
66446644
"custom-hint": "호스트, host:port 또는 ws(s):// URL을 입력하세요.",
66456645
"custom-cancel": "취소",
6646-
"custom-use": "주소 사용"
6646+
"custom-use": "주소 사용",
6647+
"reachable-address-hint": "The other device must be able to reach {{address}}.",
6648+
"generated-loopback-warning": "These links were generated for {{address}}, so only this computer can open them. Pick a reachable address above and generate again to pair another device."
66476649
},
66486650
"Settings": {
66496651
"3bf149e873": "프로젝트 설정 > {{value0}}",

src/renderer/src/i18n/locales/zh.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6643,7 +6643,9 @@
66436643
"custom-description": "公布其他设备可以访问的地址:LAN 或 Tailscale 主机,或完整的 ws(s):// URL。",
66446644
"custom-hint": "请输入主机、host:port 或 ws(s):// URL。",
66456645
"custom-cancel": "取消",
6646-
"custom-use": "使用地址"
6646+
"custom-use": "使用地址",
6647+
"reachable-address-hint": "The other device must be able to reach {{address}}.",
6648+
"generated-loopback-warning": "These links were generated for {{address}}, so only this computer can open them. Pick a reachable address above and generate again to pair another device."
66476649
},
66486650
"Settings": {
66496651
"3bf149e873": "项目设置 > {{value0}}",

src/shared/network/server-share-address.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { parseServerShareAddress } from './server-share-address'
2+
import { isLoopbackShareAddress, parseServerShareAddress } from './server-share-address'
33

44
describe('parseServerShareAddress', () => {
55
it('accepts a bare hostname or IP', () => {
@@ -35,3 +35,36 @@ describe('parseServerShareAddress', () => {
3535
expect(parseServerShareAddress('my-host:70000').ok).toBe(false)
3636
})
3737
})
38+
39+
describe('isLoopbackShareAddress', () => {
40+
it('detects loopback hosts in every accepted address form', () => {
41+
for (const address of [
42+
'127.0.0.1',
43+
'127.1.2.3',
44+
'127.0.0.1:6768',
45+
'localhost',
46+
'LocalHost:6768',
47+
'::1',
48+
'[::1]',
49+
'ws://127.0.0.1:6768',
50+
'wss://localhost/runtime'
51+
]) {
52+
expect(isLoopbackShareAddress(address), address).toBe(true)
53+
}
54+
})
55+
56+
it('leaves routable LAN, tailnet, and public addresses alone', () => {
57+
for (const address of [
58+
'192.168.1.50',
59+
'100.64.0.2',
60+
'10.0.0.5:6768',
61+
'my-mac.tail-abcd.ts.net',
62+
'ws://100.64.0.2:6768',
63+
'128.0.0.1',
64+
'27.0.0.1',
65+
''
66+
]) {
67+
expect(isLoopbackShareAddress(address), address).toBe(false)
68+
}
69+
})
70+
})

src/shared/network/server-share-address.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,31 @@ export function parseServerShareAddress(input: string): ParseServerShareAddressR
4040
}
4141
return { ok: true, value: trimmed }
4242
}
43+
44+
// Why: a shared address that resolves to loopback produces a pairing link only
45+
// this computer can dial, so both the picker and the generated link warn on it.
46+
export function isLoopbackShareAddress(input: string): boolean {
47+
const trimmed = input.trim()
48+
if (trimmed === '') {
49+
return false
50+
}
51+
52+
let host = trimmed
53+
if (/^[a-z]+:\/\//i.test(trimmed)) {
54+
try {
55+
host = new URL(trimmed).hostname
56+
} catch {
57+
return false
58+
}
59+
} else if (HOST_OR_HOST_PORT.test(trimmed) && trimmed.includes(':')) {
60+
host = trimmed.slice(0, trimmed.lastIndexOf(':'))
61+
}
62+
63+
const normalized = host.replace(/^\[|\]$/g, '').toLowerCase()
64+
return (
65+
normalized === 'localhost' ||
66+
normalized === '::1' ||
67+
normalized === '0:0:0:0:0:0:0:1' ||
68+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)
69+
)
70+
}

0 commit comments

Comments
 (0)