diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c20a64b2..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 { resolveNetworkAddresses } from "./server/network-addresses" +import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" @@ -451,18 +451,22 @@ async function main() { // which can lead clients to talk to the wrong process. const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}` let remoteUrl: string | undefined + let remoteAddresses = [] as ReturnType if (remoteStart) { const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) let remoteHost = options.host if (wantsAll) { if (options.host === "0.0.0.0") { - const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) - remoteHost = candidates.find((addr) => addr.scope === "external")?.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 @@ -473,7 +477,9 @@ async function main() { serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" if (serverMeta.remotePort && remoteUrl) { - serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) + serverMeta.addresses = remoteAddresses.length + ? remoteAddresses + : resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) } else { serverMeta.addresses = [] } @@ -481,6 +487,16 @@ async function main() { console.log(`Local Connection URL : ${serverMeta.localUrl}`) if (serverMeta.remoteUrl) { console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) + const additionalRemoteUrls = serverMeta.addresses + .map((addr) => addr.remoteUrl) + .filter((url) => url !== serverMeta.remoteUrl) + + if (additionalRemoteUrls.length > 0) { + console.log("Other Accessible URLs:") + for (const url of additionalRemoteUrls) { + console.log(` - ${url}`) + } + } } if (options.launch) { diff --git a/packages/server/src/server/__tests__/network-addresses.test.ts b/packages/server/src/server/__tests__/network-addresses.test.ts new file mode 100644 index 00000000..a6d47767 --- /dev/null +++ b/packages/server/src/server/__tests__/network-addresses.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import os from "node:os" +import { describe, it } from "node:test" + +import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses" + +describe("resolveNetworkAddresses", () => { + 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 }, + { address: "10.0.0.8", family: 4, internal: false }, + { address: "127.0.0.1", family: "IPv4", internal: true }, + { address: "169.254.10.20", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual( + result.map((entry) => entry.ip), + ["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"], + ) + }) + }) +}) + +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 = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + 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") + }) + }) + + it("prefers private LAN addresses over public addresses", () => { + const addresses = [ + { address: "203.0.113.40", family: "IPv4", internal: false }, + { address: "192.168.1.128", family: "IPv4", internal: false }, + { address: "8.8.8.8", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual( + result.userVisible.map((entry) => entry.ip), + ["192.168.1.128", "203.0.113.40", "8.8.8.8"], + ) + assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898") + }) + }) + + it("uses a public address when no private LAN address is available", () => { + const addresses = [ + { address: "169.254.10.20", family: "IPv4", internal: false }, + { address: "203.0.113.40", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"]) + assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898") + }) + }) +}) + +function usingMockedNetworkInterfaces( + addresses: Array<{ address: string; family: string | number; internal: boolean }>, + callback: () => void, +) { + const original = os.networkInterfaces + os.networkInterfaces = (() => ({ + ethernet0: addresses as unknown as ReturnType[string], + })) as typeof os.networkInterfaces + + try { + callback() + } finally { + os.networkInterfaces = original + } +} diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts index ffedd821..8491fc82 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" @@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: { return results.sort((a, b) => { const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] if (scopeDelta !== 0) return scopeDelta - return a.ip.localeCompare(b.ip) + + return 0 }) } +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 sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] { + return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip)) +} + +function getUserVisiblePriority(ip: string): number { + if (isPrivateIPv4(ip)) return 0 + if (isLinkLocalIPv4(ip)) return 2 + return 1 +} + +function isLinkLocalIPv4(ip: string): boolean { + const octets = parseIPv4(ip) + if (!octets) return false + const [first, second] = octets + return first === 169 && second === 254 +} + +function isPrivateIPv4(ip: string): boolean { + const octets = parseIPv4(ip) + if (!octets) return false + const [first, second] = octets + + if (first === 10) return true + if (first === 192 && second === 168) return true + return first === 172 && second >= 16 && second <= 31 +} + +function parseIPv4(value: string): number[] | null { + if (!isIPv4Address(value)) return null + return value.split(".").map((part) => Number(part)) +} + function isIPv4Address(value: string | undefined): value is string { if (!value) return false const parts = value.split(".") 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 da8ef383..9f28619c 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog" import { Switch } from "@kobalte/core/switch" import { For, Show, createEffect, createMemo, createSignal } from "solid-js" import { toDataURL } from "qrcode" -import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" +import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" import { restartCli } from "../lib/native/cli" @@ -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 { @@ -326,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { {t("remoteAccess.addresses.loading")}}> {error()}}> - 0} fallback={
{t("remoteAccess.addresses.none")}
}> + {t("remoteAccess.addresses.none")}}>
{(url) => { @@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { ) }} - - {(address) => { + + {(addressAccessor) => { + const address = addressAccessor() const url = address.remoteUrl const expandedState = () => expandedUrl() === url const qr = () => qrCodes()[url] @@ -384,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { : address.scope === "loopback" ? t("remoteAccess.address.scope.loopback") : t("remoteAccess.address.scope.internal") + return (

{url}

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

@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
) }} - + + + 0}> +
+ + + +
+ + {(address) => { + 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} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+
+
+
+
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 52dd65fc..06a81aee 100644 --- a/packages/ui/src/components/settings/remote-access-settings-section.tsx +++ b/packages/ui/src/components/settings/remote-access-settings-section.tsx @@ -1,7 +1,7 @@ import { Switch } from "@kobalte/core/switch" import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js" import { toDataURL } from "qrcode" -import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" +import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types" import { serverApi } from "../../lib/api-client" import { restartCli } from "../../lib/native/cli" @@ -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 { @@ -218,31 +221,35 @@ export const RemoteAccessSettingsSection: Component = () => { fallback={
{t("remoteAccess.authStatus.unavailable")}
} >
-

{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}

-

- {authStatus()!.passwordUserProvided - ? t("remoteAccess.password.status.set") - : t("remoteAccess.password.status.unset")} -

+
+
+

{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}

+

+ {authStatus()!.passwordUserProvided + ? t("remoteAccess.password.status.set") + : t("remoteAccess.password.status.unset")} +

+
-
- +
+ +
- +
{ {t("remoteAccess.addresses.loading")}
}> {error()}
}> 0 || meta()?.localUrl} + when={Boolean(displayAddresses().recommended) || meta()?.localUrl} fallback={
{t("remoteAccess.addresses.none")}
} >
@@ -342,8 +349,9 @@ export const RemoteAccessSettingsSection: Component = () => { }} - - {(address) => { + + {(addressAccessor) => { + const address = addressAccessor() const url = address.remoteUrl const expandedState = () => expandedUrl() === url const qr = () => qrCodes()[url] @@ -383,7 +391,11 @@ export const RemoteAccessSettingsSection: Component = () => {
@@ -391,7 +403,80 @@ export const RemoteAccessSettingsSection: Component = () => {
) }} - +
+ + 0}> +
+ + + +
+ + {(address) => { + 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} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+
+
+
+
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), + } +} diff --git a/packages/ui/src/styles/components/remote-access.css b/packages/ui/src/styles/components/remote-access.css index feee3fbc..be7b8ad0 100644 --- a/packages/ui/src/styles/components/remote-access.css +++ b/packages/ui/src/styles/components/remote-access.css @@ -256,6 +256,55 @@ cursor: pointer; } +.remote-address-disclosure { + border: 1px solid var(--border-base); + border-radius: 12px; + background: var(--surface-primary); + overflow: hidden; +} + +.remote-address-disclosure-trigger { + width: 100%; + min-height: 40px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 8px 12px; + border: 0; + background: transparent; + color: var(--text-primary); + cursor: pointer; +} + +.remote-address-disclosure-label { + grid-column: 2; + justify-self: center; + text-align: center; + font-size: 13px; + font-weight: 600; +} + +.remote-address-disclosure-chevron { + grid-column: 3; + justify-self: end; + width: 16px; + height: 16px; + color: var(--text-secondary); + transition: transform 0.2s ease; +} + +.remote-address-disclosure-chevron.is-expanded { + transform: rotate(180deg); +} + +.remote-address-disclosure-content { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 10px 10px; + border-top: 1px solid var(--border-base); +} + .remote-qr { margin-top: 12px; display: flex; diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css index 31805bb9..15ad55d9 100644 --- a/packages/ui/src/styles/components/settings-screen.css +++ b/packages/ui/src/styles/components/settings-screen.css @@ -1,11 +1,12 @@ .settings-screen-frame { - @apply fixed inset-0 z-50 flex items-center justify-center p-4; + @apply fixed inset-0 z-50 flex items-center justify-center px-4; + padding-block: 5dvh; } /* Override .modal-surface (defined later in panels.css). */ .modal-surface.settings-screen-shell { width: min(1120px, 100%); - height: min(88vh, 920px); + height: 100%; max-height: none; display: grid; grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); @@ -278,10 +279,25 @@ font-size: var(--font-size-sm); } +.settings-password-summary-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.settings-password-summary-copy { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + .settings-password-actions { display: flex; - justify-content: flex-start; - margin-top: 0.75rem; + justify-content: flex-end; + margin-top: 0; } .settings-form-group {