Let the person choose and keep the LAN address friends connect to

Sharing on the local network used to take whatever address the Manager found
first, which is often a virtual adapter. The Friends page now lists the usable
IPv4 addresses of the computer (the automatic one marked) and accepts a custom
one. The choice is kept independently of the sharing mode, so it survives
restarts and switching to other modes. A chosen address that is not available
is reported instead of silently switching to another adapter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-07 06:44:34 +05:00
1 parent 3f700ee8f4
commit 8490dc9983
14 files changed
+292 -17

No files matched your search

Generated
+11
View File
@@ -499,6 +499,7 @@ dependencies = [
"ed25519-dalek",
"fs4",
"hex",
"if-addrs",
"num-bigint",
"rand_core",
"reqwest 0.12.28",
@@ -1913,6 +1914,16 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "if-addrs"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Choose and remember the local network address friends use to connect
---
Select an adapter or custom IPv4 address when sharing on LAN. Your choice survives restarts and mode changes; unavailable addresses show a warning instead of silently switching adapters.
+1
View File
@@ -25,6 +25,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
sha1 = "0.10"
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
tempfile = "3"
if-addrs = "0.15"
[dev-dependencies]
rand_core = { version = "0.6", features = ["getrandom"] }
+64 -3
View File
@@ -31,11 +31,25 @@ pub struct Settings {
pub mode: Mode,
/// The address friends type into their client for this mode.
pub host: Option<String>,
/// Independent of the effective host of the current mode. None means automatic LAN detection.
#[serde(default)]
pub lan_address_override: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Settings { mode: Mode::Local, host: None }
Settings { mode: Mode::Local, host: None, lan_address_override: None }
}
}
impl Settings {
/// Only LAN application changes the LAN preference. Other modes keep it for the next LAN session.
pub fn select_mode(&mut self, mode: Mode, host: String, lan_override: Option<String>) {
if mode == Mode::Lan {
self.lan_address_override = lan_override;
}
self.mode = mode;
self.host = Some(host);
}
}
@@ -233,7 +247,7 @@ CoA.AllowRemoteClients = 0
fn a_shared_server_gets_its_bind_address_back_before_start_and_a_local_one_is_left_alone() {
let (_d, root, meta) = setup();
assert!(!ensure_bind(&root, &meta).unwrap(), "local mode: nothing to do");
save(&meta, &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()) }).unwrap();
save(&meta, &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()), ..Settings::default() }).unwrap();
assert!(ensure_bind(&root, &meta).unwrap(), "templates were reset to 127.0.0.1 -> reopened");
assert!(bind_is_open(&root));
assert!(!ensure_bind(&root, &meta).unwrap(), "already open: idempotent");
@@ -243,11 +257,39 @@ CoA.AllowRemoteClients = 0
fn settings_round_trip_and_default_to_local() {
let d = tempfile::tempdir().unwrap();
assert_eq!(load(d.path()).mode, Mode::Local);
save(d.path(), &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()) }).unwrap();
save(d.path(), &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()), ..Settings::default() }).unwrap();
let s = load(d.path());
assert_eq!((s.mode, s.host.as_deref()), (Mode::Private, Some("100.64.1.2")));
}
#[test]
fn old_settings_load_without_migrating_effective_host_to_override() {
let s: Settings = serde_json::from_str(r#"{"mode":"lan","host":"192.168.1.50"}"#).unwrap();
assert_eq!(s.mode, Mode::Lan);
assert_eq!(s.host.as_deref(), Some("192.168.1.50"));
assert_eq!(s.lan_address_override, None);
}
#[test]
fn lan_preference_survives_save_load_and_every_other_mode_and_can_be_cleared() {
let d = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.select_mode(Mode::Lan, "192.168.1.50".into(), Some("192.168.1.50".into()));
for (mode, host) in [(Mode::Private, "100.101.20.5"), (Mode::Direct, "203.0.113.9"), (Mode::Local, "127.0.0.1")] {
s.select_mode(mode, host.into(), None);
save(d.path(), &s).unwrap();
s = load(d.path());
assert_eq!(s.lan_address_override.as_deref(), Some("192.168.1.50"));
assert_eq!(s.host.as_deref(), Some(host));
let lan = crate::net::resolve_lan_host(s.lan_address_override.as_deref(), Some("192.168.0.169".parse().unwrap())).unwrap();
s.select_mode(Mode::Lan, lan, s.lan_address_override.clone());
assert_eq!(s.host.as_deref(), Some("192.168.1.50"));
}
s.select_mode(Mode::Lan, "192.168.0.169".into(), None);
save(d.path(), &s).unwrap();
assert_eq!(load(d.path()).lan_address_override, None);
}
#[test]
fn friend_package_contains_only_whitelisted_files() {
let (d, root, _m) = setup();
@@ -274,4 +316,23 @@ CoA.AllowRemoteClients = 0
}
assert!(make_friend_package(&root, "bad host; x", false, &d.path().join("x.zip")).is_err());
}
#[test]
fn manual_lan_host_survives_startup_bind_restoration_and_drives_friend_package() {
let (d, root, meta) = setup();
let mut s = Settings::default();
s.select_mode(Mode::Lan, "192.168.1.50".into(), Some("192.168.1.50".into()));
save(&meta, &s).unwrap();
assert!(ensure_bind(&root, &meta).unwrap());
assert!(bind_is_open(&root));
let restored = load(&meta);
assert_eq!(restored.host.as_deref(), Some("192.168.1.50"));
assert_eq!(restored.lan_address_override.as_deref(), Some("192.168.1.50"));
let package = d.path().join("manual-lan.zip");
make_friend_package(&root, restored.host.as_deref().unwrap(), false, &package).unwrap();
let mut zip = zip::ZipArchive::new(fs::File::open(package).unwrap()).unwrap();
let mut realm = String::new();
std::io::Read::read_to_string(&mut zip.by_name("realmlist.wtf").unwrap(), &mut realm).unwrap();
assert_eq!(realm, "set realmlist 192.168.1.50\r\n");
}
}
+81
View File
@@ -19,6 +19,51 @@ pub fn lan_ip() -> Option<Ipv4Addr> {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LanAddress {
pub interface: String,
pub address: String,
pub is_default: bool,
}
/// Suitable unicast IPv4 addresses; shared carrier/Tailscale space belongs to Private mode.
pub fn is_lan_address(ip: Ipv4Addr) -> bool {
!ip.is_unspecified() && !ip.is_loopback() && !ip.is_link_local()
&& !ip.is_multicast() && !ip.is_broadcast() && ip.octets()[0] != 0
&& ip.octets()[0] < 240 && !is_cgnat_range(ip)
}
pub fn validate_lan_address(address: &str) -> Result<Ipv4Addr> {
address.parse::<Ipv4Addr>().ok().filter(|ip| is_lan_address(*ip))
.ok_or_else(|| Error::Invalid("Invalid IPv4 address for local network.".into()))
}
/// An absent/disconnected manual address is intentional: never fall back to another adapter.
pub fn resolve_lan_host(manual: Option<&str>, automatic: Option<Ipv4Addr>) -> Result<String> {
match manual {
Some(address) => validate_lan_address(address).map(|ip| ip.to_string()),
None => automatic.filter(|ip| is_lan_address(*ip)).map(|ip| ip.to_string())
.ok_or_else(|| Error::Invalid("This computer has no usable local network address.".into())),
}
}
fn lan_candidates(rows: impl IntoIterator<Item = (String, Ipv4Addr)>, automatic: Option<Ipv4Addr>) -> Vec<LanAddress> {
let mut rows: Vec<_> = rows.into_iter().filter(|(_, ip)| is_lan_address(*ip)).collect();
rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut seen = std::collections::HashSet::new();
rows.into_iter().filter(|(_, ip)| seen.insert(*ip)).map(|(interface, ip)| LanAddress {
interface, address: ip.to_string(), is_default: Some(ip) == automatic,
}).collect()
}
/// Native OS enumeration (GetAdaptersAddresses on Windows, getifaddrs on Unix).
pub fn lan_addresses(automatic: Option<Ipv4Addr>) -> Result<Vec<LanAddress>> {
let interfaces = if_addrs::get_if_addrs()?;
Ok(lan_candidates(interfaces.into_iter().filter(|i| i.is_oper_up()).filter_map(|i| {
match i.ip() { IpAddr::V4(ip) => Some((i.name, ip)), _ => None }
}), automatic))
}
pub fn is_private(ip: Ipv4Addr) -> bool {
ip.is_private() || ip.is_loopback() || ip.is_link_local()
}
@@ -132,6 +177,42 @@ mod tests {
s.parse().unwrap()
}
#[test]
fn lan_validation_and_resolution_never_replace_manual_addresses() {
for good in ["192.168.1.20", "10.0.0.5", "172.16.2.4"] {
assert!(validate_lan_address(good).is_ok());
}
for bad in ["abc", "999.1.1.1", "0.0.0.0", "0.1.2.3", "127.0.0.1", "127.9.1.2", "169.254.10.20", "100.101.20.5", "224.0.0.1", "255.255.255.255", "240.0.0.1", "192.168.1.1'; DROP TABLE realmlist"] {
assert!(validate_lan_address(bad).is_err(), "{bad}");
}
let automatic = Some(ip("192.168.0.169"));
assert_eq!(resolve_lan_host(None, automatic).unwrap(), "192.168.0.169");
assert_eq!(resolve_lan_host(Some("192.168.1.50"), automatic).unwrap(), "192.168.1.50");
assert_eq!(resolve_lan_host(Some("192.168.1.50"), None).unwrap(), "192.168.1.50");
assert!(resolve_lan_host(Some("invalid"), automatic).is_err());
assert!(resolve_lan_host(None, None).is_err());
assert!(resolve_lan_host(None, Some(ip("169.254.1.2"))).is_err());
assert!(resolve_lan_host(None, Some(ip("100.100.1.2"))).is_err());
}
#[test]
fn candidates_are_filtered_deduplicated_deterministic_and_default_marked() {
let rows = vec![
("Wi-Fi", "192.168.0.25"), ("Ethernet 2", "192.168.1.50"),
("Ethernet", "192.168.0.169"), ("Duplicate", "192.168.1.50"),
("Loopback", "127.0.0.2"), ("APIPA", "169.254.1.2"),
("Tailscale", "100.101.20.5"), ("Empty", "0.0.0.0"),
("Hyper-V Virtual", "10.0.0.5"),
];
let make = |rows: Vec<(&str, &str)>| lan_candidates(rows.into_iter().map(|(name, address)| (name.into(), ip(address))), Some(ip("192.168.0.169")));
let result = make(rows.clone());
assert_eq!(result.len(), 4);
assert_eq!(result.iter().filter(|a| a.is_default).count(), 1);
assert!(result.iter().any(|a| a.interface == "Hyper-V Virtual"));
assert!(result.iter().any(|a| a.is_default && a.address == "192.168.0.169"));
assert_eq!(result, make(rows.into_iter().rev().collect()));
}
#[test]
fn address_classes() {
assert!(is_private(ip("192.168.1.5")) && is_private(ip("10.0.0.2")) && is_private(ip("172.16.4.4")) && is_private(ip("127.0.0.1")));
+10 -4
View File
@@ -1249,6 +1249,7 @@ async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<Dri
struct FriendsStatus {
settings: coa_core::friends::Settings,
lan_ip: Option<String>,
lan_addresses: Vec<coa_core::net::LanAddress>,
exposure: Vec<coa_core::net::Exposure>,
/// The configuration lets other computers reach the login and world servers.
servers_open: bool,
@@ -1328,9 +1329,11 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result::
blocking(move || {
let meta = meta_dir(&root)?;
let ports = layout::read_ports(&root);
let automatic = coa_core::net::lan_ip().filter(|ip| coa_core::net::is_lan_address(*ip));
Ok(FriendsStatus {
settings: coa_core::friends::load(&meta),
lan_ip: coa_core::net::lan_ip().map(|a| a.to_string()),
lan_ip: automatic.map(|a| a.to_string()),
lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(),
exposure: coa_core::net::exposure(&ports),
servers_open: coa_core::friends::bind_is_open(&root),
firewall: coa_core::firewall::status(),
@@ -1383,9 +1386,10 @@ async fn friends_enable(
id: String,
mode: coa_core::friends::Mode,
host: Option<String>,
lan_address_override: Option<String>,
use_upnp: bool,
) -> std::result::Result<FriendsResult, UiError> {
use coa_core::friends::{self, Mode, Settings};
use coa_core::friends::{self, Mode};
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
@@ -1396,7 +1400,7 @@ async fn friends_enable(
let secondary = realms.secondary_world_port.filter(|_| realms.simultaneous);
let host = match mode {
Mode::Local => "127.0.0.1".to_string(),
Mode::Lan => coa_core::net::lan_ip().ok_or_else(|| Error::Invalid("This computer has no network address.".into()))?.to_string(),
Mode::Lan => coa_core::net::resolve_lan_host(lan_address_override.as_deref(), coa_core::net::lan_ip())?,
Mode::Direct => host.filter(|h| !h.is_empty()).ok_or_else(|| Error::Invalid("Check your connection first to learn your public address.".into()))?,
Mode::Private => coa_core::net::tailscale().ip.ok_or_else(|| Error::Invalid("Tailscale is not connected. Install it, sign in, then try again.".into()))?,
};
@@ -1417,7 +1421,9 @@ async fn friends_enable(
None => note = Some("Your router does not support automatic setup; forward the two game ports by hand or use the private network.".to_string()),
}
}
friends::save(&meta, &Settings { mode, host: Some(host.clone()) })?;
let mut settings = friends::load(&meta);
settings.select_mode(mode, host.clone(), lan_address_override);
friends::save(&meta, &settings)?;
let running = coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running;
if running {
friends::apply_realm_address(&root, &host)?;
+8
View File
@@ -816,6 +816,14 @@ const de: Partial<Record<keyof typeof en, string>> = {
"upd.current": "Du bist auf dem neuesten Stand (Version {v}).",
"client.job.checking": "Client wird geprüft…",
"client.job.updating": "Client wird aktualisiert",
"fr.lan.address": "Netzwerkadresse",
"fr.lan.automatic": "Automatisch",
"fr.lan.default": "Standard",
"fr.lan.custom": "Eigene Adresse…",
"fr.lan.customIpv4": "Eigene IPv4-Adresse",
"fr.lan.unavailable": "Ausgewählte Adresse nicht verfügbar",
"fr.lan.unavailableText": "Die ausgewählte Adresse ist derzeit keinem erkannten lokalen Netzwerkadapter zugewiesen. Wählen Sie eine andere Adresse oder Automatisch, oder behalten Sie diese Adresse bewusst bei.",
"fr.lan.invalid": "Ungültige IPv4-Adresse",
};
export default de;
+8
View File
@@ -837,6 +837,14 @@ const en = {
"upd.current": "You are up to date (version {v}).",
"client.job.checking": "Checking the client…",
"client.job.updating": "Updating the client",
"fr.lan.address": "Network address",
"fr.lan.automatic": "Automatic",
"fr.lan.default": "Default",
"fr.lan.custom": "Custom address…",
"fr.lan.customIpv4": "Custom IPv4 address",
"fr.lan.unavailable": "Selected address unavailable",
"fr.lan.unavailableText": "The selected address is not currently assigned to a detected local interface. Choose another address or switch to Automatic, or keep this address intentionally.",
"fr.lan.invalid": "Invalid IPv4 address",
} as const;
export default en;
+8
View File
@@ -816,6 +816,14 @@ const es: Partial<Record<keyof typeof en, string>> = {
"upd.current": "Estás al día (versión {v}).",
"client.job.checking": "Comprobando el cliente…",
"client.job.updating": "Actualizando el cliente",
"fr.lan.address": "Dirección de red",
"fr.lan.automatic": "Automática",
"fr.lan.default": "Predeterminada",
"fr.lan.custom": "Dirección personalizada…",
"fr.lan.customIpv4": "Dirección IPv4 personalizada",
"fr.lan.unavailable": "Dirección seleccionada no disponible",
"fr.lan.unavailableText": "La dirección seleccionada no está asignada actualmente a ninguna interfaz local detectada. Elige otra dirección o Automática, o conserva esta dirección si así lo deseas.",
"fr.lan.invalid": "Dirección IPv4 no válida",
};
export default es;
+8
View File
@@ -816,6 +816,14 @@ const fr: Partial<Record<keyof typeof en, string>> = {
"upd.current": "Vous êtes à jour (version {v}).",
"client.job.checking": "Vérification du client…",
"client.job.updating": "Mise à jour du client",
"fr.lan.address": "Adresse réseau",
"fr.lan.automatic": "Automatique",
"fr.lan.default": "Par défaut",
"fr.lan.custom": "Adresse personnalisée…",
"fr.lan.customIpv4": "Adresse IPv4 personnalisée",
"fr.lan.unavailable": "Adresse sélectionnée indisponible",
"fr.lan.unavailableText": "Cette adresse n’est actuellement attribuée à aucune interface locale détectée. Choisissez une autre adresse ou le mode Automatique, ou conservez volontairement cette adresse.",
"fr.lan.invalid": "Adresse IPv4 non valide",
};
export default fr;
+8
View File
@@ -835,6 +835,14 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"upd.current": "У вас последняя версия ({v}).",
"client.job.checking": "Проверка клиента…",
"client.job.updating": "Обновление клиента",
"fr.lan.address": "Сетевой адрес",
"fr.lan.automatic": "Автоматически",
"fr.lan.default": "По умолчанию",
"fr.lan.custom": "Другой адрес…",
"fr.lan.customIpv4": "Свой IPv4-адрес",
"fr.lan.unavailable": "Выбранный адрес недоступен",
"fr.lan.unavailableText": "Выбранный адрес сейчас не назначен обнаруженному сетевому интерфейсу. Выберите другой адрес, переключитесь на автоматический выбор или намеренно сохраните этот адрес.",
"fr.lan.invalid": "Некорректный IPv4-адрес",
};
export default ru;
+8
View File
@@ -818,6 +818,14 @@ const zh = {
"client.job.updating": "正在更新游戏客户端",
"err.docker_unavailable.title": "Docker 不可用",
"err.docker_unavailable.message": "此服务器通过 Docker 运行。请安装并启动 Docker,确保当前用户有权使用它,然后重试。",
"fr.lan.address": "网络地址",
"fr.lan.automatic": "自动",
"fr.lan.default": "默认",
"fr.lan.custom": "自定义地址…",
"fr.lan.customIpv4": "自定义 IPv4 地址",
"fr.lan.unavailable": "所选地址不可用",
"fr.lan.unavailableText": "所选地址目前未分配给检测到的本地网络接口。请选择其他地址、切换到自动,或有意保留此地址。",
"fr.lan.invalid": "无效的 IPv4 地址",
} as const;
export default zh;
+10 -3
View File
@@ -348,9 +348,16 @@ export interface ClientDownloadCheck {
export type FriendsMode = "local" | "lan" | "direct" | "private";
export interface LanAddress {
interface: string;
address: string;
is_default: boolean;
}
export interface FriendsStatus {
settings: { mode: FriendsMode; host: string | null };
settings: { mode: FriendsMode; host: string | null; lan_address_override: string | null };
lan_ip: string | null;
lan_addresses: LanAddress[];
exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[];
servers_open: boolean;
firewall: { auth: boolean; world: boolean };
@@ -456,8 +463,8 @@ export const api = {
play: (id: string) => invoke<DriverOutcome>("play", { id }),
friendsStatus: (id: string) => invoke<FriendsStatus>("friends_status", { id }),
friendsCheckInternet: () => invoke<InternetCheck>("friends_check_internet"),
friendsEnable: (id: string, mode: FriendsMode, host?: string, useUpnp = false) =>
invoke<{ host: string; restart_required: boolean; note: string | null }>("friends_enable", { id, mode, host: host ?? null, useUpnp }),
friendsEnable: (id: string, mode: FriendsMode, host?: string, useUpnp = false, lanAddressOverride: string | null = null) =>
invoke<{ host: string; restart_required: boolean; note: string | null }>("friends_enable", { id, mode, host: host ?? null, useUpnp, lanAddressOverride }),
friendsPackage: (id: string) => invoke<string>("friends_package", { id }),
runDiagnostics: (id: string) => invoke<{ checks: DiagCheck[]; problems: number }>("run_diagnostics", { id }),
verifyFiles: (id: string) => invoke<{ path: string; kind: "missing" | "changed" }[]>("verify_files", { id }),
+60 -7
View File
@@ -5,6 +5,15 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useHuman, useT, type Key } from "@/i18n";
/** Convenience only; Rust validates again before applying or saving. */
function validLanIpv4(value: string): boolean {
if (!/^(0|[1-9]\d{0,2})(\.(0|[1-9]\d{0,2})){3}$/.test(value)) return false;
const octets = value.split(".").map(Number);
const [a, b] = octets;
return octets.every((n) => n <= 255) && a > 0 && a < 224 && a !== 127
&& !(a === 169 && b === 254) && !(a === 100 && b >= 64 && b <= 127);
}
function Line({ ok, children, warn }: { ok: boolean; warn?: boolean; children: React.ReactNode }) {
const t = useT();
return (
@@ -81,11 +90,28 @@ export function FriendsPage({ serverId }: { serverId: string }) {
const [note, setNote] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [needsRestart, setNeedsRestart] = useState(false);
const [lanChoice, setLanChoice] = useState("automatic");
const [customLan, setCustomLan] = useState("");
const refresh = useCallback(() => api.friendsStatus(serverId).then(setSt).catch((e) => setError(asUiError(e))), [serverId]);
const refresh = useCallback(() => api.friendsStatus(serverId).then((status) => {
setSt(status);
}).catch((e) => setError(asUiError(e))), [serverId]);
useEffect(() => {
void refresh();
}, [refresh]);
let cancelled = false;
setSt(null);
setNet(null);
void api.friendsStatus(serverId).then((status) => {
if (cancelled) return;
const saved = status.settings.lan_address_override;
setCustomLan(saved ?? "");
setLanChoice(saved === null ? "automatic" : status.lan_addresses.some((a) => a.address === saved) ? saved : "custom");
setSt(status);
}).catch((e) => { if (!cancelled) setError(asUiError(e)); });
return () => { cancelled = true; };
}, [serverId]);
const lanOverride = lanChoice === "automatic" ? null : lanChoice === "custom" ? customLan.trim() : lanChoice;
const lanValid = lanOverride === null || validLanIpv4(lanOverride);
async function run(label: string, fn: () => Promise<string | void>) {
setBusy(label);
@@ -109,7 +135,12 @@ export function FriendsPage({ serverId }: { serverId: string }) {
const enable = (mode: FriendsMode) =>
run(`enable-${mode}`, async () => {
const r = await api.friendsEnable(serverId, mode, net?.public_ip ?? undefined, mode === "direct" && !!net?.router_found);
const r = await api.friendsEnable(
serverId, mode,
mode === "direct" ? net?.public_ip ?? undefined : undefined,
mode === "direct" && !!net?.router_found,
mode === "lan" ? lanOverride : null,
);
setNeedsRestart(r.restart_required);
return [r.restart_required ? t("fr.restartToApply") : null, r.note ? (r.note.startsWith("Your router was asked") ? t("fr.note.forwarded") : r.note.startsWith("Your router does not support") ? t("fr.note.manual") : r.note) : null].filter(Boolean).join(" ") || t("fr.ready");
});
@@ -119,6 +150,8 @@ export function FriendsPage({ serverId }: { serverId: string }) {
const cur = st.settings;
const exposed = st.exposure.filter((e) => (e.what === "database" || e.what === "server console") && e.reachable_from_network);
const connection = cur.mode === "local" ? null : `set realmlist ${cur.host}`;
const selectedLan = lanOverride ?? st.lan_ip;
const lanUnavailable = lanOverride !== null && lanValid && !st.lan_addresses.some((a) => a.address === lanOverride);
return (
<div className="max-w-3xl">
@@ -157,8 +190,28 @@ export function FriendsPage({ serverId }: { serverId: string }) {
{m.id === "lan" && (
<>
<p className="mt-2 text-sm">{t("fr.yourLan")} <b className="selectable">{st.lan_ip ?? t("fr.unknown")}</b></p>
{st.lan_ip && cur.mode !== m.id && <RealmlistLine host={st.lan_ip} />}
<label htmlFor="lan-address" className="mt-3 block text-sm">{t("fr.lan.address")}</label>
<select id="lan-address" value={lanChoice} disabled={!!busy}
className="mt-1 w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-gold"
onChange={(e) => {
if (e.target.value === "custom" && lanOverride) setCustomLan(lanOverride);
setLanChoice(e.target.value);
}}>
<option value="automatic">{t("fr.lan.automatic")} — {st.lan_ip ?? t("fr.unknown")}</option>
{st.lan_addresses.map((a) => <option key={a.address} value={a.address}>{a.interface} — {a.address}{a.is_default ? ` (${t("fr.lan.default")})` : ""}</option>)}
{lanChoice !== "automatic" && lanChoice !== "custom" && !st.lan_addresses.some((a) => a.address === lanChoice) && <option value={lanChoice}>{lanChoice}</option>}
<option value="custom">{t("fr.lan.custom")}</option>
</select>
{lanChoice === "custom" && <>
<label htmlFor="lan-custom" className="mt-2 block text-sm">{t("fr.lan.customIpv4")}</label>
<input id="lan-custom" value={customLan} disabled={!!busy} spellCheck={false} inputMode="decimal"
aria-invalid={!lanValid} aria-describedby={!lanValid ? "lan-invalid" : undefined}
className="mt-1 w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-gold"
onChange={(e) => setCustomLan(e.target.value)} />
</>}
{!lanValid && <p id="lan-invalid" className="mt-2 text-sm text-bad" role="alert">{t("fr.lan.invalid")}</p>}
{lanUnavailable && <p className="mt-2 text-sm text-warn" role="status"><b>{t("fr.lan.unavailable")}</b> {t("fr.lan.unavailableText")}</p>}
{selectedLan && lanValid && <RealmlistLine host={selectedLan} />}
</>
)}
@@ -221,7 +274,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
<Button
size="sm"
variant={m.id === "private" && net?.reachability === "cgnat" ? "primary" : "secondary"}
disabled={!!busy || (m.id === "direct" && !net?.public_ip) || (m.id === "private" && !st.tailscale.connected)}
disabled={!!busy || (m.id === "lan" && (!lanValid || !selectedLan)) || (m.id === "direct" && !net?.public_ip) || (m.id === "private" && !st.tailscale.connected)}
onClick={() => void enable(m.id)}
title={m.id === "direct" && !net?.public_ip ? t("fr.checkFirst") : undefined}
>