diff --git a/Cargo.lock b/Cargo.lock index 1f939a0..f678932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/changelog.d/20261006-lan-address-selector.md b/changelog.d/20261006-lan-address-selector.md new file mode 100644 index 0000000..118b21c --- /dev/null +++ b/changelog.d/20261006-lan-address-selector.md @@ -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. diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index d4471dd..113c946 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -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"] } diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 738f1bd..9b8a71a 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -31,11 +31,25 @@ pub struct Settings { pub mode: Mode, /// The address friends type into their client for this mode. pub host: Option, + /// Independent of the effective host of the current mode. None means automatic LAN detection. + #[serde(default)] + pub lan_address_override: Option, } 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) { + 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"); + } } diff --git a/crates/coa-core/src/net.rs b/crates/coa-core/src/net.rs index b1fb7c5..65df3ef 100644 --- a/crates/coa-core/src/net.rs +++ b/crates/coa-core/src/net.rs @@ -19,6 +19,51 @@ pub fn lan_ip() -> Option { } } +#[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 { + address.parse::().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) -> Result { + 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, automatic: Option) -> Vec { + 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) -> Result> { + 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"))); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e930549..b935cfc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1249,6 +1249,7 @@ async fn play(state: State<'_, AppState>, id: String) -> std::result::Result, + lan_addresses: Vec, exposure: Vec, /// 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, + lan_address_override: Option, use_upnp: bool, ) -> std::result::Result { - 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)?; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index e825ce1..b488be9 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -816,6 +816,14 @@ const de: Partial> = { "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; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d63d4b4..ab204a2 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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; diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index c68bcfa..43fa680 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -816,6 +816,14 @@ const es: Partial> = { "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; diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 6f0ab00..efa4fff 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -816,6 +816,14 @@ const fr: Partial> = { "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; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index bef5677..d9814f0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -835,6 +835,14 @@ const ru: Partial> = { "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; diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 45c397a..6a21a9d 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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; diff --git a/src/lib/api.ts b/src/lib/api.ts index c3f239d..b0b6ff4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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("play", { id }), friendsStatus: (id: string) => invoke("friends_status", { id }), friendsCheckInternet: () => invoke("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("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 }), diff --git a/src/screens/FriendsPage.tsx b/src/screens/FriendsPage.tsx index e180a6e..a7d3059 100644 --- a/src/screens/FriendsPage.tsx +++ b/src/screens/FriendsPage.tsx @@ -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(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) { 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 (
@@ -157,8 +190,28 @@ export function FriendsPage({ serverId }: { serverId: string }) { {m.id === "lan" && ( <> -

{t("fr.yourLan")} {st.lan_ip ?? t("fr.unknown")}

- {st.lan_ip && cur.mode !== m.id && } + + + {lanChoice === "custom" && <> + + setCustomLan(e.target.value)} /> + } + {!lanValid && } + {lanUnavailable &&

{t("fr.lan.unavailable")} {t("fr.lan.unavailableText")}

} + {selectedLan && lanValid && } )} @@ -221,7 +274,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {