fix(server): show sane remote URLs for 0.0.0.0 binds (#262)
Closes #261 ## Summary - improve startup remote URL selection when the server binds to `0.0.0.0` - print additional reachable remote URLs instead of advertising only the first external address - add targeted tests for address ordering and advertisability behavior ## Problem When CodeNomad was started with `--host 0.0.0.0`, the CLI chose the first external IPv4 address it discovered and displayed only that one as the remote URL. On Windows machines with WSL, Hyper-V, Docker, or other virtual adapters, that often surfaced a virtual `172.x.x.x` address even though a more useful LAN address such as `192.168.x.x` was also reachable and usable from other devices. That made remote access look broken or confusing even though the server itself was accessible. ## What changed - reuse the resolved network-address list for both: - primary remote URL selection - startup logging of additional reachable URLs - choose the primary remote URL from the **advertisable** external addresses instead of any external address - print `Other Accessible URLs` when multiple useful remote URLs are available - avoid hard-coding a preference like `192.168 > 10 > 172` - suppress link-local `169.254.*` addresses from user-facing advertised URLs - add tests covering: - stable ordering across RFC1918 address ranges - link-local addresses being non-advertisable - link-local-first discovery not stealing the primary LAN URL ## Why this approach This keeps address derivation in the network-address resolver layer and limits `index.ts` to startup wiring and presentation. It also fixes the misleading terminal output without redesigning binding behavior, TLS behavior, or the server API contract. ## Validation - `npm run typecheck --workspace @neuralnomads/codenomad` - `npx tsx --test '.\\src\\server\\__tests__\\network-addresses.test.ts'` ## Notes - this change is intentionally focused on selection and presentation of reachable addresses - it does not attempt a broader virtual-adapter classification policy beyond suppressing clearly low-value link-local addresses in user-facing output --------- Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
@@ -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<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
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) {
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<div class="remote-address-list">
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
@@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(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 (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(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 (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
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={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||
>
|
||||
<div class="settings-card-content">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
<div class="settings-password-summary-row">
|
||||
<div class="settings-password-summary-copy">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="settings-form-group">
|
||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||
<input
|
||||
@@ -292,7 +299,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show
|
||||
when={displayAddresses().length > 0 || meta()?.localUrl}
|
||||
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
|
||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||
>
|
||||
<div class="remote-address-list">
|
||||
@@ -342,8 +349,9 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(addressAccessor) => {
|
||||
const address = addressAccessor()
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
@@ -383,7 +391,11 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -391,7 +403,80 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(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 (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "פנימי",
|
||||
|
||||
@@ -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": "内部",
|
||||
|
||||
@@ -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": "Внутренний",
|
||||
|
||||
@@ -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": "内部",
|
||||
|
||||
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
})
|
||||
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user