diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 97dd9b69..20179023 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,7 +21,7 @@ import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" -import { isAdvertisableRemoteAddress, resolveNetworkAddresses } from "./server/network-addresses" +import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" @@ -457,13 +457,16 @@ async function main() { let remoteHost = options.host if (wantsAll) { if (options.host === "0.0.0.0") { - remoteAddresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) - remoteHost = remoteAddresses.find((addr) => isAdvertisableRemoteAddress(addr))?.ip ?? "localhost" + const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) + remoteAddresses = resolved.userVisible + remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}` } } else { remoteHost = "localhost" } - remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` + if (!remoteUrl) { + remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` + } } serverMeta.localUrl = localUrl @@ -485,7 +488,6 @@ async function main() { if (serverMeta.remoteUrl) { console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) const additionalRemoteUrls = serverMeta.addresses - .filter((addr) => isAdvertisableRemoteAddress(addr)) .map((addr) => addr.remoteUrl) .filter((url) => url !== serverMeta.remoteUrl) diff --git a/packages/server/src/server/__tests__/network-addresses.test.ts b/packages/server/src/server/__tests__/network-addresses.test.ts index fec66021..a4955937 100644 --- a/packages/server/src/server/__tests__/network-addresses.test.ts +++ b/packages/server/src/server/__tests__/network-addresses.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert/strict" import os from "node:os" import { describe, it } from "node:test" -import { isAdvertisableRemoteAddress, resolveNetworkAddresses } from "../network-addresses" +import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses" describe("resolveNetworkAddresses", () => { - it("keeps RFC1918 addresses grouped without preferring one private range over another", () => { + it("preserves interface order among external addresses", () => { const addresses = [ { address: "172.24.0.1", family: "IPv4", internal: false }, { address: "192.168.1.128", family: "IPv4", internal: false }, @@ -23,24 +23,24 @@ describe("resolveNetworkAddresses", () => { ) }) }) +}) - it("marks link-local addresses as non-advertisable for terminal output", () => { - assert.equal(isAdvertisableRemoteAddress({ ip: "169.254.10.20", scope: "external" }), false) - assert.equal(isAdvertisableRemoteAddress({ ip: "192.168.1.128", scope: "external" }), true) - assert.equal(isAdvertisableRemoteAddress({ ip: "127.0.0.1", scope: "loopback" }), false) - }) - - it("keeps a usable LAN address advertisable when a link-local address is discovered first", () => { +describe("resolveRemoteAddresses", () => { + it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => { const addresses = [ { address: "169.254.10.20", family: "IPv4", internal: false }, { address: "192.168.1.128", family: "IPv4", internal: false }, + { address: "172.24.0.1", family: "IPv4", internal: false }, ] usingMockedNetworkInterfaces(addresses, () => { - const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) - const primaryAdvertisable = result.find((entry) => isAdvertisableRemoteAddress(entry)) + const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) - assert.equal(primaryAdvertisable?.ip, "192.168.1.128") + assert.deepEqual( + result.userVisible.map((entry) => entry.ip), + ["192.168.1.128", "172.24.0.1", "169.254.10.20"], + ) + assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898") }) }) }) diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts index 8580ac2f..09255835 100644 --- a/packages/server/src/server/network-addresses.ts +++ b/packages/server/src/server/network-addresses.ts @@ -1,6 +1,12 @@ import os from "os" import type { NetworkAddress } from "../api-types" +export interface ResolvedRemoteAddresses { + all: NetworkAddress[] + userVisible: NetworkAddress[] + primaryRemoteUrl?: string +} + export function resolveNetworkAddresses(args: { host: string protocol: "http" | "https" @@ -59,34 +65,30 @@ export function resolveNetworkAddresses(args: { const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] if (scopeDelta !== 0) return scopeDelta - const addressDelta = compareAddressPriority(a.ip, b.ip) - if (addressDelta !== 0) return addressDelta - return 0 }) } -export function isAdvertisableRemoteAddress(address: Pick): boolean { - if (address.scope !== "external") return false - return !isLinkLocalIPv4(address.ip) +export function resolveRemoteAddresses(args: { + host: string + protocol: "http" | "https" + port: number +}): ResolvedRemoteAddresses { + const all = resolveNetworkAddresses(args) + const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external")) + return { + all, + userVisible, + primaryRemoteUrl: userVisible[0]?.remoteUrl, + } } -function compareAddressPriority(left: string, right: string): number { - return getAddressPriority(left) - getAddressPriority(right) +function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] { + return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip)) } -function getAddressPriority(ip: string): number { - const octets = parseIPv4(ip) - if (!octets) return 100 - - const [first, second] = octets - - if (isLinkLocalIPv4(ip)) return 90 - if (first === 172 && second >= 16 && second <= 31) return 10 - if (first === 10) return 10 - if (first === 192 && second === 168) return 10 - - return 50 +function getUserVisiblePriority(ip: string): number { + return isLinkLocalIPv4(ip) ? 1 : 0 } function isLinkLocalIPv4(ip: string): boolean { diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index ef01c4cb..65adda5f 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from "fastify" import { ServerMeta } from "../../api-types" -import { resolveNetworkAddresses } from "../network-addresses" + interface RouteDeps { serverMeta: ServerMeta @@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { function buildMetaResponse(meta: ServerMeta): ServerMeta { const localPort = resolveLocalPort(meta) const remote = resolveRemote(meta) - const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : [] return { ...meta, localPort, remotePort: remote?.port, listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", - addresses, } } diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx index 6bcf53cf..04fd26fb 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences" import { showConfirmDialog } from "../stores/alerts" import { getLogger } from "../lib/logger" import { useI18n } from "../lib/i18n" +import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses" const log = getLogger("actions") @@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const [passwordConfirm, setPasswordConfirm] = createSignal("") const [passwordError, setPasswordError] = createSignal(null) const [savingPassword, setSavingPassword] = createSignal(false) + const [showAllAddresses, setShowAllAddresses] = createSignal(false) const addresses = createMemo(() => meta()?.addresses ?? []) const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const allowExternalConnections = createMemo(() => currentMode() === "all") - const displayAddresses = createMemo(() => { + const displayAddresses = createMemo(() => { const list = addresses() if (!allowExternalConnections()) { - return [] + return { recommended: null, hidden: [] } } - // Local URL is displayed separately; list only remote-friendly addresses. - return list.filter((address) => address.scope !== "loopback") + return splitRemoteAddresses(list) }) const refreshMeta = async () => { @@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) setMeta(metaResult) setAuthStatus(authResult) + setShowAllAddresses(false) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -325,7 +327,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { {t("remoteAccess.addresses.loading")}}> {error()}}> - 0} fallback={
{t("remoteAccess.addresses.none")}
}> + {t("remoteAccess.addresses.none")}}>
{(url) => { @@ -372,7 +374,74 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { ) }} - + + {(addressAccessor) => { + const address = addressAccessor() + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + 0}> +
+ +
+
+ + + {(address) => { const url = address.remoteUrl const expandedState = () => expandedUrl() === url @@ -424,7 +493,8 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
) }} - + +
diff --git a/packages/ui/src/components/settings/remote-access-settings-section.tsx b/packages/ui/src/components/settings/remote-access-settings-section.tsx index 049036dd..a4a61a0f 100644 --- a/packages/ui/src/components/settings/remote-access-settings-section.tsx +++ b/packages/ui/src/components/settings/remote-access-settings-section.tsx @@ -9,6 +9,7 @@ import { serverSettings, setListeningMode } from "../../stores/preferences" import { showConfirmDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" import { useI18n } from "../../lib/i18n" +import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses" const log = getLogger("actions") @@ -30,14 +31,15 @@ export const RemoteAccessSettingsSection: Component = () => { const [passwordConfirm, setPasswordConfirm] = createSignal("") const [passwordError, setPasswordError] = createSignal(null) const [savingPassword, setSavingPassword] = createSignal(false) + const [showAllAddresses, setShowAllAddresses] = createSignal(false) const addresses = createMemo(() => meta()?.addresses ?? []) const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const allowExternalConnections = createMemo(() => currentMode() === "all") - const displayAddresses = createMemo(() => { + const displayAddresses = createMemo(() => { const list = addresses() - if (!allowExternalConnections()) return [] - return list.filter((address) => address.scope !== "loopback") + if (!allowExternalConnections()) return { recommended: null, hidden: [] } + return splitRemoteAddresses(list) }) const refreshMeta = async () => { @@ -48,6 +50,7 @@ export const RemoteAccessSettingsSection: Component = () => { const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) setMeta(metaResult) setAuthStatus(authResult) + setShowAllAddresses(false) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -291,7 +294,7 @@ export const RemoteAccessSettingsSection: Component = () => { {t("remoteAccess.addresses.loading")}}> {error()}}> 0 || meta()?.localUrl} + when={Boolean(displayAddresses().recommended) || meta()?.localUrl} fallback={
{t("remoteAccess.addresses.none")}
} >
@@ -341,7 +344,74 @@ export const RemoteAccessSettingsSection: Component = () => { }} - + + {(addressAccessor) => { + const address = addressAccessor() + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + 0}> +
+ +
+
+ + + {(address) => { const url = address.remoteUrl const expandedState = () => expandedUrl() === url @@ -390,7 +460,8 @@ export const RemoteAccessSettingsSection: Component = () => {
) }} - + +
diff --git a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts index 5b1567f2..cad9f855 100644 --- a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.", "remoteAccess.addresses.loading": "Loading addresses…", "remoteAccess.addresses.none": "No addresses available yet.", + "remoteAccess.addresses.actions.showOther": "Show {count} other addresses", + "remoteAccess.addresses.actions.hideOther": "Hide other addresses", "remoteAccess.address.scope.network": "Network", "remoteAccess.address.scope.loopback": "Loopback", "remoteAccess.address.scope.internal": "Internal", diff --git a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts index 3539c8f4..f372d60c 100644 --- a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.", "remoteAccess.addresses.loading": "Cargando direcciones…", "remoteAccess.addresses.none": "Aún no hay direcciones disponibles.", + "remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más", + "remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones", "remoteAccess.address.scope.network": "Red", "remoteAccess.address.scope.loopback": "Loopback", "remoteAccess.address.scope.internal": "Interna", diff --git a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts index 8049b1c2..3b6c17ad 100644 --- a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.", "remoteAccess.addresses.loading": "Chargement des adresses…", "remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.", + "remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses", + "remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses", "remoteAccess.address.scope.network": "Réseau", "remoteAccess.address.scope.loopback": "Boucle locale", "remoteAccess.address.scope.internal": "Interne", diff --git a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts index 684ff23d..dc026c46 100644 --- a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.", "remoteAccess.addresses.loading": "טוען כתובות…", "remoteAccess.addresses.none": "אין כתובות זמינות עדיין.", + "remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות", + "remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות", "remoteAccess.address.scope.network": "רשת", "remoteAccess.address.scope.loopback": "לולאה מקומית", "remoteAccess.address.scope.internal": "פנימי", diff --git a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts index dbc9e221..996b481e 100644 --- a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。", "remoteAccess.addresses.loading": "アドレスを読み込み中…", "remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。", + "remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示", + "remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す", "remoteAccess.address.scope.network": "ネットワーク", "remoteAccess.address.scope.loopback": "ループバック", "remoteAccess.address.scope.internal": "内部", diff --git a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts index df90ce3d..26f4999e 100644 --- a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.", "remoteAccess.addresses.loading": "Загрузка адресов…", "remoteAccess.addresses.none": "Пока нет доступных адресов.", + "remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов", + "remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса", "remoteAccess.address.scope.network": "Сеть", "remoteAccess.address.scope.loopback": "Loopback", "remoteAccess.address.scope.internal": "Внутренний", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts index 265e65c4..16a8b265 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts @@ -41,6 +41,8 @@ export const remoteAccessMessages = { "remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。", "remoteAccess.addresses.loading": "正在加载地址…", "remoteAccess.addresses.none": "暂时没有可用地址。", + "remoteAccess.addresses.actions.showOther": "显示另外 {count} 个地址", + "remoteAccess.addresses.actions.hideOther": "隐藏其他地址", "remoteAccess.address.scope.network": "网络", "remoteAccess.address.scope.loopback": "回环", "remoteAccess.address.scope.internal": "内部", diff --git a/packages/ui/src/lib/remote-access-addresses.test.ts b/packages/ui/src/lib/remote-access-addresses.test.ts new file mode 100644 index 00000000..7161d035 --- /dev/null +++ b/packages/ui/src/lib/remote-access-addresses.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { splitRemoteAddresses } from "./remote-access-addresses" + +describe("splitRemoteAddresses", () => { + it("keeps the first remote address visible and collapses the rest", () => { + const result = splitRemoteAddresses([ + { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, + { ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" }, + { ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" }, + ]) + + assert.equal(result.recommended?.ip, "192.168.1.128") + assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"]) + }) +}) diff --git a/packages/ui/src/lib/remote-access-addresses.ts b/packages/ui/src/lib/remote-access-addresses.ts new file mode 100644 index 00000000..e5aa8eb8 --- /dev/null +++ b/packages/ui/src/lib/remote-access-addresses.ts @@ -0,0 +1,14 @@ +import type { NetworkAddress } from "../../../server/src/api-types" + +export interface RemoteAddressGroups { + recommended: NetworkAddress | null + hidden: NetworkAddress[] +} + +export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups { + const remoteAddresses = addresses.filter((address) => address.scope !== "loopback") + return { + recommended: remoteAddresses[0] ?? null, + hidden: remoteAddresses.slice(1), + } +}