From 16fb8b32413639bf0f83cf82dfa772069f4edc3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:15:15 +0000 Subject: [PATCH 1/3] CI: run every job on GitHub-hosted runners The test and release workflows now run on GitHub's own runners instead of the self-hosted nugget-linux and windows-pc machines and Blacksmith's macOS runners: ubuntu-24.04 for Linux, Android and publishing, macos-15 for macOS, and windows-2025 for Windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VSvNnejWtHcmvqR13Uj7oU --- .github/workflows/release.yml | 24 ++++++++++-------------- .github/workflows/test.yml | 18 ++++++++---------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a8fef6..6d868c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,15 +7,13 @@ on: env: WAILS_VERSION: v3.0.0-alpha2.117 -# Runners are Blacksmith. The cache actions below are the upstream ones on -# purpose: Blacksmith intercepts them and serves from a colocated cache, and its -# own useblacksmith/* forks are archived. +# Runners are GitHub-hosted. jobs: macos: name: macOS (universal .dmg) # Apple Silicon; the universal build compiles arm64 and amd64 then lipos - # them together, so the extra cores pay for themselves here. - runs-on: blacksmith-12vcpu-macos-15 + # them together. + runs-on: macos-15 steps: - uses: actions/checkout@v4 @@ -56,7 +54,7 @@ jobs: linux: name: Linux (.deb / .rpm / .AppImage) - runs-on: nugget-linux + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -116,9 +114,7 @@ jobs: windows: name: Windows (installer) - # Blacksmith's Windows runners are in public beta; swap back to - # `windows-latest` if this job turns flaky. - runs-on: windows-pc + runs-on: windows-2025 steps: - uses: actions/checkout@v4 @@ -149,9 +145,9 @@ jobs: run: go install github.com/wailsapp/wails/v3/cmd/wails3@${{ env.WAILS_VERSION }} - name: Install NSIS - # The installer is built with makensis, which the Blacksmith Windows - # image does not ship (GitHub's older Windows images did). Chocolatey - # when the image has it, otherwise the official zip. + # The installer is built with makensis, which the windows-2025 image + # does not ship (GitHub's older Windows images did). Chocolatey when + # the image has it, otherwise the official zip. shell: pwsh run: | if (Get-Command makensis -ErrorAction SilentlyContinue) { @@ -198,7 +194,7 @@ jobs: android: name: Android (.apk) - runs-on: nugget-linux + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -282,7 +278,7 @@ jobs: publish: name: Publish release needs: [macos, linux, windows, android] - runs-on: nugget-linux + runs-on: ubuntu-24.04 permissions: contents: write steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 816818d..42fd232 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,9 +19,7 @@ env: SING_BOX_TAGS: with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api,with_dhcp,with_acme WAILS_VERSION: v3.0.0-alpha2.117 -# Runners are Blacksmith. Note the cache actions below are the upstream ones on -# purpose: Blacksmith intercepts them and serves from a colocated cache, and its -# own useblacksmith/* forks are archived. +# Runners are GitHub-hosted. jobs: test: name: Go tests (${{ matrix.name }}) @@ -30,11 +28,11 @@ jobs: matrix: include: - name: linux - runner: nugget-linux + runner: ubuntu-24.04 - name: macos - runner: blacksmith-6vcpu-macos-15 + runner: macos-15 - name: windows - runner: windows-pc + runner: windows-2025 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 @@ -69,7 +67,7 @@ jobs: android: name: Build (android) - runs-on: nugget-linux + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -149,11 +147,11 @@ jobs: matrix: include: - name: macos-arm64 - runner: blacksmith-12vcpu-macos-15 + runner: macos-15 - name: linux-amd64 - runner: nugget-linux + runner: ubuntu-24.04 - name: windows-amd64 - runner: windows-pc + runner: windows-2025 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 From 6298b9ea9a0469e75c734d73e83b42181f31da49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:37:45 +0000 Subject: [PATCH 2/3] UI feedback: settings search, traffic chart, server links, canvas tidy-up - Settings: a search box that finds a section by any setting inside it, in the UI language or English; sections grouped under four headings in two columns, with smaller rows. - Statistics: a donut of each app's share of the traffic (top seven plus "Other"), with a legend and hover readout. - Routing: Block and Drop can be taken off the canvas while nothing goes to them, and put back from the palette. Domain patterns are now a mode of the Domains card instead of a separate card. "Protocols" and "TCP / UDP" are renamed "Traffic protocols" and "Transport". The drag hint and the geo-data note moved behind info icons. - Sharing: a Servers tab gives every server in a subscription or config as its own link, with QR and copy-all. Profiles imported as sing-box or Xray JSON or Clash YAML are encoded back into vless/vmess/trojan/ss/ hysteria/hy2/tuic/socks/http links (internal/link/share.go). - Fixes: with a background picture the sidebar was tinted twice, which drew a hard edge down its left side; the Connections table's sticky header drew square corners over the rounded border. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VSvNnejWtHcmvqR13Uj7oU --- frontend/src/App.css | 24 + frontend/src/App.tsx | 2 + frontend/src/components/qr.tsx | 198 +++++- frontend/src/components/routing/nodes.tsx | 71 ++- frontend/src/components/settings/shell.tsx | 10 +- .../components/views/ConfigurationView.tsx | 5 +- .../src/components/views/ConnectionsView.tsx | 7 +- frontend/src/components/views/RoutingView.tsx | 131 +++- .../src/components/views/SettingsView.tsx | 130 +++- .../src/components/views/StatisticsView.tsx | 100 ++- frontend/src/lib/backend.ts | 1 + frontend/src/locales/en.ts | 28 +- frontend/src/locales/fa.ts | 28 +- frontend/src/locales/ja.ts | 28 +- frontend/src/locales/ru.ts | 28 +- frontend/src/locales/uk.ts | 28 +- frontend/src/locales/zh.ts | 28 +- internal/app/profiles.go | 30 + internal/link/share.go | 570 ++++++++++++++++++ internal/link/share_test.go | 116 ++++ 20 files changed, 1464 insertions(+), 99 deletions(-) create mode 100644 internal/link/share.go create mode 100644 internal/link/share_test.go diff --git a/frontend/src/App.css b/frontend/src/App.css index e101f3e..6751a86 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -194,6 +194,15 @@ --status-connected: oklch(var(--brand-l) var(--brand-c) var(--brand-h)); --status-error: oklch(0.55 0.225 27.325); + /* Categorical chart hues, in fixed order; checked for colour-blind separation. */ + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; + --series-4: #eda100; + --series-5: #e87ba4; + --series-6: #008300; + --series-7: #4a3aa7; + --routing-apps: oklch(0.6 0.2 350); --routing-domains: oklch(0.53 0.2 300); --routing-ip: oklch(0.6 0.16 40); @@ -258,6 +267,14 @@ --status-connected: oklch(var(--brand-l) var(--brand-c) var(--brand-h)); --status-error: oklch(0.704 0.191 22.216); + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; + --series-4: #c98500; + --series-5: #d55181; + --series-6: #008300; + --series-7: #9085e9; + --routing-apps: oklch(0.72 0.19 350); --routing-domains: oklch(0.7 0.18 300); --routing-ip: oklch(0.75 0.16 45); @@ -993,3 +1010,10 @@ :root[data-backdrop] [data-slot="sheet-content"] { background-color: oklch(var(--ui-bg-l) var(--ui-c) var(--ui-h)); } + +/* The sidebar's wrapper already carries the see-through tint; painting the + inner panel too doubled it inside the wrapper's padding, which showed as a + hard edge down the sidebar's left side. */ +:root[data-backdrop] [data-slot="sidebar-inner"] { + background: transparent; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5dfa112..a48dda9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -804,6 +804,7 @@ function App() { }, [profilesOf] ); + const profileIdsOf = useCallback((source: ConfigSource) => profilesOf(source).map((profile) => profile.id), [profilesOf]); const handleCopySources = useCallback( async (list: ConfigSource[]) => { const links = list.map(linkOf).filter(Boolean); @@ -1208,6 +1209,7 @@ function App() { onRefreshSources={handleRefreshSources} onCopySources={handleCopySources} linkOf={linkOf} + profileIdsOf={profileIdsOf} onAdd={() => setIsModalOpen(true)} /> )} diff --git a/frontend/src/components/qr.tsx b/frontend/src/components/qr.tsx index 8520ee2..5b8c5c6 100644 --- a/frontend/src/components/qr.tsx +++ b/frontend/src/components/qr.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import QRCode from "qrcode"; import jsQR from "jsqr"; -import { Copy } from "lucide-react"; +import { Copy, Loader2, QrCode } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -12,7 +12,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { errorMessage, invoke } from "@/lib/backend"; import { useT } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; /** * Reads a QR code out of an image — a screenshot of a provider's page, a @@ -173,14 +175,24 @@ export function QrScanner({ onResult, onClose }: { onResult: (text: string) => v ); } -/** A link as a QR code, to scan with a phone, with a copy button. */ -export function ShareDialog({ title, link, onClose }: { title: string; link: string; onClose: () => void }) { +async function copyText(text: string, done: string, failed: string) { + try { + await navigator.clipboard.writeText(text); + toast.success(done, { id: "share-copy" }); + } catch { + toast.error(failed, { id: "share-copy" }); + } +} + +/** A link as a QR code, always dark on light: scanners expect that. */ +function QrImage({ link }: { link: string }) { const t = useT(); const [svg, setSvg] = useState(""); const [tooLong, setTooLong] = useState(false); useEffect(() => { let cancelled = false; + setTooLong(false); QRCode.toString(link, { type: "svg", margin: 1, errorCorrectionLevel: "M", color: { dark: "#000000", light: "#ffffff" } }) .then((markup) => { if (!cancelled) setSvg(markup); @@ -194,43 +206,165 @@ export function ShareDialog({ title, link, onClose }: { title: string; link: str }; }, [link]); - const copy = async () => { - try { - await navigator.clipboard.writeText(link); - toast.success(t("share.copied"), { id: "share-copy" }); - } catch { - toast.error(t("share.copyFailed"), { id: "share-copy" }); - } - }; + return tooLong ? ( +

{t("share.tooLong")}

+ ) : ( +
+ ); +} + +interface SharedLink { + name: string; + link: string; +} + +/** + * Each server on its own, as a vless://, vmess://… link: what a subscription + * or a JSON config holds, for a client that takes single servers. + */ +function ServerLinks({ profileIds }: { profileIds: string[] }) { + const t = useT(); + const [links, setLinks] = useState(null); + const [error, setError] = useState(""); + const [shown, setShown] = useState(null); + // The parent builds a fresh array on every render; read again only when + // the profiles themselves change. + const key = profileIds.join(","); + + useEffect(() => { + let cancelled = false; + invoke("share_links", { ids: key ? key.split(",") : [] }) + .then((value) => { + if (!cancelled) setLinks(value ?? []); + }) + .catch((failure) => { + if (!cancelled) setError(errorMessage(failure)); + }); + return () => { + cancelled = true; + }; + }, [key]); + + if (error) return

{error}

; + if (!links) { + return ( +
+
+ ); + } + if (links.length === 0) return

{t("share.servers.none")}

; + + return ( +
+ {shown !== null && links[shown] ? : null} +
    + {links.map((item, index) => ( +
  • + + {item.name || t("share.servers.unnamed")} + + {item.link} + + + + +
  • + ))} +
+ +
+ ); +} + +/** + * A configuration to add elsewhere: the link it came from as a QR code and + * text, and, given its profiles, each server's own link. + */ +export function ShareDialog({ title, link, profileIds, onClose }: { title: string; link: string; profileIds?: string[]; onClose: () => void }) { + const t = useT(); + const [tab, setTab] = useState<"link" | "servers">("link"); return ( (open ? undefined : onClose())}> - + {title} - {t("share.explain")} + {t(tab === "link" ? "share.explain" : "share.servers.explain")} - {tooLong ? ( -

{t("share.tooLong")}

+ {profileIds && profileIds.length > 0 ? ( +
+ {(["link", "servers"] as const).map((option) => ( + + ))} +
+ ) : null} + {tab === "servers" && profileIds ? ( + ) : ( - // Always black on white, whatever the theme: scanners - // expect a dark code on a light ground. -
+ <> + +
+ + {link} + + +
+ )} -
- - {link} - - -
); diff --git a/frontend/src/components/routing/nodes.tsx b/frontend/src/components/routing/nodes.tsx index 002ff58..5c7d088 100644 --- a/frontend/src/components/routing/nodes.tsx +++ b/frontend/src/components/routing/nodes.tsx @@ -921,6 +921,7 @@ export function CommentNode({ data, selected }: NodeProps) { /** What changes a rule node can make to its rule. */ export interface RulePatch { + kind?: RoutingSource; values?: string[]; invert?: boolean; dns?: string; @@ -959,6 +960,9 @@ export function SourceNode({ data, selected }: NodeProps) { hits={hits} order={order} > + {kind === "domains" || kind === "domain_regex" ? ( + + ) : null}
onChange({ values: next })} />
@@ -973,6 +977,68 @@ export function SourceNode({ data, selected }: NodeProps) { ); } +/** A domain as a pattern matching it and its subdomains, as "domains" does. */ +function domainAsPattern(domain: string): string { + return `(^|\\.)${domain.replace(/^\.+/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; +} + +/** + * Domains and domain patterns are one card: this switches between listing + * names and writing regular expressions. Names carry over as the patterns + * that match them; a pattern has no name to go back to, so switching back + * waits until the list is empty. + */ +function DomainModeSwitch({ + kind, + values, + onChange, +}: { + kind: "domains" | "domain_regex"; + values: string[]; + onChange: (patch: RulePatch) => void; +}) { + const t = useT(); + const locked = kind === "domain_regex" && values.length > 0; + const options = [ + { kind: "domains" as const, label: t("routing.domains.names") }, + { kind: "domain_regex" as const, label: t("routing.domains.pattern") }, + ]; + return ( +
+
+ {options.map((option) => { + const active = option.kind === kind; + const disabled = !active && option.kind === "domains" && locked; + return ( + + ); + })} +
+
+ ); +} + /** Kinds a combined rule's condition can be. */ const CONDITION_KINDS: RoutingSource[] = [ "apps", "domains", "domain_regex", "ip", "port", "protocol", "network", "geosite", "geoip", "ruleset", @@ -1162,6 +1228,8 @@ export interface ActionNodeData { action: RoutingAction; /** How many sources currently feed this destination. */ inbound: number; + /** Takes the destination off the canvas; set only while nothing uses it. */ + onHide?: () => void; [key: string]: unknown; } @@ -1182,7 +1250,7 @@ function InboundSummary({ accent, status, inbound }: { accent: string; status: s /** A destination: everything wired into it goes here. */ export function ActionNode({ data, selected }: NodeProps) { - const { action, inbound } = data as ActionNodeData; + const { action, inbound, onHide } = data as ActionNodeData; const meta = ACTION_META[action]; const t = useT(); @@ -1194,6 +1262,7 @@ export function ActionNode({ data, selected }: NodeProps) { subtitle={t(meta.hint)} selected={selected} box={data as BoxData} + onDelete={onHide} > - {title} + {title} {badge ? ( {badge} @@ -52,7 +52,7 @@ export function SettingsRow({ icon: Icon, title, subtitle, badge, onClick }: Row -
-
+ {/* clip-path, not just overflow, holds the rounded corners: the + sticky header is a layer of its own, and the WebView drew + it square over the border at the top. */} +
{shown.length === 0 ? (

{rows.length === 0 ? t("connections.none") : t("connections.noMatch")} @@ -202,7 +205,7 @@ function ConnectionsView({ connected, rules, profiles }: ConnectionsViewProps) { // app and rule under it, then the server, with the data // on the right. Five columns left a phone a word per cell. - + diff --git a/frontend/src/components/views/RoutingView.tsx b/frontend/src/components/views/RoutingView.tsx index 19983c6..11058f9 100644 --- a/frontend/src/components/views/RoutingView.tsx +++ b/frontend/src/components/views/RoutingView.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; -import { Download, FileUp, Globe2, Landmark, Link2, Loader2, Redo2, RefreshCw, Server, StickyNote, Trash2, Puzzle, Undo2, Upload, Plus } from "lucide-react"; +import { Download, FileUp, Globe2, Info, Landmark, Link2, Loader2, Redo2, RefreshCw, Server, StickyNote, Trash2, Puzzle, Undo2, Upload, Plus } from "lucide-react"; import { Background, BackgroundVariant, @@ -40,6 +40,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { errorMessage, invoke } from "@/lib/backend"; import { MessageKey, useT } from "@/lib/i18n"; import { cn } from "@/lib/utils"; @@ -55,10 +56,10 @@ import { RuleListStatus, } from "@/types"; +// Domain patterns are added as a domains card, switched to patterns there. const SOURCE_KINDS: RoutingKind[] = [ "apps", "domains", - "domain_regex", "ip", "port", "protocol", @@ -70,6 +71,59 @@ const SOURCE_KINDS: RoutingKind[] = [ ]; const ACTIONS: RoutingAction[] = ["proxy", "direct", "block", "drop"]; +/** + * Destinations that can be taken off the canvas while nothing goes to them: + * many setups never block or drop anything, and the two cards only take room. + * Which ones are hidden is how this device shows the canvas, not part of the + * routing, so it is kept here rather than in the settings. + */ +const HIDEABLE_ACTIONS: RoutingAction[] = ["block", "drop"]; +const HIDDEN_ACTIONS_KEY = "nugget.routing.hiddenActions"; + +function useHiddenActions() { + const [hidden, setHidden] = useState(() => { + try { + const saved = JSON.parse(localStorage.getItem(HIDDEN_ACTIONS_KEY) ?? "[]"); + return Array.isArray(saved) ? saved.filter((action) => HIDEABLE_ACTIONS.includes(action)) : []; + } catch { + return []; + } + }); + const update = useCallback((next: RoutingAction[]) => { + setHidden(next); + try { + localStorage.setItem(HIDDEN_ACTIONS_KEY, JSON.stringify(next)); + } catch { + // Not remembered; the canvas still works. + } + }, []); + const hide = useCallback((action: RoutingAction) => update([...hidden.filter((item) => item !== action), action]), [hidden, update]); + const show = useCallback((action: RoutingAction) => update(hidden.filter((item) => item !== action)), [hidden, update]); + return { hidden, hide, show }; +} + +/** A small "i" that shows an explanation on hover, focus or click. */ +function InfoTip({ text }: { text: string }) { + const [open, setOpen] = useState(false); + return ( + + + + + + {text} + + + ); +} + /** The catch-all source node has a fixed id; destinations are keyed by action. */ const DEFAULT_NODE = "default"; const actionNodeId = (action: RoutingAction) => `action:${action}`; @@ -156,6 +210,9 @@ interface RoutingViewProps { interface CanvasProps { settings: AppSettings; + /** Destinations taken off the canvas; any that something uses still shows. */ + hiddenActions: RoutingAction[]; + onHideAction: (action: RoutingAction) => void; onChange: (patch: Partial) => void; profiles: Profile[]; /** Open connections by rule id; null while disconnected. */ @@ -177,7 +234,7 @@ function serverIds(settings: AppSettings): string[] { return ids; } -function RoutingCanvas({ settings, onChange, profiles, hits, onReady }: CanvasProps) { +function RoutingCanvas({ settings, hiddenActions, onHideAction, onChange, profiles, hits, onReady }: CanvasProps) { const rules = useMemo(() => settings.routing_rules ?? [], [settings.routing_rules]); const layout = useMemo(() => settings.routing_layout ?? {}, [settings.routing_layout]); const { setCenter, getZoom, fitView } = useReactFlow(); @@ -368,12 +425,18 @@ function RoutingCanvas({ settings, onChange, profiles, hits, onReady }: CanvasPr onDelete: () => removeRule(rule.id), }, })), - ...ACTIONS.map((action) => ({ - id: actionNodeId(action), - type: "action", - position: positioned(actionNodeId(action), 0), - data: { action, inbound: inboundCounts[actionNodeId(action)] ?? 0 }, - })), + ...ACTIONS.flatMap((action) => { + const inbound = inboundCounts[actionNodeId(action)] ?? 0; + // Something going there keeps it on the canvas, hidden or not. + if (inbound === 0 && hiddenActions.includes(action)) return []; + const hideable = inbound === 0 && HIDEABLE_ACTIONS.includes(action); + return [{ + id: actionNodeId(action), + type: "action", + position: positioned(actionNodeId(action), 0), + data: { action, inbound, onHide: hideable ? () => onHideAction(action) : undefined }, + }]; + }), ...servers.map((profileId, index) => { const profile = profiles.find((item) => item.id === profileId); return { @@ -413,7 +476,7 @@ function RoutingCanvas({ settings, onChange, profiles, hits, onReady }: CanvasPr }, }; }); - }, [rules, layout, inboundCounts, updateRule, removeRule, comments, updateComment, removeComment, servers, profiles, removeServer, hits, resizeNode, resetNodeSize]); + }, [rules, layout, inboundCounts, hiddenActions, onHideAction, updateRule, removeRule, comments, updateComment, removeComment, servers, profiles, removeServer, hits, resizeNode, resetNodeSize]); const derivedEdges = useMemo(() => { // An edge carrying connections right now is drawn heavier, so the @@ -566,6 +629,8 @@ function Palette({ onSetDefault, onAddServer, onAddPreset, + hiddenActions, + onShowAction, profiles, placedServers, geoPanel, @@ -578,6 +643,9 @@ function Palette({ onSetDefault: (action: RoutingAction) => void; onAddServer: (profileId: string) => void; onAddPreset: (rules: PresetRule[], placement: PresetPlacement) => void; + /** Hidden destinations nothing uses, offered here to put back. */ + hiddenActions: RoutingAction[]; + onShowAction: (action: RoutingAction) => void; profiles: Profile[]; placedServers: string[]; geoPanel: React.ReactNode; @@ -608,8 +676,9 @@ function Palette({ {tab === "order" ? orderPanel : ( <> -

- {t("routing.sources")} +

+ {t("routing.sources")} +

{SOURCE_KINDS.map((kind) => { const meta = kindMeta(kind); @@ -679,6 +748,21 @@ function Palette({ ))} + {hiddenActions.map((action) => { + const meta = ACTION_META[action]; + return ( + + ); + })} @@ -695,10 +779,6 @@ function Palette({ {geoPanel} - -

- {t("routing.dragHint")} -

)} @@ -743,8 +823,9 @@ function GeoPanel({ return ( <> -

- {t("routing.geo.title")} +

+ {t("routing.geo.title")} +

{GEO_KINDS.map(({ kind, label, icon: Icon }) => { @@ -825,9 +906,6 @@ function GeoPanel({
); })} -

- {t("routing.geo.explain")} -

); @@ -1094,6 +1172,11 @@ function RoutingView({ settings, onChange: save, onReplace, profiles, connected const { plugins } = usePlugins(); const isMobile = useIsMobile(); const [paletteOpen, setPaletteOpen] = useState(false); + const hiddenActions = useHiddenActions(); + // A hidden destination something goes to is on the canvas regardless. + const offeredActions = hiddenActions.hidden.filter( + (action) => action !== settings.default_action && !rules.some((rule) => rule.action === action) + ); const pluginSetups = plugins .filter((plugin) => plugin.enabled) .flatMap((plugin) => plugin.routing.map((setup) => ({ plugin, setup }))); @@ -1330,6 +1413,8 @@ function RoutingView({ settings, onChange: save, onReplace, profiles, connected } @@ -1402,6 +1489,8 @@ function RoutingView({ settings, onChange: save, onReplace, profiles, connected onSetDefault={(action) => onChange({ default_action: action, default_server: "" })} onAddServer={addServer} onAddPreset={addPreset} + hiddenActions={offeredActions} + onShowAction={hiddenActions.show} profiles={profiles} placedServers={placedServers} geoPanel={} diff --git a/frontend/src/components/views/SettingsView.tsx b/frontend/src/components/views/SettingsView.tsx index 45e068f..1a47634 100644 --- a/frontend/src/components/views/SettingsView.tsx +++ b/frontend/src/components/views/SettingsView.tsx @@ -20,6 +20,7 @@ import { Palette, Plus, RefreshCw, + Search, Router, ShieldHalf, Trash2, @@ -55,7 +56,8 @@ import { RadiusPicker, } from "@/components/settings/appearance-options"; import { effectiveFont, fontLabel } from "@/lib/appearance"; -import { LANGUAGES, LanguageChoice, MessageKey, scriptOf, useI18n } from "@/lib/i18n"; +import { LANGUAGES, LanguageChoice, MessageKey, scriptOf, translate, useI18n } from "@/lib/i18n"; +import en from "@/locales/en"; import { SettingsField, SettingsGroup, @@ -251,6 +253,70 @@ const SECTIONS: Section[] = [ }, ]; +/** + * The list is grouped so fifteen sections read as four things, not a column + * to scan top to bottom. + */ +const GROUPS: { title: MessageKey; sections: SectionId[] }[] = [ + { title: "settings.group.general", sections: ["appearance", "language", "behaviour", "updates"] }, + { title: "settings.group.network", sections: ["connection", "tls", "chain", "subscriptions", "wifi"] }, + { title: "settings.group.data", sections: ["privacy", "sync", "backup", "beam"] }, + { title: "settings.group.advanced", sections: ["core", "plugins"] }, +]; + +/** + * What each section contains, as the message-key prefixes of its controls, + * so a search finds a setting by its own name and not only its section's. + */ +const SEARCH_PREFIXES: Record = { + appearance: ["appearance.", "picker.", "editor."], + language: ["settings.language"], + behaviour: ["behaviour.", "shortcut.", "close."], + connection: ["connection.dns", "connection.mtu"], + tls: ["tls."], + chain: ["chain."], + subscriptions: ["identity.", "usage."], + wifi: ["wifi."], + privacy: ["privacy."], + sync: ["sync."], + beam: ["beam."], + core: ["core."], + plugins: ["plugins."], + backup: ["backup."], + updates: ["updates."], +}; +/** Words that name a control without a message of their own. */ +const SEARCH_EXTRA: Partial> = { connection: ["MTU"] }; + +const ALL_KEYS = Object.keys(en) as MessageKey[]; + +/** Settings whose text holds every word of the query, in the UI's language or English. */ +function searchSettings(query: string, language: Parameters[0]): { id: SectionId; match: string }[] { + const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean); + if (words.length === 0) return []; + const results: { id: SectionId; match: string }[] = []; + for (const section of SECTIONS) { + const keys = ALL_KEYS.filter( + (key) => SEARCH_PREFIXES[section.id].some((prefix) => key.startsWith(prefix)) && typeof en[key] === "string" + ); + const texts = [ + section.title, + section.blurb, + ...keys.filter((key) => !key.endsWith(".description")), + ...keys.filter((key) => key.endsWith(".description")), + ].map((key) => ({ shown: translate(language, key), english: translate("en", key) })); + texts.push(...(SEARCH_EXTRA[section.id] ?? []).map((word) => ({ shown: word, english: word }))); + const haystack = texts.map((text) => `${text.shown} ${text.english}`).join(" ").toLocaleLowerCase(); + if (!words.every((word) => haystack.includes(word))) continue; + // Show the first single text that matches, to say why this section is here. + const hit = texts.find((text) => + words.some((word) => `${text.shown} ${text.english}`.toLocaleLowerCase().includes(word)) + ); + results.push({ id: section.id, match: hit?.shown.replace(/\{\w+\}/g, "…") ?? "" }); + } + return results; +} + function SettingsView({ theme, setTheme, @@ -267,6 +333,8 @@ function SettingsView({ openSignal, }: SettingsViewProps) { const [openId, setOpenId] = React.useState(null); + // Kept while a section is open, so Back returns to the same results. + const [query, setQuery] = React.useState(""); // Which way the last move went, so coming back animates in reverse. const [direction, setDirection] = React.useState<"forward" | "back">("forward"); @@ -1037,6 +1105,17 @@ function SettingsView({ }; const open = SECTIONS.find((section) => section.id === openId); + const visible = (id: SectionId) => (id !== "beam" || !!beamPreview) && !(isAndroid && HIDDEN_ON_ANDROID.has(id)); + const results = query.trim() ? searchSettings(query, language).filter((result) => visible(result.id)) : null; + const row = (section: Section, subtitle = subtitleFor(section)) => ( + navigate(section.id)} + /> + ); return ( // Keyed per page: each list or section is a fresh mount, which is what @@ -1065,18 +1144,45 @@ function SettingsView({ {open ? (
{detail(open.id)}
) : ( -
- {SECTIONS.filter( - (section) => (section.id !== "beam" || beamPreview) && !(isAndroid && HIDDEN_ON_ANDROID.has(section.id)) - ).map((section) => ( - navigate(section.id)} +
+
+
+ {results ? ( + results.length === 0 ? ( +

{t("settings.search.none")}

+ ) : ( +
+ {results.map((result) => row(SECTIONS.find((section) => section.id === result.id)!, result.match))} +
+ ) + ) : ( + GROUPS.map((group) => { + const sections = group.sections.filter(visible).map((id) => SECTIONS.find((section) => section.id === id)!); + if (sections.length === 0) return null; + return ( +
+

{t(group.title)}

+
{sections.map((section) => row(section))}
+
+ ); + }) + )}
)} diff --git a/frontend/src/components/views/StatisticsView.tsx b/frontend/src/components/views/StatisticsView.tsx index 07b3d89..41decae 100644 --- a/frontend/src/components/views/StatisticsView.tsx +++ b/frontend/src/components/views/StatisticsView.tsx @@ -105,6 +105,89 @@ function Timeline({ samples }: { samples: HealthSample[] }) { ); } +/** Apps that get a slice of their own; the rest share "Other". */ +const SLICES = 7; + +interface Slice { + key: string; + label: string; + size: number; + color: string; +} + +/** + * Each app's share of the traffic. Hovering a slice or its legend row shows + * that app in the middle; the list below has the exact numbers. + */ +function ShareChart({ slices, total }: { slices: Slice[]; total: number }) { + const t = useT(); + const [active, setActive] = useState(null); + const radius = 40; + const circumference = 2 * Math.PI * radius; + // A sliver of card between slices, so neighbours never run together. + const gap = slices.length > 1 ? 1.2 : 0; + let offset = 0; + const focused = slices.find((slice) => slice.key === active); + const share = (size: number) => `${size / total >= 0.1 ? Math.round((size / total) * 100) : ((size / total) * 100).toFixed(1)}%`; + + return ( +
+
+ + {slices.map((slice) => { + const length = (slice.size / total) * circumference; + const dash = Math.max(0.4, length - gap); + const start = offset; + offset += length; + return ( + setActive(slice.key)} + onMouseLeave={() => setActive(null)} + > + {`${slice.label} · ${share(slice.size)} · ${formatBytes(slice.size)}`} + + ); + })} + +
+ {focused ? share(focused.size) : formatBytes(total)} + + {focused ? focused.label : t("stats.total")} + +
+
+
    + {slices.map((slice) => ( +
  • setActive(slice.key)} + onMouseLeave={() => setActive(null)} + > +
  • + ))} +
+
+ ); +} + function percent(value: number): string { return value < 0 ? "—" : `${Math.round(value * 100)}%`; } @@ -147,6 +230,18 @@ function StatisticsView({ profiles, settings, onSettingsChange }: StatisticsView const programs = usage?.programs ?? []; const largest = Math.max(1, ...programs.map((program) => program.up + program.down)); const total = programs.reduce((sum, program) => ({ up: sum.up + program.up, down: sum.down + program.down }), { up: 0, down: 0 }); + const label = (program: string) => (program ? appLabels.get(program) || programName(program) : t("stats.unknownApp")); + const ranked = [...programs].sort((a, b) => b.up + b.down - (a.up + a.down)); + const slices: Slice[] = ranked.slice(0, SLICES).map((program, index) => ({ + key: program.program, + label: label(program.program), + size: program.up + program.down, + color: `var(--series-${index + 1})`, + })); + const rest = ranked.slice(SLICES).reduce((sum, program) => sum + program.up + program.down, 0); + if (rest > 0) { + slices.push({ key: "\u0000other", label: t("stats.other"), size: rest, color: "color-mix(in oklab, var(--muted-foreground) 55%, transparent)" }); + } const clear = async () => { setConfirmClear(false); @@ -209,6 +304,9 @@ function StatisticsView({ profiles, settings, onSettingsChange }: StatisticsView ) : ( <> {days > 1 && usage ? : null} + {programs.length > 0 && total.up + total.down > 0 ? ( + + ) : null} {programs.length === 0 ? (

{t("stats.apps.empty")}

) : ( @@ -218,7 +316,7 @@ function StatisticsView({ profiles, settings, onSettingsChange }: StatisticsView return (
  • - {program.program ? appLabels.get(program.program) || programName(program.program) : t("stats.unknownApp")} + {label(program.program)} diff --git a/frontend/src/lib/backend.ts b/frontend/src/lib/backend.ts index 7eaf89d..2325871 100644 --- a/frontend/src/lib/backend.ts +++ b/frontend/src/lib/backend.ts @@ -37,6 +37,7 @@ const COMMANDS: Record = { delete_profiles_by_source: { method: "DeleteProfilesBySource", args: ["sourceDomain"] }, delete_profiles_by_ids: { method: "DeleteProfilesByIds", args: ["ids"] }, set_favorites: { method: "SetFavorites", args: ["ids", "favorite"] }, + share_links: { method: "ShareLinks", args: ["ids"] }, get_settings: { method: "GetSettings", args: [] }, save_settings: { method: "SaveSettings", args: ["settings"] }, diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index d2d8bdf..47cea93 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -802,8 +802,8 @@ const en = { "routing.source.regex.hint": "Regular expression", "routing.source.port": "Ports", "routing.source.port.hint": "Single ports or ranges", - "routing.source.protocol": "Protocols", - "routing.source.protocol.hint": "Detected by inspecting the connection", + "routing.source.protocol": "Traffic protocols", + "routing.source.protocol.hint": "HTTP, DNS, TLS and others", "routing.source.geosite": "Service", "routing.source.geosite.hint": "Known service, by rule-set", "routing.source.geoip": "Country", @@ -817,8 +817,8 @@ const en = { "routing.action.block": "Block", "routing.action.block.hint": "Refuses the connection", "routing.action.block.status": "Connection is refused", - "routing.source.network": "TCP / UDP", - "routing.source.network.hint": "By transport", + "routing.source.network": "Transport", + "routing.source.network.hint": "TCP or UDP", "routing.source.ruleset": "Rule list", "routing.source.ruleset.hint": "Sites and addresses from a URL", "routing.source.logical": "Combined", @@ -892,6 +892,26 @@ const en = { "routing.geo.address": "{file} address", "routing.geo.explain": "geoip.dat and geosite.dat as Happ and Xray use them. While one is loaded, its codes replace the built-in ones for that kind.", "routing.geo.offer": "This routing uses a {file} from", + "routing.domains.names": "Names", + "routing.domains.pattern": "Pattern", + "routing.domains.patternLocked": "Clear the patterns to go back to names", + "routing.action.show": "Put back on the canvas", + "settings.search": "Search settings", + "settings.search.none": "No setting matches.", + "settings.group.general": "General", + "settings.group.network": "Connection", + "settings.group.data": "Privacy and data", + "settings.group.advanced": "Advanced", + "stats.share": "Share of traffic by app", + "stats.total": "in total", + "stats.other": "Other apps", + "share.tab.link": "Link", + "share.tab.servers": "Servers", + "share.servers.explain": "Each server as its own link, for apps that add servers one at a time.", + "share.servers.none": "None of these servers can be written as a link.", + "share.servers.unnamed": "Server", + "share.servers.copyAll": { one: "Copy {count} link", other: "Copy all {count} links" }, + "share.servers.copiedAll": { one: "{count} link copied.", other: "{count} links copied." }, } as const; export default en; diff --git a/frontend/src/locales/fa.ts b/frontend/src/locales/fa.ts index fe38aec..cb35e14 100644 --- a/frontend/src/locales/fa.ts +++ b/frontend/src/locales/fa.ts @@ -757,8 +757,8 @@ const fa: Messages = { "routing.source.regex.hint": "عبارت باقاعده", "routing.source.port": "درگاه‌ها", "routing.source.port.hint": "درگاه‌های تکی یا بازه‌ها", - "routing.source.protocol": "پروتکل‌ها", - "routing.source.protocol.hint": "با بررسی محتوای اتصال شناسایی می‌شود", + "routing.source.protocol": "پروتکل‌های ترافیک", + "routing.source.protocol.hint": "HTTP، DNS، TLS و غیره", "routing.source.geosite": "سرویس", "routing.source.geosite.hint": "سرویس شناخته‌شده، بر اساس مجموعه‌قانون", "routing.source.geoip": "کشور", @@ -772,8 +772,8 @@ const fa: Messages = { "routing.action.block": "مسدود", "routing.action.block.hint": "اتصال را رد می‌کند", "routing.action.block.status": "اتصال رد می‌شود", - "routing.source.network": "TCP / UDP", - "routing.source.network.hint": "بر اساس پروتکل انتقال", + "routing.source.network": "لایهٔ انتقال", + "routing.source.network.hint": "TCP یا UDP", "routing.source.ruleset": "فهرست قواعد", "routing.source.ruleset.hint": "سایت‌ها و نشانی‌ها از یک آدرس اینترنتی", "routing.source.logical": "ترکیبی", @@ -847,6 +847,26 @@ const fa: Messages = { "routing.geo.address": "نشانی {file}", "routing.geo.explain": "geoip.dat و geosite.dat به همان شکلی که Happ و Xray به کار می‌برند. تا وقتی پرونده‌ای بارگذاری شده، کدهای آن جای کدهای داخلی همان نوع را می‌گیرند.", "routing.geo.offer": "این مسیریابی از {file} در این نشانی استفاده می‌کند:", + "routing.domains.names": "نام‌ها", + "routing.domains.pattern": "الگو", + "routing.domains.patternLocked": "برای بازگشت به نام‌ها، الگوها را پاک کنید", + "routing.action.show": "بازگرداندن به بوم", + "settings.search": "جستجو در تنظیمات", + "settings.search.none": "تنظیمی پیدا نشد.", + "settings.group.general": "عمومی", + "settings.group.network": "اتصال", + "settings.group.data": "حریم خصوصی و داده‌ها", + "settings.group.advanced": "پیشرفته", + "stats.share": "سهم ترافیک هر برنامه", + "stats.total": "در مجموع", + "stats.other": "برنامه‌های دیگر", + "share.tab.link": "پیوند", + "share.tab.servers": "سرورها", + "share.servers.explain": "هر سرور با پیوند جداگانه، برای برنامه‌هایی که سرورها را یکی‌یکی اضافه می‌کنند.", + "share.servers.none": "هیچ‌یک از این سرورها را نمی‌توان به‌صورت پیوند نوشت.", + "share.servers.unnamed": "سرور", + "share.servers.copyAll": { one: "کپی {count} پیوند", other: "کپی همهٔ {count} پیوند" }, + "share.servers.copiedAll": { one: "{count} پیوند کپی شد.", other: "{count} پیوند کپی شد." }, }; export default fa; diff --git a/frontend/src/locales/ja.ts b/frontend/src/locales/ja.ts index 163dbe7..aaa71a4 100644 --- a/frontend/src/locales/ja.ts +++ b/frontend/src/locales/ja.ts @@ -762,8 +762,8 @@ const ja: Messages = { "routing.source.regex.hint": "正規表現", "routing.source.port": "ポート", "routing.source.port.hint": "単一のポートまたは範囲", - "routing.source.protocol": "プロトコル", - "routing.source.protocol.hint": "接続の中身を調べて判別", + "routing.source.protocol": "トラフィックのプロトコル", + "routing.source.protocol.hint": "HTTP、DNS、TLS など", "routing.source.geosite": "サービス", "routing.source.geosite.hint": "既知のサービス(ルールセット)", "routing.source.geoip": "国・地域", @@ -777,8 +777,8 @@ const ja: Messages = { "routing.action.block": "ブロック", "routing.action.block.hint": "接続を拒否します", "routing.action.block.status": "接続は拒否されます", - "routing.source.network": "TCP / UDP", - "routing.source.network.hint": "トランスポートで判定", + "routing.source.network": "トランスポート", + "routing.source.network.hint": "TCP または UDP", "routing.source.ruleset": "ルールリスト", "routing.source.ruleset.hint": "URL から取得するサイトとアドレス", "routing.source.logical": "組み合わせ", @@ -852,6 +852,26 @@ const ja: Messages = { "routing.geo.address": "{file} のアドレス", "routing.geo.explain": "Happ や Xray で使われている geoip.dat と geosite.dat。読み込んでいる間は、その種類の内蔵コードの代わりにファイルのコードが使われます。", "routing.geo.offer": "このルーティングは次の場所の {file} を使います:", + "routing.domains.names": "ドメイン名", + "routing.domains.pattern": "パターン", + "routing.domains.patternLocked": "パターンを空にするとドメイン名に戻せます", + "routing.action.show": "キャンバスに戻す", + "settings.search": "設定を検索", + "settings.search.none": "一致する設定はありません。", + "settings.group.general": "一般", + "settings.group.network": "接続", + "settings.group.data": "プライバシーとデータ", + "settings.group.advanced": "詳細", + "stats.share": "アプリ別のトラフィックの割合", + "stats.total": "合計", + "stats.other": "その他のアプリ", + "share.tab.link": "リンク", + "share.tab.servers": "サーバー", + "share.servers.explain": "サーバーごとに個別のリンク。サーバーを1つずつ追加するアプリ向けです。", + "share.servers.none": "これらのサーバーはどれもリンクにできません。", + "share.servers.unnamed": "サーバー", + "share.servers.copyAll": { other: "{count} 件のリンクをすべてコピー" }, + "share.servers.copiedAll": { other: "{count} 件のリンクをコピーしました。" }, }; export default ja; diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 6abb609..aebe44f 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -816,8 +816,8 @@ const ru: Messages = { "routing.source.regex.hint": "Регулярное выражение", "routing.source.port": "Порты", "routing.source.port.hint": "Отдельные порты или диапазоны", - "routing.source.protocol": "Протоколы", - "routing.source.protocol.hint": "Определяются по содержимому соединения", + "routing.source.protocol": "Протоколы трафика", + "routing.source.protocol.hint": "HTTP, DNS, TLS и другие", "routing.source.geosite": "Сервис", "routing.source.geosite.hint": "Известный сервис, по набору правил", "routing.source.geoip": "Страна", @@ -831,8 +831,8 @@ const ru: Messages = { "routing.action.block": "Блокировать", "routing.action.block.hint": "Отклоняет соединение", "routing.action.block.status": "Соединение отклоняется", - "routing.source.network": "TCP / UDP", - "routing.source.network.hint": "По транспорту", + "routing.source.network": "Транспорт", + "routing.source.network.hint": "TCP или UDP", "routing.source.ruleset": "Список правил", "routing.source.ruleset.hint": "Сайты и адреса по ссылке", "routing.source.logical": "Составное", @@ -911,6 +911,26 @@ const ru: Messages = { "routing.geo.address": "Адрес {file}", "routing.geo.explain": "geoip.dat и geosite.dat в том виде, в каком их используют Happ и Xray. Пока файл загружен, его коды заменяют встроенные для своего вида.", "routing.geo.offer": "Эта маршрутизация использует {file} с", + "routing.domains.names": "Имена", + "routing.domains.pattern": "Шаблон", + "routing.domains.patternLocked": "Удалите шаблоны, чтобы вернуться к именам", + "routing.action.show": "Вернуть на холст", + "settings.search": "Поиск по настройкам", + "settings.search.none": "Ничего не найдено.", + "settings.group.general": "Общие", + "settings.group.network": "Подключение", + "settings.group.data": "Приватность и данные", + "settings.group.advanced": "Дополнительно", + "stats.share": "Доля трафика по приложениям", + "stats.total": "всего", + "stats.other": "Остальные", + "share.tab.link": "Ссылка", + "share.tab.servers": "Серверы", + "share.servers.explain": "Каждый сервер отдельной ссылкой — для приложений, которые добавляют серверы по одному.", + "share.servers.none": "Ни один из этих серверов нельзя записать ссылкой.", + "share.servers.unnamed": "Сервер", + "share.servers.copyAll": { one: "Скопировать {count} ссылку", few: "Скопировать {count} ссылки", many: "Скопировать {count} ссылок", other: "Скопировать {count} ссылки" }, + "share.servers.copiedAll": { one: "Скопирована {count} ссылка.", few: "Скопировано {count} ссылки.", many: "Скопировано {count} ссылок.", other: "Скопировано {count} ссылки." }, }; export default ru; diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index fd0041c..413383f 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -816,8 +816,8 @@ const uk: Messages = { "routing.source.regex.hint": "Регулярний вираз", "routing.source.port": "Порти", "routing.source.port.hint": "Окремі порти або діапазони", - "routing.source.protocol": "Протоколи", - "routing.source.protocol.hint": "Визначаються за вмістом з'єднання", + "routing.source.protocol": "Протоколи трафіку", + "routing.source.protocol.hint": "HTTP, DNS, TLS та інші", "routing.source.geosite": "Сервіс", "routing.source.geosite.hint": "Відомий сервіс, за набором правил", "routing.source.geoip": "Країна", @@ -831,8 +831,8 @@ const uk: Messages = { "routing.action.block": "Блокувати", "routing.action.block.hint": "Відхиляє з'єднання", "routing.action.block.status": "З'єднання відхиляється", - "routing.source.network": "TCP / UDP", - "routing.source.network.hint": "За транспортом", + "routing.source.network": "Транспорт", + "routing.source.network.hint": "TCP або UDP", "routing.source.ruleset": "Список правил", "routing.source.ruleset.hint": "Сайти й адреси за посиланням", "routing.source.logical": "Складене", @@ -911,6 +911,26 @@ const uk: Messages = { "routing.geo.address": "Адреса {file}", "routing.geo.explain": "geoip.dat і geosite.dat у тому вигляді, у якому їх використовують Happ і Xray. Поки файл завантажено, його коди замінюють вбудовані для свого виду.", "routing.geo.offer": "Ця маршрутизація використовує {file} з", + "routing.domains.names": "Імена", + "routing.domains.pattern": "Шаблон", + "routing.domains.patternLocked": "Видаліть шаблони, щоб повернутися до імен", + "routing.action.show": "Повернути на полотно", + "settings.search": "Пошук у налаштуваннях", + "settings.search.none": "Нічого не знайдено.", + "settings.group.general": "Загальні", + "settings.group.network": "Підключення", + "settings.group.data": "Приватність і дані", + "settings.group.advanced": "Додатково", + "stats.share": "Частка трафіку за застосунками", + "stats.total": "загалом", + "stats.other": "Інші", + "share.tab.link": "Посилання", + "share.tab.servers": "Сервери", + "share.servers.explain": "Кожен сервер окремим посиланням — для застосунків, що додають сервери по одному.", + "share.servers.none": "Жоден із цих серверів не можна записати посиланням.", + "share.servers.unnamed": "Сервер", + "share.servers.copyAll": { one: "Скопіювати {count} посилання", few: "Скопіювати {count} посилання", many: "Скопіювати {count} посилань", other: "Скопіювати {count} посилання" }, + "share.servers.copiedAll": { one: "Скопійовано {count} посилання.", few: "Скопійовано {count} посилання.", many: "Скопійовано {count} посилань.", other: "Скопійовано {count} посилання." }, }; export default uk; diff --git a/frontend/src/locales/zh.ts b/frontend/src/locales/zh.ts index 6d72d4a..d1bfb54 100644 --- a/frontend/src/locales/zh.ts +++ b/frontend/src/locales/zh.ts @@ -756,8 +756,8 @@ const zh: Messages = { "routing.source.regex.hint": "正则表达式", "routing.source.port": "端口", "routing.source.port.hint": "单个端口或端口范围", - "routing.source.protocol": "协议", - "routing.source.protocol.hint": "通过检查连接内容识别", + "routing.source.protocol": "流量协议", + "routing.source.protocol.hint": "HTTP、DNS、TLS 等", "routing.source.geosite": "服务", "routing.source.geosite.hint": "已知服务,基于规则集", "routing.source.geoip": "国家/地区", @@ -771,8 +771,8 @@ const zh: Messages = { "routing.action.block": "阻止", "routing.action.block.hint": "拒绝连接", "routing.action.block.status": "连接被拒绝", - "routing.source.network": "TCP / UDP", - "routing.source.network.hint": "按传输协议", + "routing.source.network": "传输层", + "routing.source.network.hint": "TCP 或 UDP", "routing.source.ruleset": "规则列表", "routing.source.ruleset.hint": "来自网址的网站和地址", "routing.source.logical": "组合", @@ -846,6 +846,26 @@ const zh: Messages = { "routing.geo.address": "{file} 地址", "routing.geo.explain": "Happ 和 Xray 所使用的 geoip.dat 与 geosite.dat。加载某个文件后,它的代码会替换该类型的内置代码。", "routing.geo.offer": "此路由使用的 {file} 来自", + "routing.domains.names": "域名", + "routing.domains.pattern": "正则", + "routing.domains.patternLocked": "清空正则后才能切回域名", + "routing.action.show": "放回画布", + "settings.search": "搜索设置", + "settings.search.none": "没有匹配的设置。", + "settings.group.general": "常规", + "settings.group.network": "连接", + "settings.group.data": "隐私与数据", + "settings.group.advanced": "高级", + "stats.share": "各应用流量占比", + "stats.total": "总计", + "stats.other": "其他应用", + "share.tab.link": "链接", + "share.tab.servers": "服务器", + "share.servers.explain": "每个服务器单独一条链接,适用于逐个添加服务器的应用。", + "share.servers.none": "这些服务器都无法写成链接。", + "share.servers.unnamed": "服务器", + "share.servers.copyAll": { other: "复制全部 {count} 条链接" }, + "share.servers.copiedAll": { other: "已复制 {count} 条链接。" }, }; export default zh; diff --git a/internal/app/profiles.go b/internal/app/profiles.go index d48ad29..6bc1ebe 100644 --- a/internal/app/profiles.go +++ b/internal/app/profiles.go @@ -126,3 +126,33 @@ func (a *App) SetFavorites(ids []string, favorite bool) ([]models.Profile, error } return a.replaceProfiles(profiles), nil } + +// ShareLinks returns a share link for every server in the given profiles, in +// their order: the link a profile was imported from, or for a JSON or YAML +// config, one link per server in it. Profiles with nothing shareable are +// skipped; an error means none of them had anything. +func (a *App) ShareLinks(ids []string) ([]link.SharedLink, error) { + profiles, _ := a.snapshot() + byID := make(map[string]models.Profile, len(profiles)) + for _, profile := range profiles { + byID[profile.ID] = profile + } + links := []link.SharedLink{} + var lastErr error + for _, id := range ids { + profile, ok := byID[id] + if !ok { + continue + } + shared, err := link.ShareLinks(profile.ConfigLink, profile.Name) + if err != nil { + lastErr = fmt.Errorf("%s: %w", profile.Name, err) + continue + } + links = append(links, shared...) + } + if len(links) == 0 && lastErr != nil { + return nil, lastErr + } + return links, nil +} diff --git a/internal/link/share.go b/internal/link/share.go new file mode 100644 index 0000000..61e22af --- /dev/null +++ b/internal/link/share.go @@ -0,0 +1,570 @@ +package link + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// Turning profiles back into share links. +// +// A profile imported from a link keeps that link, which is what gets shared. +// One imported as a sing-box or Xray JSON config, or legacy Clash YAML, has +// no link of its own: each server in it is encoded as the vless://, vmess:// +// … link that describes it, so it can be added to another client. + +// SharedLink is one server as a share link, named as the link's #fragment. +type SharedLink struct { + Name string `json:"name"` + Link string `json:"link"` +} + +// ShareLinks returns the share links a stored profile describes: the link it +// was imported from, or one per server in a config. A server whose protocol +// has no share link form (WireGuard, SSH) is left out; an error means none of +// them could be. +func ShareLinks(raw, name string) ([]SharedLink, error) { + trimmed := strings.TrimSpace(strings.ReplaceAll(raw, "&", "&")) + if trimmed == "" { + return nil, fmt.Errorf("empty config link") + } + + var outbounds []map[string]any + switch { + case strings.HasPrefix(trimmed, "{"): + var root map[string]any + if err := json.Unmarshal([]byte(trimmed), &root); err != nil { + return nil, fmt.Errorf("invalid JSON config: %w", err) + } + outbounds = configServers(root) + case looksLikeClashYAML(trimmed): + outbound, err := outboundFromClashYAML(trimmed) + if err != nil { + return nil, err + } + outbounds = []map[string]any{outbound} + default: + // Already a share link: share it exactly as it came. + return []SharedLink{{Name: name, Link: trimmed}}, nil + } + + links := make([]SharedLink, 0, len(outbounds)) + var lastErr error + for _, outbound := range outbounds { + label := strings.TrimSpace(stringValue(outbound["tag"])) + if label == "" || len(outbounds) == 1 { + label = name + } + encoded, err := EncodeShareLink(outbound, label) + if err != nil { + lastErr = err + continue + } + links = append(links, SharedLink{Name: label, Link: encoded}) + } + if len(links) == 0 { + if lastErr == nil { + lastErr = fmt.Errorf("the config has no server to share") + } + return nil, lastErr + } + return links, nil +} + +// configServers lists the servers in a sing-box or Xray config, or the one +// outbound object a profile can also store, as sing-box outbounds. +func configServers(root map[string]any) []map[string]any { + if _, has := root["outbounds"]; !has { + if _, xray := root["protocol"]; xray { + if converted := xrayOutbound(root); converted != nil { + return []map[string]any{converted} + } + return nil + } + return []map[string]any{sanitizeOutbound(root)} + } + list, _ := root["outbounds"].([]any) + servers := make([]map[string]any, 0, len(list)) + for _, item := range list { + outbound, ok := item.(map[string]any) + if !ok { + continue + } + if _, xray := outbound["protocol"]; xray { + if converted := xrayOutbound(outbound); converted != nil { + servers = append(servers, converted) + } + continue + } + if isDialableOutbound(outbound) { + servers = append(servers, sanitizeOutboundKeepTag(outbound)) + } + } + return servers +} + +func sanitizeOutboundKeepTag(source map[string]any) map[string]any { + outbound := sanitizeOutbound(source) + if tag := stringValue(source["tag"]); tag != "" { + outbound["tag"] = tag + } + return outbound +} + +// xrayOutbound reads an Xray outbound into the sing-box shape the encoder +// takes. Only what a share link can say is read; nil means it is not a server +// (freedom, blackhole, dns) or not one a link can describe. +func xrayOutbound(source map[string]any) map[string]any { + protocol := strings.ToLower(stringValue(source["protocol"])) + settings, _ := source["settings"].(map[string]any) + if settings == nil { + return nil + } + first := func(key string) map[string]any { + list, _ := settings[key].([]any) + if len(list) == 0 { + return nil + } + entry, _ := list[0].(map[string]any) + return entry + } + outbound := map[string]any{"tag": stringValue(source["tag"])} + + switch protocol { + case "vless", "vmess": + server := first("vnext") + // Newer Xray configs put the server straight in settings. + if server == nil { + server = settings + } + users, _ := server["users"].([]any) + user := map[string]any{} + if len(users) > 0 { + user, _ = users[0].(map[string]any) + } else if id := stringValue(server["id"]); id != "" { + user = server + } + outbound["type"] = protocol + outbound["server"] = stringValue(server["address"]) + outbound["server_port"] = intValue(server["port"]) + outbound["uuid"] = stringValue(user["id"]) + if protocol == "vless" { + if flow := stringValue(user["flow"]); flow != "" { + outbound["flow"] = flow + } + if encryption := stringValue(user["encryption"]); encryption != "" && !strings.EqualFold(encryption, "none") { + outbound["encryption"] = encryption + } + } else { + outbound["security"] = defaultString(stringValue(user["security"]), "auto") + if alterID := intValue(user["alterId"]); alterID > 0 { + outbound["alter_id"] = alterID + } + } + case "trojan": + server := first("servers") + if server == nil { + server = settings + } + outbound["type"] = "trojan" + outbound["server"] = stringValue(server["address"]) + outbound["server_port"] = intValue(server["port"]) + outbound["password"] = stringValue(server["password"]) + case "shadowsocks": + server := first("servers") + if server == nil { + server = settings + } + outbound["type"] = "shadowsocks" + outbound["server"] = stringValue(server["address"]) + outbound["server_port"] = intValue(server["port"]) + outbound["method"] = stringValue(server["method"]) + outbound["password"] = stringValue(server["password"]) + case "hysteria", "hysteria2": + outbound["type"] = "hysteria2" + outbound["server"] = stringValue(settings["address"]) + outbound["server_port"] = intValue(settings["port"]) + if password := stringValue(settings["password"]); password != "" { + outbound["password"] = password + } + case "socks", "http": + server := first("servers") + if server == nil { + return nil + } + outbound["type"] = protocol + outbound["server"] = stringValue(server["address"]) + outbound["server_port"] = intValue(server["port"]) + if users, _ := server["users"].([]any); len(users) > 0 { + if user, ok := users[0].(map[string]any); ok { + outbound["username"] = stringValue(user["user"]) + outbound["password"] = stringValue(user["pass"]) + } + } + default: + return nil + } + if stringValue(outbound["server"]) == "" || intValue(outbound["server_port"]) == 0 { + return nil + } + + stream, _ := source["streamSettings"].(map[string]any) + if stream == nil { + if outbound["type"] == "hysteria2" { + outbound["tls"] = map[string]any{"enabled": true, "server_name": outbound["server"]} + } + return outbound + } + security := strings.ToLower(stringValue(stream["security"])) + switch security { + case "tls": + settings, _ := stream["tlsSettings"].(map[string]any) + outbound["tls"] = xrayTLS(settings, false) + case "reality": + settings, _ := stream["realitySettings"].(map[string]any) + outbound["tls"] = xrayTLS(settings, true) + default: + if outbound["type"] == "hysteria2" { + outbound["tls"] = map[string]any{"enabled": true, "server_name": outbound["server"]} + } + } + if transport := xrayTransport(stream); transport != nil { + outbound["transport"] = transport + } + return outbound +} + +func xrayTLS(settings map[string]any, reality bool) map[string]any { + tls := map[string]any{"enabled": true, "server_name": stringValue(settings["serverName"])} + if alpn := stringList(settings["alpn"]); len(alpn) > 0 { + tls["alpn"] = alpn + } + if fingerprint := stringValue(settings["fingerprint"]); fingerprint != "" { + tls["utls"] = map[string]any{"enabled": true, "fingerprint": fingerprint} + } + if settings["allowInsecure"] == true { + tls["insecure"] = true + } + if reality { + tls["reality"] = map[string]any{ + "enabled": true, + "public_key": defaultString(stringValue(settings["publicKey"]), stringValue(settings["password"])), + "short_id": stringValue(settings["shortId"]), + } + } + return tls +} + +func xrayTransport(stream map[string]any) map[string]any { + network := normalizeTransport(stringValue(stream["network"])) + section := func(name string) map[string]any { + value, _ := stream[name].(map[string]any) + if value == nil { + value = map[string]any{} + } + return value + } + switch network { + case "ws": + ws := section("wsSettings") + host := stringValue(ws["host"]) + if headers, ok := ws["headers"].(map[string]any); ok && host == "" { + host = stringValue(headers["Host"]) + } + return map[string]any{"type": "ws", "path": defaultString(stringValue(ws["path"]), "/"), "headers": map[string]any{"Host": host}} + case "grpc": + return map[string]any{"type": "grpc", "service_name": stringValue(section("grpcSettings")["serviceName"])} + case "http": + h2 := section("httpSettings") + return map[string]any{"type": "http", "host": stringList(h2["host"]), "path": defaultString(stringValue(h2["path"]), "/")} + case "httpupgrade": + upgrade := section("httpupgradeSettings") + return map[string]any{"type": "httpupgrade", "host": stringValue(upgrade["host"]), "path": defaultString(stringValue(upgrade["path"]), "/")} + case "xhttp": + xhttp := section("xhttpSettings") + if len(xhttp) == 0 { + xhttp = section("splithttpSettings") + } + transport := map[string]any{ + "type": "xhttp", + "host": stringValue(xhttp["host"]), + "path": defaultString(stringValue(xhttp["path"]), "/"), + } + if mode := stringValue(xhttp["mode"]); mode != "" { + transport["mode"] = mode + } + mergeXHTTP(transport, xhttp) + return transport + case "quic": + return map[string]any{"type": "quic"} + } + return nil +} + +// EncodeShareLink writes a sing-box outbound as the share link that parses +// back into it. +func EncodeShareLink(outbound map[string]any, name string) (string, error) { + kind := stringValue(outbound["type"]) + host := stringValue(outbound["server"]) + port := intValue(outbound["server_port"]) + if host == "" || port == 0 { + return "", fmt.Errorf("%s server has no address", kind) + } + address := net.JoinHostPort(host, strconv.Itoa(port)) + tls, _ := outbound["tls"].(map[string]any) + transport, _ := outbound["transport"].(map[string]any) + fragment := "" + if name != "" { + fragment = "#" + url.PathEscape(name) + } + + switch kind { + case "vless": + params := url.Values{} + params.Set("encryption", defaultString(stringValue(outbound["encryption"]), "none")) + if flow := stringValue(outbound["flow"]); flow != "" { + params.Set("flow", flow) + } + writeTLS(params, tls, host) + writeTransport(params, transport) + return "vless://" + url.PathEscape(stringValue(outbound["uuid"])) + "@" + address + "?" + params.Encode() + fragment, nil + + case "trojan": + params := url.Values{} + writeTLS(params, tls, host) + writeTransport(params, transport) + return "trojan://" + url.PathEscape(stringValue(outbound["password"])) + "@" + address + "?" + params.Encode() + fragment, nil + + case "vmess": + params := url.Values{} + writeTransport(params, transport) + payload := map[string]any{ + "v": "2", "ps": name, "add": host, "port": strconv.Itoa(port), + "id": stringValue(outbound["uuid"]), + "aid": strconv.Itoa(intValue(outbound["alter_id"])), + "scy": defaultString(stringValue(outbound["security"]), "auto"), + "net": defaultString(params.Get("type"), "tcp"), "type": "none", + "host": params.Get("host"), "path": params.Get("path"), "tls": "", + } + if service := params.Get("serviceName"); service != "" { + payload["path"] = service + } + if tls != nil && tls["enabled"] != false { + payload["tls"] = "tls" + payload["sni"] = stringValue(tls["server_name"]) + if alpn := stringList(tls["alpn"]); len(alpn) > 0 { + payload["alpn"] = strings.Join(alpn, ",") + } + if utls, ok := tls["utls"].(map[string]any); ok { + payload["fp"] = stringValue(utls["fingerprint"]) + } + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + return "vmess://" + base64.StdEncoding.EncodeToString(encoded), nil + + case "shadowsocks": + credentials := base64.RawURLEncoding.EncodeToString([]byte(stringValue(outbound["method"]) + ":" + stringValue(outbound["password"]))) + query := "" + if plugin := stringValue(outbound["plugin"]); plugin != "" { + value := plugin + if opts := stringValue(outbound["plugin_opts"]); opts != "" { + value += ";" + opts + } + query = "?" + url.Values{"plugin": {value}}.Encode() + } + return "ss://" + credentials + "@" + address + query + fragment, nil + + case "hysteria2": + params := url.Values{} + writeTLS(params, tls, host) + params.Del("security") + if obfs, ok := outbound["obfs"].(map[string]any); ok && stringValue(obfs["type"]) != "" { + params.Set("obfs", stringValue(obfs["type"])) + params.Set("obfs-password", stringValue(obfs["password"])) + } + if up := intValue(outbound["up_mbps"]); up > 0 { + params.Set("upmbps", strconv.Itoa(up)) + } + if down := intValue(outbound["down_mbps"]); down > 0 { + params.Set("downmbps", strconv.Itoa(down)) + } + user := "" + if password := stringValue(outbound["password"]); password != "" { + user = url.PathEscape(password) + "@" + } + return "hysteria2://" + user + address + "/?" + params.Encode() + fragment, nil + + case "hysteria": + params := url.Values{} + writeTLS(params, tls, host) + params.Del("security") + if sni := params.Get("sni"); sni != "" { + params.Set("peer", sni) + } + if auth := stringValue(outbound["auth_str"]); auth != "" { + params.Set("auth", auth) + } + if obfs := stringValue(outbound["obfs"]); obfs != "" { + params.Set("obfsParam", obfs) + } + params.Set("upmbps", strconv.Itoa(defaultInt(intValue(outbound["up_mbps"]), 100))) + params.Set("downmbps", strconv.Itoa(defaultInt(intValue(outbound["down_mbps"]), 100))) + return "hysteria://" + address + "?" + params.Encode() + fragment, nil + + case "tuic": + params := url.Values{} + writeTLS(params, tls, host) + params.Del("security") + if congestion := stringValue(outbound["congestion_control"]); congestion != "" { + params.Set("congestion_control", congestion) + } + if mode := stringValue(outbound["udp_relay_mode"]); mode != "" { + params.Set("udp_relay_mode", mode) + } + user := url.PathEscape(stringValue(outbound["uuid"])) + if password := stringValue(outbound["password"]); password != "" { + user += ":" + url.PathEscape(password) + } + return "tuic://" + user + "@" + address + "?" + params.Encode() + fragment, nil + + case "socks", "http": + scheme := map[string]string{"socks": "socks5", "http": "http"}[kind] + if kind == "http" && tls != nil && tls["enabled"] != false { + scheme = "https" + } + user := "" + if username := stringValue(outbound["username"]); username != "" { + user = url.UserPassword(username, stringValue(outbound["password"])).String() + "@" + } + return scheme + "://" + user + address + fragment, nil + } + return "", &ErrUnsupportedProtocol{Scheme: kind, Reason: "it has no share link form"} +} + +// writeTLS sets the TLS and Reality parameters parseVLESS and friends read. +func writeTLS(params url.Values, tls map[string]any, host string) { + if tls == nil || tls["enabled"] == false { + params.Set("security", "none") + return + } + reality, _ := tls["reality"].(map[string]any) + if reality != nil && reality["enabled"] != false { + params.Set("security", "reality") + params.Set("pbk", stringValue(reality["public_key"])) + if shortID := stringValue(reality["short_id"]); shortID != "" { + params.Set("sid", shortID) + } + } else { + params.Set("security", "tls") + } + if serverName := stringValue(tls["server_name"]); serverName != "" && serverName != host { + params.Set("sni", serverName) + } else if serverName != "" { + params.Set("sni", serverName) + } + if alpn := stringList(tls["alpn"]); len(alpn) > 0 { + params.Set("alpn", strings.Join(alpn, ",")) + } + if utls, ok := tls["utls"].(map[string]any); ok { + if fingerprint := stringValue(utls["fingerprint"]); fingerprint != "" { + params.Set("fp", fingerprint) + } + } + if tls["insecure"] == true { + params.Set("allowInsecure", "1") + } +} + +// writeTransport sets the transport parameters buildTransport reads. +func writeTransport(params url.Values, transport map[string]any) { + if transport == nil { + params.Set("type", "tcp") + return + } + kind := stringValue(transport["type"]) + switch kind { + case "ws": + params.Set("type", "ws") + path := stringValue(transport["path"]) + if early := intValue(transport["max_early_data"]); early > 0 { + path += "?ed=" + strconv.Itoa(early) + } + params.Set("path", path) + if headers, ok := transport["headers"].(map[string]any); ok { + if host := stringValue(headers["Host"]); host != "" { + params.Set("host", host) + } + } + case "grpc": + params.Set("type", "grpc") + params.Set("serviceName", stringValue(transport["service_name"])) + case "http": + params.Set("type", "http") + params.Set("path", stringValue(transport["path"])) + if hosts := stringList(transport["host"]); len(hosts) > 0 { + params.Set("host", hosts[0]) + } + case "httpupgrade", "xhttp": + params.Set("type", kind) + params.Set("path", stringValue(transport["path"])) + if host := stringValue(transport["host"]); host != "" { + params.Set("host", host) + } + if kind == "xhttp" { + if mode := stringValue(transport["mode"]); mode != "" { + params.Set("mode", mode) + } + // Everything past host, path and mode rides in "extra", as + // Xray's own JSON for the transport. + extra := map[string]any{} + for key, value := range transport { + switch key { + case "type", "host", "path", "mode": + default: + extra[key] = value + } + } + if len(extra) > 0 { + if encoded, err := json.Marshal(extra); err == nil { + params.Set("extra", string(encoded)) + } + } + } + case "quic": + params.Set("type", "quic") + default: + params.Set("type", "tcp") + } +} + +func stringList(value any) []string { + switch typed := value.(type) { + case []string: + return typed + case []any: + list := make([]string, 0, len(typed)) + for _, item := range typed { + if text := stringValue(item); text != "" { + list = append(list, text) + } + } + return list + case string: + return splitList(typed) + } + return nil +} + +func defaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/internal/link/share_test.go b/internal/link/share_test.go new file mode 100644 index 0000000..45a15f8 --- /dev/null +++ b/internal/link/share_test.go @@ -0,0 +1,116 @@ +package link + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/Rigby-Foundation/NuggetVPN/internal/models" +) + +// A server written out as a link must parse back into the same server. +func TestEncodeShareLinkRoundTrips(t *testing.T) { + links := []string{ + "vless://11111111-2222-3333-4444-555555555555@example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.microsoft.com&fp=chrome&pbk=PUBKEY&sid=ab12&type=tcp#Reality", + "vless://11111111-2222-3333-4444-555555555555@example.com:443?security=tls&sni=cdn.example.com&type=ws&path=%2Fws%3Fed%3D2048&host=cdn.example.com#WS", + "vless://11111111-2222-3333-4444-555555555555@example.com:443?security=tls&type=grpc&serviceName=svc#gRPC", + "vless://11111111-2222-3333-4444-555555555555@example.com:443?security=tls&type=xhttp&path=%2Fx&host=h.example.com&mode=packet-up#XHTTP", + "trojan://secret@example.com:443?sni=example.com&alpn=h2,http/1.1#Trojan", + "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388#SS", + "hysteria2://pass@example.com:443/?sni=example.com&obfs=salamander&obfs-password=x&insecure=1#Hy2", + "tuic://uuid-value:pw@example.com:443?sni=example.com&congestion_control=bbr&udp_relay_mode=native#TUIC", + "vmess://" + vmessPayload(t), + } + for _, original := range links { + want, err := ParseOutbound(original, models.AppSettings{}) + if err != nil { + t.Fatalf("parse %s: %v", original, err) + } + encoded, err := EncodeShareLink(want, "name") + if err != nil { + t.Fatalf("encode %s: %v", original, err) + } + got, err := ParseOutbound(encoded, models.AppSettings{}) + if err != nil { + t.Fatalf("parse encoded %s: %v", encoded, err) + } + if !reflect.DeepEqual(normalize(t, want), normalize(t, got)) { + t.Errorf("round trip changed the server\n link: %s\n out: %s\n want: %v\n got: %v", original, encoded, want, got) + } + } +} + +func vmessPayload(t *testing.T) string { + t.Helper() + encoded, err := json.Marshal(map[string]any{ + "v": "2", "ps": "VMess", "add": "example.com", "port": "443", "id": "11111111-2222-3333-4444-555555555555", + "aid": "0", "scy": "auto", "net": "ws", "host": "example.com", "path": "/v", "tls": "tls", "sni": "example.com", + }) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(encoded) +} + +// normalize compares through JSON, so []string and []any agree. +func normalize(t *testing.T, value any) any { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + return decoded +} + +// Every server in an Xray config comes out as its own link. +func TestShareLinksFromXrayConfig(t *testing.T) { + config := `{ + "inbounds": [{"port": 10808, "protocol": "socks"}], + "outbounds": [ + {"tag": "nl", "protocol": "vless", + "settings": {"vnext": [{"address": "nl.example.com", "port": 443, "users": [{"id": "uuid-1", "flow": "xtls-rprx-vision", "encryption": "none"}]}]}, + "streamSettings": {"network": "tcp", "security": "reality", + "realitySettings": {"serverName": "www.microsoft.com", "publicKey": "PBK", "shortId": "01", "fingerprint": "chrome"}}}, + {"tag": "de", "protocol": "trojan", + "settings": {"servers": [{"address": "de.example.com", "port": 443, "password": "pw"}]}, + "streamSettings": {"network": "ws", "security": "tls", "tlsSettings": {"serverName": "de.example.com"}, "wsSettings": {"path": "/t", "headers": {"Host": "de.example.com"}}}}, + {"tag": "direct", "protocol": "freedom", "settings": {}} + ] + }` + links, err := ShareLinks(config, "Config") + if err != nil { + t.Fatal(err) + } + if len(links) != 2 { + t.Fatalf("got %d links, want 2: %v", len(links), links) + } + if links[0].Name != "nl" || !strings.HasPrefix(links[0].Link, "vless://uuid-1@nl.example.com:443?") { + t.Errorf("first link: %+v", links[0]) + } + parsed, err := ParseOutbound(links[0].Link, models.AppSettings{}) + if err != nil { + t.Fatal(err) + } + reality := parsed["tls"].(map[string]any)["reality"].(map[string]any) + if reality["public_key"] != "PBK" || parsed["flow"] != "xtls-rprx-vision" { + t.Errorf("reality settings lost: %v", parsed) + } + if !strings.HasPrefix(links[1].Link, "trojan://pw@de.example.com:443?") || !strings.Contains(links[1].Link, "type=ws") { + t.Errorf("second link: %+v", links[1]) + } +} + +// A profile imported from a link shares that link untouched. +func TestShareLinksKeepsTheOriginalLink(t *testing.T) { + original := "vless://id@example.com:443?security=tls&custom=kept#Mine" + links, err := ShareLinks(original, "Mine") + if err != nil || len(links) != 1 || links[0].Link != original { + t.Fatalf("got %v, %v", links, err) + } +} From 8fe5a77f02c1e4742bc3d1760f6cb4453426653e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:03:21 +0000 Subject: [PATCH 3/3] Show the connection status once, above the connect button The status was on screen three times at once: the line above the button, the caption under it ("Your traffic is going through the tunnel") and the sidebar footer (a dot on the bottom bar on phones). Only the line above the button stays. The caption under the button now appears only when it says something the status line doesn't: how to connect, what connecting is doing, a reconnect or an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VSvNnejWtHcmvqR13Uj7oU --- frontend/src/App.tsx | 3 +-- frontend/src/components/layout/AppSidebar.tsx | 26 ------------------- frontend/src/components/layout/BottomNav.tsx | 8 +----- .../src/components/views/ConnectionView.tsx | 9 ++++--- frontend/src/locales/en.ts | 1 - frontend/src/locales/fa.ts | 1 - frontend/src/locales/ja.ts | 1 - frontend/src/locales/ru.ts | 1 - frontend/src/locales/uk.ts | 1 - frontend/src/locales/zh.ts | 1 - 10 files changed, 7 insertions(+), 45 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a48dda9..30c5cf8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1005,7 +1005,6 @@ function App() { onMinimize={appWindow.minimize} onMaximize={appWindow.toggleMaximize} platform={platform} - status={connection.state.status} /> {isMobile ? ( - + ) : null}
  • diff --git a/frontend/src/components/layout/AppSidebar.tsx b/frontend/src/components/layout/AppSidebar.tsx index 7986cca..bb32a2c 100644 --- a/frontend/src/components/layout/AppSidebar.tsx +++ b/frontend/src/components/layout/AppSidebar.tsx @@ -14,7 +14,6 @@ import { import { MacWindowControls } from "@/components/layout/MacWindowControls"; import { MessageKey, useI18n } from "@/lib/i18n"; import { cn } from "@/lib/utils"; -import { ConnectionStatus } from "@/types"; interface AppSidebarProps { activeTab: string; @@ -23,7 +22,6 @@ interface AppSidebarProps { onMinimize: () => void; onMaximize: () => void; platform?: string; - status: ConnectionStatus; } export const TABS = [ @@ -36,19 +34,6 @@ export const TABS = [ { id: "logs", label: "nav.logs", icon: FileText }, ] as const satisfies readonly { id: string; label: MessageKey; icon: unknown }[]; -export const STATUS_DOT: Record = { - idle: "bg-status-idle", - connecting: "bg-status-connecting animate-pulse", - connected: "bg-status-connected", - error: "bg-status-error", -}; - -const STATUS_LABEL: Record = { - idle: "status.idle", - connecting: "status.connecting", - connected: "status.connected", - error: "status.error", -}; function AppSidebar({ activeTab, @@ -57,7 +42,6 @@ function AppSidebar({ onMinimize, onMaximize, platform, - status, }: AppSidebarProps) { const { t, dir } = useI18n(); const isMac = platform === "macos"; @@ -104,16 +88,6 @@ function AppSidebar({ - {/* The tunnel state is visible from every screen, not only the - one screen that happens to be about connecting. */} -
    -
    - (PRIMARY.map((tab) => tab.id)); export function BottomNav({ activeTab, onTabChange, - status, }: { activeTab: string; onTabChange: (tab: string) => void; - status: ConnectionStatus; }) { const t = useT(); const [more, setMore] = useState(false); @@ -57,9 +54,6 @@ export function BottomNav({
    {t("connections.col.site")} {t("connections.col.app")}