From 0cad78718ab7a0813b0dbf542279d073ca56eb2a Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 3 Sep 2026 18:40:37 +0800 Subject: [PATCH] server protocol & ports UX improvement --- messages/en.json | 3 + messages/zh-CN.json | 3 + src-tauri/src/network/connection.rs | 33 +++++-- src/app.css | 11 ++- .../components/server-endpoint-fields.svelte | 96 +++++++++++++++++++ src/lib/server-endpoint.ts | 64 +++++++++++++ .../control-panel/add/network/+page.svelte | 38 ++++---- .../edit/network/[id]/+page.svelte | 44 ++++----- src/routes/onboarding/+page.svelte | 12 ++- .../onboarding/components/server-step.svelte | 29 ++---- tests/server-endpoint.test.ts | 56 +++++++++++ 11 files changed, 317 insertions(+), 72 deletions(-) create mode 100644 src/lib/components/server-endpoint-fields.svelte create mode 100644 src/lib/server-endpoint.ts create mode 100644 tests/server-endpoint.test.ts diff --git a/messages/en.json b/messages/en.json index 52252e8..a91204a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -22,6 +22,8 @@ "common_name": "Name", "common_status": "Status", "common_address": "Address", + "common_connection_type": "Connection type", + "common_connection_direct": "Direct", "common_optional_port": "Port (optional)", "common_optional_friendly_name": "Friendly name (optional)", "common_server_details": "Server details", @@ -36,6 +38,7 @@ "error_unexpected": "Something went wrong. Please try again.", "error_display_name_empty": "Display name cannot be empty.", "error_server_address_required": "Server address is required.", + "error_server_address_scheme": "Choose the protocol from the menu instead of including it in the address.", "error_port_invalid": "Port must be a whole number from 1 to 65535.", "error_friend_key_required": "Identification Key is required.", "error_server_not_selected": "No server was selected.", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index dc7e9ca..fc63836 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -22,6 +22,8 @@ "common_name": "名称", "common_status": "状态", "common_address": "地址", + "common_connection_type": "连接类型", + "common_connection_direct": "直连", "common_optional_port": "端口(可选)", "common_optional_friendly_name": "备注名称(可选)", "common_server_details": "服务器信息", @@ -36,6 +38,7 @@ "error_unexpected": "发生错误,请重试。", "error_display_name_empty": "显示名称不能为空。", "error_server_address_required": "服务器地址不能为空。", + "error_server_address_scheme": "请从菜单中选择协议,不要将协议写入地址。", "error_port_invalid": "端口必须是 1 到 65535 之间的整数。", "error_friend_key_required": "请输入身份密钥。", "error_server_not_selected": "尚未选择服务器。", diff --git a/src-tauri/src/network/connection.rs b/src-tauri/src/network/connection.rs index af1a52b..55720b8 100644 --- a/src-tauri/src/network/connection.rs +++ b/src-tauri/src/network/connection.rs @@ -397,12 +397,15 @@ fn url(remote: &Remote) -> String { .or_else(|| address.strip_prefix("ws://")) .or_else(|| address.strip_prefix("wss://")) .unwrap_or(address); - let scheme = if remote.address.starts_with("https://") || remote.address.starts_with("wss://") { - "wss" - } else { - "ws" - }; - let port = remote.port.unwrap_or(DEFAULT_SERVER_PORT); + let (scheme, default_port) = + if remote.address.starts_with("https://") || remote.address.starts_with("wss://") { + ("wss", 443) + } else if remote.address.starts_with("http://") || remote.address.starts_with("ws://") { + ("ws", 80) + } else { + ("ws", DEFAULT_SERVER_PORT) + }; + let port = remote.port.unwrap_or(default_port); format!("{scheme}://{address}:{port}/v1/ws") } @@ -421,13 +424,29 @@ mod tests { } #[test] - fn url_uses_default_server_port_when_unspecified() { + fn url_uses_direct_server_port_when_unspecified() { assert_eq!( url(&remote("example.net", None)), "ws://example.net:27520/v1/ws" ); } + #[test] + fn url_uses_https_default_port_when_unspecified() { + assert_eq!( + url(&remote("https://example.net", None)), + "wss://example.net:443/v1/ws" + ); + } + + #[test] + fn url_uses_http_default_port_when_unspecified() { + assert_eq!( + url(&remote("http://example.net", None)), + "ws://example.net:80/v1/ws" + ); + } + #[test] fn url_preserves_explicit_port() { assert_eq!( diff --git a/src/app.css b/src/app.css index 981de40..d73cedf 100644 --- a/src/app.css +++ b/src/app.css @@ -81,11 +81,18 @@ pointer-events: auto; } -button, -input { +button { @apply btn-sm; } +input { + @apply input-sm; +} + +select { + @apply select-sm; +} + @font-face { font-family: "w95f"; src: url("/w95f.woff2") format("woff2"); diff --git a/src/lib/components/server-endpoint-fields.svelte b/src/lib/components/server-endpoint-fields.svelte new file mode 100644 index 0000000..630ed71 --- /dev/null +++ b/src/lib/components/server-endpoint-fields.svelte @@ -0,0 +1,96 @@ + + + +
+ + +
+{#if hasTypedScheme} +

+ {$messages.error_server_address_scheme()} +

+{/if} + + + diff --git a/src/lib/server-endpoint.ts b/src/lib/server-endpoint.ts new file mode 100644 index 0000000..56cc3cc --- /dev/null +++ b/src/lib/server-endpoint.ts @@ -0,0 +1,64 @@ +export type ServerConnectionType = "https" | "http" | "direct"; + +const SCHEME = /^(https?|wss?):\/\//i; + +export function connectionTypeFromScheme(scheme: string): ServerConnectionType { + return scheme.toLowerCase().startsWith("https") || + scheme.toLowerCase().startsWith("wss") + ? "https" + : "http"; +} + +export function splitServerAddress(address: string): { + address: string; + connectionType: ServerConnectionType; +} { + const match = address.match(SCHEME); + if (!match) return { address, connectionType: "direct" }; + return { + address: address.slice(match[0].length), + connectionType: connectionTypeFromScheme(match[1]), + }; +} + +export function storedServerAddress( + address: string, + connectionType: ServerConnectionType, +): string { + const host = address.trim(); + if (connectionType === "direct") return host; + return `${connectionType}://${host}`; +} + +export function containsScheme(address: string): boolean { + return SCHEME.test(address.trim()); +} + +export function pastedServerAddress(value: string): { + address: string; + connectionType: ServerConnectionType; + port: string; +} | null { + const trimmed = value.trim(); + if (!SCHEME.test(trimmed)) return null; + + try { + const url = new URL(trimmed); + if ( + url.username || + url.password || + (url.pathname !== "/" && url.pathname !== "") || + url.search || + url.hash + ) { + return null; + } + return { + address: url.hostname, + connectionType: connectionTypeFromScheme(url.protocol), + port: url.port, + }; + } catch { + return null; + } +} diff --git a/src/routes/control-panel/add/network/+page.svelte b/src/routes/control-panel/add/network/+page.svelte index 1f98669..5ea597b 100644 --- a/src/routes/control-panel/add/network/+page.svelte +++ b/src/routes/control-panel/add/network/+page.svelte @@ -2,11 +2,18 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { commands, type RemoteInput } from "$lib/bindings"; import PanelMessage from "$lib/components/control-panel/panel-message.svelte"; + import ServerEndpointFields from "$lib/components/server-endpoint-fields.svelte"; import { errorMessage, messages } from "$lib/i18n"; + import { + containsScheme, + storedServerAddress, + type ServerConnectionType, + } from "$lib/server-endpoint"; let name = $state(""); let address = $state(""); let port = $state(""); + let connectionType = $state("https"); let busy = $state(false); let error = $state(""); @@ -24,6 +31,10 @@ error = $messages.error_server_address_required(); return null; } + if (containsScheme(address)) { + error = $messages.error_server_address_scheme(); + return null; + } if ( parsedPort !== null && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) @@ -32,7 +43,7 @@ return null; } return { - address: address.trim(), + address: storedServerAddress(address, connectionType), name: name.trim() || null, port: parsedPort, }; @@ -79,28 +90,13 @@ maxlength="64" autocomplete="off" /> - - - - diff --git a/src/routes/control-panel/edit/network/[id]/+page.svelte b/src/routes/control-panel/edit/network/[id]/+page.svelte index b2d1243..2819b1f 100644 --- a/src/routes/control-panel/edit/network/[id]/+page.svelte +++ b/src/routes/control-panel/edit/network/[id]/+page.svelte @@ -4,11 +4,19 @@ import { onMount } from "svelte"; import { commands, type RemoteInput } from "$lib/bindings"; import PanelMessage from "$lib/components/control-panel/panel-message.svelte"; + import ServerEndpointFields from "$lib/components/server-endpoint-fields.svelte"; import { errorMessage, messages } from "$lib/i18n"; + import { + containsScheme, + splitServerAddress, + storedServerAddress, + type ServerConnectionType, + } from "$lib/server-endpoint"; let name = $state(""); let address = $state(""); let port = $state(""); + let connectionType = $state("https"); let loading = $state(true); let busy = $state(false); let available = $state(false); @@ -38,7 +46,9 @@ return; } name = remote.name ?? ""; - address = remote.address; + const endpoint = splitServerAddress(remote.address); + address = endpoint.address; + connectionType = endpoint.connectionType; port = remote.port?.toString() ?? ""; available = true; }) @@ -64,6 +74,10 @@ error = $messages.error_server_address_required(); return null; } + if (containsScheme(address)) { + error = $messages.error_server_address_scheme(); + return null; + } if ( parsedPort !== null && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) @@ -72,7 +86,7 @@ return null; } return { - address: address.trim(), + address: storedServerAddress(address, connectionType), name: name.trim() || null, port: parsedPort, }; @@ -135,28 +149,14 @@ maxlength="64" autocomplete="off" /> - - - - diff --git a/src/routes/onboarding/+page.svelte b/src/routes/onboarding/+page.svelte index 68ad5fc..e50b4f7 100644 --- a/src/routes/onboarding/+page.svelte +++ b/src/routes/onboarding/+page.svelte @@ -20,6 +20,11 @@ import SkinStep from "./components/skin-step.svelte"; import WelcomeStep from "./components/welcome-step.svelte"; import { errorMessage, messages, type MessageCatalog } from "$lib/i18n"; + import { + containsScheme, + storedServerAddress, + type ServerConnectionType, + } from "$lib/server-endpoint"; import { getVersion } from "@tauri-apps/api/app"; type StepId = @@ -99,6 +104,7 @@ let serverName = $state(""); let serverAddress = $state(""); let serverPort = $state(""); + let serverConnectionType = $state("https"); let savedServerId = $state(null); let friendId = $state(""); let savedFriendId = $state(null); @@ -191,6 +197,9 @@ function remoteInput(): RemoteInput | null { if (!serverAddress.trim()) return null; + if (containsScheme(serverAddress)) { + throw new Error($messages.error_server_address_scheme()); + } const port = serverPort ? Number(serverPort) : null; if ( port !== null && @@ -199,7 +208,7 @@ throw new Error($messages.error_port_invalid()); } return { - address: serverAddress.trim(), + address: storedServerAddress(serverAddress, serverConnectionType), name: serverName.trim() || null, port, }; @@ -370,6 +379,7 @@ bind:serverName bind:serverAddress bind:serverPort + bind:serverConnectionType {busy} /> {:else if step.id === "friend"} diff --git a/src/routes/onboarding/components/server-step.svelte b/src/routes/onboarding/components/server-step.svelte index 17b2376..62df88b 100644 --- a/src/routes/onboarding/components/server-step.svelte +++ b/src/routes/onboarding/components/server-step.svelte @@ -1,16 +1,20 @@ @@ -29,25 +33,12 @@ autocomplete="off" disabled={busy} /> - - - - diff --git a/tests/server-endpoint.test.ts b/tests/server-endpoint.test.ts new file mode 100644 index 0000000..3b29f55 --- /dev/null +++ b/tests/server-endpoint.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + containsScheme, + pastedServerAddress, + splitServerAddress, + storedServerAddress, +} from "../src/lib/server-endpoint"; + +describe("server endpoint fields", () => { + test("normalizes a pasted secure URL and explicit port", () => { + assert.deepEqual(pastedServerAddress("https://api.example.com:8443/"), { + address: "api.example.com", + connectionType: "https", + port: "8443", + }); + }); + + test("maps WebSocket schemes to their matching HTTP modes", () => { + assert.deepEqual(pastedServerAddress("wss://api.example.com"), { + address: "api.example.com", + connectionType: "https", + port: "", + }); + assert.deepEqual(pastedServerAddress("ws://api.example.com"), { + address: "api.example.com", + connectionType: "http", + port: "", + }); + }); + + test("does not normalize URLs containing unsupported components", () => { + assert.equal(pastedServerAddress("https://api.example.com/v1/ws"), null); + assert.equal(pastedServerAddress("https://user@api.example.com"), null); + }); + + test("detects a manually typed scheme without changing the address", () => { + assert.equal(containsScheme("https://api.example.com"), true); + assert.equal(containsScheme("api.example.com"), false); + }); + + test("round-trips stored proxy and direct addresses", () => { + assert.equal( + storedServerAddress("api.example.com", "https"), + "https://api.example.com", + ); + assert.deepEqual(splitServerAddress("https://api.example.com"), { + address: "api.example.com", + connectionType: "https", + }); + assert.equal( + storedServerAddress("lan.example.com", "direct"), + "lan.example.com", + ); + }); +});