diff --git a/crates/coa-core/src/control/direct_ingress.rs b/crates/coa-core/src/control/direct_ingress.rs index 14f6690..3b1970d 100644 --- a/crates/coa-core/src/control/direct_ingress.rs +++ b/crates/coa-core/src/control/direct_ingress.rs @@ -212,7 +212,7 @@ fn handle_auth_client( } done.store(true, Ordering::Relaxed); if let Some(c) = client_shutdown { - let _ = c.shutdown(std::net::Shutdown::Both); + let _ = c.shutdown(std::net::Shutdown::Write); } let _ = t_c2s.join(); @@ -352,6 +352,7 @@ mod tests { fake_realm_list[1..3].copy_from_slice(&body_len.to_le_bytes()); stream.write_all(&fake_realm_list).unwrap(); + let _ = stream.shutdown(std::net::Shutdown::Write); }); let mut client_auth = TcpStream::connect(format!("127.0.0.1:{ingress_auth}")).unwrap(); @@ -422,6 +423,7 @@ mod tests { fake_realm_list[1..3].copy_from_slice(&body_len.to_le_bytes()); stream.write_all(&fake_realm_list).unwrap(); + let _ = stream.shutdown(std::net::Shutdown::Write); }); let mut client_auth = TcpStream::connect(format!("127.0.0.1:{}", ingress.auth_port())).unwrap(); @@ -444,6 +446,7 @@ mod tests { "Must NOT contain actual internal ingress port" ); + drop(client_auth); t_auth.join().unwrap(); } } diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index 9f1bf68..6bb04dc 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -401,10 +401,12 @@ AC>"; #[test] fn a_failed_account_command_is_reported_and_bad_names_never_reach_the_console() { let _lock = serial(); - let (port, _h) = fake_ra("Account not found."); + let (port, h) = fake_ra("Account not found."); let mut ra = Ra::connect_to(port, "u", "p").unwrap(); assert!(ra.set_account_password("x", "newpass9").is_err(), "the name is validated before anything is sent"); assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found")); + drop(ra); + let _ = h.join(); } #[test] @@ -421,9 +423,11 @@ AC>"; #[test] fn duplicate_account_and_input_validation() { let _lock = serial(); - let (port, _h) = fake_ra("Account already exist."); + let (port, h) = fake_ra("Account already exist."); let mut ra = Ra::connect_to(port, "u", "p").unwrap(); assert!(ra.create_account("Player1", "hunter22").unwrap_err().to_string().contains("already taken")); + drop(ra); + let _ = h.join(); for (n, p) in [("ab", "hunter22"), ("bad name", "hunter22"), ("Player1", "short"), ("Player1", "has space1"), ("Player1", "quote\"pw1"), ("Pl\r\nayer", "hunter22"), ("x".repeat(18).as_str(), "hunter22")] { assert!(validate_account(n, p).is_err(), "{n:?} {p:?}"); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 67c2cdb..f3c44db 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,5 +7,12 @@ fn main() { if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } + #[cfg(windows)] + if std::env::var_os("WEBVIEW2_USER_DATA_FOLDER").is_none() { + if let Some(dir) = std::env::var_os("COA_MANAGER_DATA_DIR") { + let wv = std::path::PathBuf::from(dir).join("webview"); + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", wv); + } + } coa_server_manager_lib::run() } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 883c229..7906fe5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -52,9 +52,11 @@ const en = { "nav.overview": "Overview", "nav.bots": "Bots", - "nav.server": "Server", + "nav.server": "Server Configuration", "nav.players": "Accounts", "nav.friends": "Play with Friends", + "fr.tab.servers": "Public Servers", + "fr.tab.lan": "LAN & Direct Connection", "nav.backups": "Backups", "nav.console": "Console", "nav.settings": "Settings", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 078e438..5711684 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -52,9 +52,11 @@ const ru: Partial> = { "realm.experimental": "Экспериментально в Wildcard · игровая совместимость пока не проверена.", "nav.overview": "Обзор", "nav.bots": "Боты", - "nav.server": "Сервер", + "nav.server": "Конфигурация сервера", "nav.players": "Аккаунты", "nav.friends": "Игра с друзьями", + "fr.tab.servers": "Список серверов", + "fr.tab.lan": "Локальная сеть", "nav.backups": "Резервные копии", "nav.console": "Консоль", "nav.settings": "Настройки", diff --git a/src/screens/BrowsePage.tsx b/src/screens/BrowsePage.tsx index b58b1eb..0688bfa 100644 --- a/src/screens/BrowsePage.tsx +++ b/src/screens/BrowsePage.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useI18n, type Key } from "@/i18n"; import { asUiError } from "@/lib/api"; -import { realmRegistry, useRegistryStatus } from "@/lib/registry"; +import { useRegistryStatus } from "@/lib/registry"; import { asPortableError, portable, usePortable, type PreflightView } from "@/lib/portable"; import { Notes, Projection } from "@/screens/PortablePage"; import { JoinPanel } from "@/screens/JoinPanel"; @@ -22,9 +22,10 @@ function ModuleChip({ m, catalog }: { m: ModuleEntry; catalog: Catalog }) { const known = catalog[m.id]; const name = known?.name ?? m.id; const description = known ? known.description[locale] ?? known.description.en ?? "" : ""; - return + const titleText = description ? `${name} — ${description}` : name; + return {name} - + {name} {description && {description}} {m.version && {m.version}} @@ -66,7 +67,7 @@ function SortHeader({ id, sort, order, onSort, children, className }: { id: Sort } function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog: Catalog; onClose: () => void }) { - const { t } = useI18n(); + const { t, locale } = useI18n(); const { state, refresh: refreshPortable } = usePortable(); const [detail, setDetail] = useState(null); const [error, setError] = useState(null); @@ -117,8 +118,20 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:

{t("browse.modules")}

- {summary.modules.length === 0 ?

—

:
    - {summary.modules.map((m) =>
  • {catalog[m.id]?.name ?? m.id}{m.version ?? ""}{!m.enabled && ` ${t("browse.moduleOff")}`}
  • )} + {summary.modules.length === 0 ?

    —

    :
      + {summary.modules.map((m) => { + const known = catalog[m.id]; + const desc = known ? (known.description[locale] ?? known.description.en ?? "") : ""; + return ( +
    • +
      + {known?.name ?? m.id} + {m.version ?? ""}{!m.enabled && ` ${t("browse.moduleOff")}`} +
      + {desc &&

      {desc}

      } +
    • + ); + })}
    }
@@ -151,8 +164,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog: export function BrowsePage() { const { t } = useI18n(); - const { status, refresh } = useRegistryStatus(5000); - const [url, setUrl] = useState(""); + const { status } = useRegistryStatus(5000); const [q, setQ] = useState(""); const [ruleset, setRuleset] = useState<"" | "coa" | "wildcard">(""); const [capMin, setCapMin] = useState(""); @@ -174,7 +186,6 @@ export function BrowsePage() { useEffect(() => { void control.status().then((c) => { setPreferred(c.preferred_username); setPreferredSaved(c.preferred_username); }).catch(() => {}); }, []); useEffect(() => { void browse.modules().then((m) => setCatalog(Object.fromEntries(m.map((x) => [x.id, x])))).catch(() => {}); }, []); - useEffect(() => { if (status?.url && url === "") setUrl(status.url); }, [status?.url]); // eslint-disable-line react-hooks/exhaustive-deps const params = useMemo(() => ({ q: q.trim() || undefined, @@ -225,11 +236,7 @@ export function BrowsePage() { -
- setUrl(e.target.value)} placeholder="https://coa-manager.duckdns.org" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2 text-sm" /> - -
- {noUrl &&

{t("browse.noUrl")}

} + {noUrl &&

{t("browse.noUrl")}

}
{ setPreferred(e.target.value); setPreferredNote(null); }} className="w-44 rounded-md border border-line bg-card px-3 py-1.5" data-testid="preferred-name" /> diff --git a/src/screens/JoinPanel.tsx b/src/screens/JoinPanel.tsx index 5601ad5..1008a3d 100644 --- a/src/screens/JoinPanel.tsx +++ b/src/screens/JoinPanel.tsx @@ -4,6 +4,8 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useI18n, type Key } from "@/i18n"; import { asControlError, control, type AccountView, type Credentials, type JoinOutcome, type RemoteCharacter } from "@/lib/control"; +import { REMOTE_CLIENT_ID } from "@/lib/api"; +import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog"; import { Notes } from "@/screens/PortablePage"; const STATUS_TEXT: Record = { @@ -121,6 +123,7 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s const [err, setErr] = useState<{ code: string; message: string } | null>(null); const [linking, setLinking] = useState(false); const [bring, setBring] = useState(false); + const [clientDialog, setClientDialog] = useState(null); const refresh = () => control.account(realm).then(setAccount).catch(() => setAccount(null)); useEffect(() => { setOutcome(null); setErr(null); setLinking(false); void refresh(); }, [realm]); // eslint-disable-line react-hooks/exhaustive-deps @@ -130,6 +133,7 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s const o = await control.join(realm, bring && character ? character : null, true); setOutcome(o); if (o.status === "needs_link") setLinking(true); + if (o.status === "needs_client") setClientDialog("setup"); await refresh(); } catch (e) { setErr(asControlError(e)); } finally { setBusy(false); } } @@ -145,6 +149,11 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s {!account && automatic && !linking && } {outcome && text &&

{t(text)}

+ {outcome.status === "needs_client" && ( + + )} {outcome.account_created &&

{t("join.created", { name: outcome.username ?? "" })}

} {outcome.password_reset &&

{t("join.reset")}

} {outcome.notes.length > 0 && } @@ -152,5 +161,16 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s {err && }
+ {clientDialog && ( + setClientDialog(null)} + onChanged={() => { + setClientDialog(null); + void join(); + }} + /> + )}
; } diff --git a/src/screens/PlayWithFriends.tsx b/src/screens/PlayWithFriends.tsx new file mode 100644 index 0000000..231e42a --- /dev/null +++ b/src/screens/PlayWithFriends.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { Globe, Network } from "lucide-react"; +import { useT } from "@/i18n"; +import { cn } from "@/lib/utils"; +import { BrowsePage } from "@/screens/BrowsePage"; +import { FriendsPage } from "@/screens/FriendsPage"; + +export function PlayWithFriends({ serverId }: { serverId: string; path?: string }) { + const t = useT(); + const [tab, setTab] = useState<"servers" | "lan">("servers"); + + return ( +
+
+ + +
+ + {tab === "servers" && } + {tab === "lan" && } +
+ ); +} diff --git a/src/screens/PublishCard.tsx b/src/screens/PublishCard.tsx index 07cac15..3118a47 100644 --- a/src/screens/PublishCard.tsx +++ b/src/screens/PublishCard.tsx @@ -17,7 +17,6 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server const { status, refresh } = useRegistryStatus(); const mine = status?.realms.find((r) => r.local_id === localId); const published = !!mine?.enabled; - const [url, setUrl] = useState(""); const [name, setName] = useState(serverName); const [description, setDescription] = useState(""); const [language, setLanguage] = useState("en"); @@ -43,7 +42,6 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server try { await control.setAccess(localId, existingOnly, route.trim() === "" ? null : route.trim()); await refresh(); setAccessSaved(true); } catch (e) { setError(asControlError(e).message); } finally { setBusy(false); } } - useEffect(() => { if (status && status.url !== null) setUrl((u) => (u === "" ? status.url ?? "" : u)); }, [status?.url]); useEffect(() => { if (mine && mine.display_name) { setName(mine.display_name); setDescription(mine.description); setLanguage(mine.language || "en"); setRegion(mine.region ?? ""); } }, [mine?.display_name, mine?.description, mine?.language]); @@ -61,11 +59,6 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server

{t("registry.title")}

{t("registry.text")}

- -
- setUrl(e.target.value)} placeholder="https://coa-manager.duckdns.org" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2" /> - -
{!published && <> diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index ca84928..900e875 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -10,7 +10,7 @@ import { SettingsPage } from "@/screens/SettingsPage"; import { BackupsPage } from "@/screens/BackupsPage"; import { PlayersPage } from "@/screens/PlayersPage"; import { SettingsHome } from "@/screens/SettingsHome"; -import { FriendsPage } from "@/screens/FriendsPage"; +import { PlayWithFriends } from "@/screens/PlayWithFriends"; import { ReportPage } from "@/screens/ReportPage"; import { ModulesPage } from "@/screens/ModulesPage"; import { ConsolePage } from "@/screens/ConsolePage"; @@ -143,7 +143,7 @@ export function Shell(props: { ) : page === "report" ? ( ) : page === "friends" ? ( - + ) : page === "settings" ? ( props.onForget(server.id)} /> ) : page === "characters" ? (