diff --git a/.gitignore b/.gitignore index e93008d1..b317b22f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist src-tauri/target src-tauri/gen .DS_Store + +# tsc -b 的增量缓存 +*.tsbuildinfo diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c0d4f1bb..03bd094e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4188,7 +4188,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tw-api" version = "0.1.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?branch=main#fef4e99a1e881c9393c3f807336ba8f52d6d0ced" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?branch=main#4d777064540fcefa052d063eab676989351655a0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1f6785fd..27f7d921 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "主窗口的能力。Tauri v2 的 capability 漏配是静默失败 —— 加一个新命令就要回来确认这里有它。", + "description": "主窗口的能力。Tauri v2 的 capability 漏配是静默失败 —— 加一个新命令就要回来确认这里有它。start-dragging 是同一个坑的实例:标题栏是 Overlay,没有一条真的标题栏可以抓,窗口全靠 data-tauri-drag-region 移动,而那条权限不在 core:window 的默认集合里。少了它拖拽区毫无反应,也不报错。", "windows": [ "main" ], @@ -11,6 +11,7 @@ "core:window:allow-hide", "core:window:allow-close", "core:window:allow-set-focus", + "core:window:allow-start-dragging", "core:app:allow-app-hide", "core:event:default", "core:tray:default", diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 24f9c647..89cbcd58 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -517,6 +517,26 @@ impl ControlClient { .await } + /// 生成一把新的网关密钥。**不写进配置** —— 只是拿一个值去填。 + /// + /// 在 core 里生成,不在界面里:字母表和长度是安全决定,两处各写 + /// 一份的话迟早只有一处被改。 + pub async fn new_key(&self) -> Result { + let body = self.get("/keys/new").await?; + let k: tw_api::NewKey = serde_json::from_slice(&body)?; + Ok(k.key) + } + + /// 这台机器上有哪些网卡。 + /// + /// **每次现问,不缓存。**插拔网线、连上另一个 Wi-Fi、起一条 VPN, + /// 清单就变了 —— 缓存下来只会让选单里出现一个已经不存在的地址, + /// 而选中它的后果是网关起不来。 + pub async fn interfaces(&self) -> Result> { + let body = self.get("/interfaces").await?; + Ok(serde_json::from_slice(&body)?) + } + /// 界面要显示的配置概览。 pub async fn overview(&self) -> Result { let body = self.get("/overview").await?; @@ -574,6 +594,27 @@ impl ControlClient { mod tests { use super::*; + /// 界面发出来的每一种补丁操作,这一层都要认得。 + /// + /// **这条缝是真的裂过。**`tw-api` 是按 rev 锁在 core 的 main 上的, + /// 而补丁协议加一种操作时,界面这边只是多写一个字符串 —— TypeScript + /// 编译得过、Rust 编译也得过(没有哪一行提到那个新分支),只有用户 + /// 点下去的那一刻才失败。把 UI 发的原样 JSON 在这儿解一遍,锁没跟上 + /// 就是编译期的事,不是运行期的。 + #[test] + fn every_patch_op_the_ui_sends_still_deserialises() { + let raw = r#"[ + {"op":"replace","path":"/listen/gateway/bind","value":"all"}, + {"op":"replace","path":"/clients/demo/max_concurrent","value":3}, + {"op":"replace","path":"/clients/demo/route","value":null}, + {"op":"append","path":"/clients","item":"name: a\nkey: tw-x"}, + {"op":"remove","path":"/clients/a"}, + {"op":"clear","path":"/clients/demo/allow"} + ]"#; + let ops: Vec = serde_json::from_str(raw).expect("UI 发的 op 解不动"); + assert_eq!(ops.len(), 6); + } + #[tokio::test] async fn connecting_to_a_missing_socket_says_core_might_be_down() { // 「Connection refused」对用户毫无意义。这里要说的是「core 可能 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ff1e7329..4b61caa9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -95,6 +95,20 @@ pub fn locate_core(app: &tauri::AppHandle) -> anyhow::Result { ) } +#[tauri::command] +async fn new_key(state: tauri::State<'_, AppState>) -> Result { + state.control.new_key().await.map_err(|e| format!("{e:#}")) +} + +#[tauri::command] +async fn interfaces(state: tauri::State<'_, AppState>) -> Result, String> { + state + .control + .interfaces() + .await + .map_err(|e| format!("{e:#}")) +} + #[tauri::command] async fn core_status(state: tauri::State<'_, AppState>) -> Result { // Tauri 的 invoke 用**字符串** reject,不是 Error 对象 —— @@ -798,6 +812,8 @@ pub fn run() { )) .invoke_handler(tauri::generate_handler![ core_status, + interfaces, + new_key, core_state, overview, probe_upstream, diff --git a/src/App.tsx b/src/App.tsx index 6d6929a9..65d85007 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,26 @@ import { } from "./requestTable"; import Config from "./Config"; import Clients from "./Clients"; +import Keys from "./Keys"; +import Routes from "./Routes"; import Security from "./Security"; import Guard from "./Guard"; import { Dialog, DialogButton } from "./ui/Dialog"; import { Tip, TooltipRoot } from "./ui/Tooltip"; +import { + IconClient, + IconDashboard, + IconFindings, + IconFlow, + IconGateway, + IconGuard, + IconRoute, + IconSession, + IconKey, + IconServer, + IconSettings, + IconSidebar, +} from "./ui/icons"; import { RowMenu } from "./ui/ContextMenu"; import Sessions from "./Sessions"; import Dashboard from "./Dashboard"; @@ -44,20 +60,26 @@ type Surface = | "security" | "guard" | "routing" + | "keys" + | "upstreams" | "config" | "settings" | "clients"; -const SOURCES: { group: string; items: { id: Surface; label: string }[] }[] = [ +type SourceIcon = (p: { size?: number }) => React.ReactElement; +const SOURCES: { + group: string; + items: { id: Surface; label: string; icon: SourceIcon }[]; +}[] = [ { group: "监控", items: [ // **概览在最上面。**它回答的是「现在什么情况」,而流量和会话回答 // 的是「刚才那一条发生了什么」—— 前者是打开这个应用的默认意图, // 后者是带着问题来的时候才点。 - { id: "dashboard", label: "概览" }, - { id: "requests", label: "流量" }, - { id: "sessions", label: "会话" }, + { id: "dashboard", label: "概览" , icon: IconDashboard }, + { id: "requests", label: "流量" , icon: IconFlow }, + { id: "sessions", label: "会话" , icon: IconSession }, ], }, { @@ -71,8 +93,8 @@ const SOURCES: { group: string; items: { id: Surface; label: string }[] }[] = [ // 所以拆成两项:发现(看证据)和防护(配策略)。 group: "安全", items: [ - { id: "security", label: "发现" }, - { id: "guard", label: "防护" }, + { id: "security", label: "发现" , icon: IconFindings }, + { id: "guard", label: "防护" , icon: IconGuard }, ], }, { @@ -80,9 +102,11 @@ const SOURCES: { group: string; items: { id: Surface; label: string }[] }[] = [ items: [ // 路由原来埋在配置页中段,和「监听与访问」「诊断包」并列 —— // 而它是这个产品区别于一个普通代理的核心概念,不该要滚两屏才看见。 - { id: "routing", label: "路由" }, - { id: "config", label: "网关" }, - { id: "clients", label: "客户端" }, + { id: "upstreams", label: "上游" , icon: IconServer }, + { id: "keys", label: "密钥" , icon: IconKey }, + { id: "routing", label: "路由" , icon: IconRoute }, + { id: "config", label: "网关" , icon: IconGateway }, + { id: "clients", label: "客户端" , icon: IconClient }, ], }, { @@ -92,20 +116,33 @@ const SOURCES: { group: string; items: { id: Surface; label: string }[] }[] = [ // 那是两类完全不同的东西:一个写进系统的登录项,一个写进 config.yaml。 // 分界线就是这个:改的是这个 macOS 应用,还是改网关的配置文件。 group: "应用", - items: [{ id: "settings", label: "设置" }], + items: [{ id: "settings", label: "设置" , icon: IconSettings }], }, ]; -function describeCore(raw: string): { text: string; tone: "ok" | "warn" | "bad" } { - if (raw.startsWith("running:")) return { text: "运行中", tone: "ok" }; - if (raw === "starting") return { text: "启动中", tone: "warn" }; +/** + * core 的状态说成人话。 + * + * `short` 是给收起的源列表用的 —— 那里只有 80px,「重启中(第 3 次)」 + * 和「安全模式 · 网关未运行」都放不下。**不是截断,是另写一句**:截断 + * 出来的「安全模式 · 网关未…」比四个字更难读,而第几次重试在那个宽度上 + * 本来就是悬停才看的细节。 + */ +function describeCore(raw: string): { + text: string; + short: string; + tone: "ok" | "warn" | "bad"; +} { + if (raw.startsWith("running:")) return { text: "运行中", short: "运行中", tone: "ok" }; + if (raw === "starting") return { text: "启动中", short: "启动中", tone: "warn" }; if (raw.startsWith("restarting:")) { const [, attempt] = raw.split(":"); - return { text: `重启中(第 ${attempt} 次)`, tone: "warn" }; + return { text: `重启中(第 ${attempt} 次)`, short: "重启中", tone: "warn" }; } // 安全模式必须显眼:这时候网关不转发了,用户所有的 AI 客户端都在瞎。 - if (raw === "safe_mode") return { text: "安全模式 · 网关未运行", tone: "bad" }; - return { text: "已停止", tone: "bad" }; + if (raw === "safe_mode") + return { text: "安全模式 · 网关未运行", short: "安全模式", tone: "bad" }; + return { text: "已停止", short: "已停止", tone: "bad" }; } /** 可排序表头。箭头只出现在当前排序列上 —— 每列都挂一个等于没挂。 */ @@ -241,6 +278,29 @@ export default function App() { // 「保存」还看着「还没有上游」,会以为没生效(和那条一样的理由)。 const [nudge, setNudge] = useState(0); + /** + * 源列表收起还是展开。 + * + * **记住用户的选择。**这是一个开着不关的应用——每次启动都把它展开 + * 回来,等于每次都要重按一遍。读取放在初始化里而不是 effect 里, + * 否则第一帧会先按默认宽度画一次再跳。 + */ + const [railOpen, setRailOpen] = useState(() => { + try { + return window.localStorage.getItem("rail") !== "collapsed"; + } catch { + // 隐私窗口、禁用站点数据都会在这里抛 + return true; + } + }); + useEffect(() => { + try { + window.localStorage.setItem("rail", railOpen ? "open" : "collapsed"); + } catch { + // 记不住只是下次要重按一遍,不值得打断任何事 + } + }, [railOpen]); + useEffect(() => { const now = rows.map((r) => r.id); const news = now.filter((id) => !seenIds.current.has(id)); @@ -282,6 +342,14 @@ export default function App() { // 里按它该重选。加上这两个前提就等于把它变成「已经在搜索框里的时 // 候才有用」。 —— ⌘F 的全部意义就是从任何 // 地方跳到搜索框,而「正在输入」恰恰是它最该生效的场景之一。 + // ⌘⌥S 收起/展开源列表 —— 访达、邮件、备忘录都是这个键。 + // 判 `code` 不判 `key`:macOS 上 ⌥ 会把 s 变成 ß。 + if (e.metaKey && e.altKey && !e.ctrlKey && e.code === "KeyS") { + e.preventDefault(); + setRailOpen((v) => !v); + return; + } + if (e.metaKey && !e.altKey && !e.ctrlKey) { const k = e.key.toLowerCase(); if (k === "f") { @@ -405,44 +473,107 @@ export default function App() { return ( -
+
{/* 源列表。整条都是拖拽区 —— 窗口用的是 Overlay 标题栏(红绿灯浮在 内容上),没有一条真的标题栏可以抓,不给拖拽区窗口就挪不动。 + + **可以收起。**收起之后只剩图标,内容区多出 116px —— 对一个开着 + 不关、一直在看图表的应用,这是唯一真正改善主界面的方向。名字进 + 悬浮说明,所以收起来不是把信息丢掉,是把它推迟到需要的时候。 + + **收起宽度 80px 不是审美选的,是红绿灯定的。**标题栏是 Overlay, + 三颗灯浮在内容上,最右那颗绿灯的右边缘落在约 71pt 处。侧栏窄于 + 这个数,右边框就会从绿灯身上穿过去 —— 之前用 60px 正是如此。 + 80 给了它 9pt 余量。**改窄之前先量一遍那三颗灯。** + + **没有分组标题,只有细分隔线。**四个标题原本吃掉列表约三分之一 + 的高度,而它们说的事情分隔线也说得出:这两项和上面那两项不一样。 + 代价是分组的**名字**没了 —— 认下这笔,换来整列读起来是一个对象, + 而不是四个小区块。 */} @@ -485,7 +658,31 @@ export default function App() { (split ? "overflow-hidden" : "overflow-y-auto") } > -
+ {/* + 标题栏那一条。整条是拖拽区,按钮不是 —— 拖拽区只作用在带那个 + 属性的元素上,不带的子元素照常可点。 + + **收起源列表的按钮放在这儿,不放在源列表里。**收起之后源列表 + 只有 60px 宽,按钮塞进去要么挤掉一个图标位,要么小到点不准; + 而放在内容这一侧,它在两种状态下都在同一个位置。系统应用 + (访达、邮件)也是这么放的。 + */} +
+ + + +
{error && (
{error} @@ -579,7 +776,10 @@ export default function App() { ) : tab === "dashboard" ? ( ) : tab === "clients" ? ( - + c.name)} + configVersion={configVersion} + /> ) : tab === "security" ? ( ) : tab === "guard" ? ( @@ -592,11 +792,43 @@ export default function App() { ) : (

读取配置中…

) - ) : tab === "routing" || tab === "config" || tab === "settings" ? ( + ) : tab === "keys" ? ( + ov ? ( + setNudge((n) => n + 1)} + /> + ) : ( +

读取配置中…

+ ) + ) : tab === "routing" ? ( + ov ? ( + <> + setNudge((n) => n + 1)} + /> + {/* 试算和策略组还在 Config 里 —— 它俩和文本模式那条路缠着 */} + setNudge((n) => n + 1)} + /> + + ) : ( +

读取配置中…

+ ) + ) : tab === "upstreams" || tab === "config" || tab === "settings" ? ( ov ? ( (null); const [error, setError] = useState(null); const [plan, setPlan] = useState<{ p: PlanView; c: DetectedClient; restore: boolean } | null>( @@ -71,8 +78,31 @@ export default function Clients() { if (!plan) return; setBusy(true); try { + /* + **每个客户端用它自己那把密钥,不再共用第一把。** + + 在此之前这里不传 `key_name`,于是 core 一路落到 + `cfg.clients.first()` —— 五个被接管的客户端拿到同一把密钥。 + 后果是连锁的:`RequestFacts.client` 永远是同一个值,所以按密钥 + 绑路由匹不到、每客户端并发上限形同虚设、流量和会话里也分不出 + 是谁发的。**整条「按客户端」的能力链因为末端少传一个参数而 + 全线失效。** + + 没有同名密钥就先建一把。建和用是两次写入,中间那一刻只是「多了 + 一把还没人用的密钥」—— 一个无害的状态,而反过来(先接管再建) + 那一刻客户端配的是一把不存在的密钥。 + */ + const keyName = plan.c.id; + if (!plan.restore && !clientKeys.includes(keyName)) { + const key = await invoke("new_key"); + await invoke("patch_config", { + ops: [{ op: "append", path: "/clients", item: `name: ${keyName}\nkey: ${key}` }], + baseVersion: configVersion, + }); + } const r = await invoke(plan.restore ? "restore_client" : "adopt_client", { client: plan.c.id, + keyName: plan.restore ? null : keyName, }); setPlan(null); setDone(r); diff --git a/src/Config.tsx b/src/Config.tsx index a7321a48..1d342f16 100644 --- a/src/Config.tsx +++ b/src/Config.tsx @@ -1,6 +1,7 @@ -import { useRef, useState } from "react"; +import { Fragment, useRef, useState } from "react"; import { Tip } from "./ui/Tooltip"; import AddUpstream from "./AddUpstream"; +import Proxies from "./Proxies"; import { invoke } from "@tauri-apps/api/core"; import { useEffect } from "react"; import ConfigTextMode from "./ConfigText"; @@ -8,7 +9,14 @@ import Pricing from "./Pricing"; import SpeedTest from "./SpeedTest"; import { triggers } from "./triggers"; import DryRun from "./DryRun"; -import type { ConfigText, ConfigVersion, L1Result, Overview, PatchOp } from "./types"; +import type { + ConfigText, + ConfigVersion, + L1Result, + NicView, + Overview, + PatchOp, +} from "./types"; /** * 一个能改的字段。 @@ -219,6 +227,428 @@ function SpeedRows({ r }: { r: L1Result }) { * 转移」「分组」这些词 —— 那些概念对他确实不存在。但**模型路由一直在**, * 因为一个上游就有几十个模型,那个问题从第一天就存在。 */ +/** + * 监听方式 —— 网关绑在哪张网卡上。 + * + * 三个选择对应三件真实不同的事: + * + * · **仅本机** `loopback` —— 绑 127.0.0.1。别的设备连不过来。 + * · **指定网卡** `` —— 绑某一张网卡自己的地址。只有那张网卡所在的 + * 网络连得上。 + * · **全部网卡** `all` —— 绑 0.0.0.0。**每一张**网卡,包括对着公网的那张。 + * + * 以前中间那档叫「局域网」,而它绑的也是 0.0.0.0 —— 和「全部网卡」是同 + * 一个地址,区别只在来源白名单的默认值。**那是个白名单概念,伪装成了网卡 + * 选择**:用户以为网关只在局域网那张网卡上听,实际它在所有网卡上听。 + * + * 改完**立刻生效,不用重启**。core 的 `serve_following_config` 在 + * `relisten` 上等通知:地址变了就优雅停掉旧监听器(不再接新连接,在跑 + * 的请求自己跑完)再绑新的 —— nginx reload 的语义。所以这里**不要**加 + * 「重启网关」的按钮:那句提示会让用户以为还没生效,而它早就生效了。 + */ +type BindKind = "loopback" | "nic" | "all"; + +function kindOf(bind: string): BindKind { + if (bind === "loopback") return "loopback"; + if (bind === "all") return "all"; + return "nic"; +} + +const KINDS: { id: BindKind; label: string; what: string }[] = [ + { + id: "loopback", + label: "仅本机", + what: "绑 127.0.0.1。只有这台电脑上的程序连得上,别的设备连不过来。", + }, + { + id: "nic", + label: "指定网卡", + what: "只绑这一张网卡自己的地址,只有它所在的那个网络连得上。密钥校验强制开启。", + }, + { + id: "all", + label: "全部网卡", + what: "绑 0.0.0.0,每一张网卡都在听 —— 包括对着公网的那张。密钥校验强制开启。", + }, +]; + +/** + * 来源白名单(CIDR)。 + * + * **每一条单独增删,不是一个逗号分隔的输入框。**一个框装一串 CIDR 的话, + * 改错任何一处的后果都是整份白名单失效 —— 而白名单失效的表现是「全放行」, + * 不是「全拦住」。错在安全的那一侧比错在另一侧更该避免。 + */ +function CidrList({ + items, + configVersion, + onErr, +}: { + items: string[]; + configVersion: string | null; + onErr: (e: string | null) => void; +}) { + const [adding, setAdding] = useState(""); + const [busy, setBusy] = useState(false); + + async function run(ops: PatchOp[]) { + if (!configVersion) { + onErr("还没读到配置版本,稍等一下再试"); + return; + } + setBusy(true); + onErr(null); + try { + await invoke("patch_config", { ops, baseVersion: configVersion }); + setAdding(""); + } catch (e) { + onErr(typeof e === "string" ? e : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+ {items.length === 0 && (全放行)} + {items.map((c, i) => ( + + {c} + + + ))} + setAdding(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && adding.trim()) { + void run([ + { op: "append", path: "/listen/gateway/allow_from", item: adding.trim() }, + ]); + } + }} + className="w-44 rounded border border-neutral-300 bg-transparent px-1.5 font-mono tw-label outline-none focus:border-neutral-500 dark:border-neutral-700" + /> +
+ ); +} + +const PROBE_MODES: { id: string; label: string; what: string }[] = [ + { id: "intercept", label: "本地应答", what: "一个字节都不发给上游,不花钱。" }, + { id: "passthrough", label: "原样放行", what: "当成普通请求发出去,按量计费。" }, + { id: "route", label: "交给路由", what: "走路由规则,可以分流到更便宜的地方。" }, +]; + +/** + * 客户端自己发的辅助请求。 + * + * **这一段以前在界面上完全不存在,而它的缺席是连锁的**:路由条件 + * `when.intent` 只有在对应那一类被配成「交给路由」时才可能命中 —— + * 所以任何写了 `intent` 的规则都是死的,而用户无从知道为什么。 + */ +function ProbesSection({ + ov, + configVersion, +}: { + ov: Overview; + configVersion: string | null; +}) { + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(null); + const probes = ov.client_probes ?? []; + if (probes.length === 0) return null; + + async function set(id: string, mode: string) { + if (!configVersion) { + setErr("还没读到配置版本,稍等一下再试"); + return; + } + setBusy(id); + setErr(null); + try { + await invoke("patch_config", { + ops: [{ op: "replace", path: `/client_probes/${id}`, value: mode }], + baseVersion: configVersion, + }); + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + } finally { + setBusy(null); + } + } + + return ( +
+

客户端的辅助请求

+

+ 客户端自己发的、你没点过的那些请求。它们也花钱。 +

+
    + {probes.map((p) => ( +
  • +
    + {p.label} +
    + {PROBE_MODES.map((m) => ( + + ))} +
    +
    +

    {p.what}

    +

    + {PROBE_MODES.find((m) => m.id === p.mode)?.what} +

    +
  • + ))} +
+

+ 路由规则里的「辅助请求」条件,只有在这一类选了「交给路由」时才可能命中。 +

+ {err &&

{err}

} +
+ ); +} + +/** 并发上限。以前这一整段也没有界面。 */ +function LimitsSection({ + ov, + configVersion, +}: { + ov: Overview; + configVersion: string | null; +}) { + const [err, setErr] = useState(null); + const l = ov.limits; + if (!l) return null; + const rows: [string, keyof typeof l, string][] = [ + ["全局并发", "max_concurrent", "同时在飞的请求上限。超了先排队。"], + ["单个上游", "per_provider", "一家上游同时最多几个。防止一家慢拖垮全部。"], + ["队列上限", "queue_depth", "排队排到这么多就真的拒绝了。"], + ["排队超时", "queue_timeout_secs", "排这么多秒还没轮到就放弃(秒)。"], + ]; + return ( +
+

并发

+
+ {rows.map(([label, key, what]) => ( + +
{label}
+
+ +
+
{what}
+
+ ))} +
+ {err &&

{err}

} +
+ ); +} + +function ListenSection({ + ov, + configVersion, +}: { + ov: Overview; + configVersion: string | null; +}) { + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [nics, setNics] = useState(null); + const cur = ov.listen.bind; + const kind = kindOf(cur); + + // 网卡清单每次打开这一页现拉 —— 它会变(插拔网线、换 Wi-Fi、起 VPN) + useEffect(() => { + let alive = true; + void (async () => { + try { + const list = await invoke("interfaces"); + if (alive) setNics(list.filter((n) => !n.loopback)); + } catch { + // 拉不到就只是选单是空的,前后两档照常能选 + } + })(); + return () => { + alive = false; + }; + }, [configVersion]); + + async function write(value: string) { + if (value === cur || busy) return; + if (!configVersion) { + setErr("还没读到配置版本,稍等一下再试"); + return; + } + setBusy(true); + setErr(null); + try { + await invoke("patch_config", { + ops: [{ op: "replace", path: "/listen/gateway/bind", value }], + baseVersion: configVersion, + }); + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + } finally { + setBusy(false); + } + } + + function pickKind(k: BindKind) { + if (k === "loopback") return void write("loopback"); + if (k === "all") return void write("all"); + // 选「指定网卡」时先落到第一张,用户再从选单里换 + const first = nics?.[0]; + if (!first) { + setErr("没找到可以绑的网卡。插着网线或连着 Wi-Fi 吗?"); + return; + } + void write(first.addr); + } + + const picked = KINDS.find((k) => k.id === kind); + + return ( +
+
+

监听与访问

+
+ {KINDS.map((k) => ( + + ))} +
+
+ +

+ {picked?.what} +

+ + {kind === "nic" && ( +
+ + + + 地址会变 + + +
+ )} + +
+
正在监听
+
+ {ov.listen.bind} : + setErr(e)} + /> +
+
客户端密钥
+
+ {ov.clients.map((c) => `${c.name} ${c.key}`).join(",")} +
+ {ov.listen.exposed && ( + <> +
来源白名单
+
+ +
+ + )} +
+ + {ov.listen.exposed && ( +

+ 监听在非本机地址上,局域网里的机器能连过来 + + + 密钥强制校验 + + +

+ )} + + {err && ( +

{err}

+ )} + + {/* + 白名单还只能读不能改:`PatchOp::Replace` 只吃标量,而 `allow_from` + 是一个列表。要在界面上编辑它,得先给补丁协议加一个列表操作 —— + 那是另一件事,不该在这里塞一个只能改第一项的半吊子输入框。 + */} +
+ ); +} + export default function Config({ section = "gateway", ov, @@ -237,7 +667,15 @@ export default function Config({ * * 文本模式两个面共用 —— 它编辑的是整份文件,本来就不分域。 */ - section?: "gateway" | "routing" | "settings"; + /** + * 这一次渲染哪一域。 + * + * `routing` 这一域现在只剩「试算」和「策略组」—— 路由本身搬去了 + * `Routes.tsx`。**没跟着搬的原因是它俩和 `SelectCell`、配置版本、 + * 以及跳去文本模式那条路缠在一起**,硬拆会弄坏正在工作的东西。 + * 路由页把两个组件叠起来渲染,对用户是一页。 + */ + section?: "gateway" | "upstreams" | "routing" | "settings"; ov: Overview; configVersion: string | null; /** 最近一次校验失败指到的行号。文本模式会把它滚进视野 */ @@ -370,7 +808,7 @@ export default function Config({ return (
- {section === "gateway" && ( + {section === "upstreams" && (

上游

@@ -526,7 +964,9 @@ export default function Config({ options={[ ["direct", "直连"], ["system", "跟随系统"], - ...(ov.proxies ?? []).map((x) => [x, x] as [string, string]), + ...(ov.proxies ?? []).map( + (x) => [x.name, `${x.name}(${x.kind} ${x.addr})`] as [string, string], + ), ]} path={`/providers/${p.name}/proxy`} version={cfg?.version ?? null} @@ -625,40 +1065,6 @@ export default function Config({ 看出「那个不花钱、这个花钱」 */} - {section === "routing" && ( -
-

路由规则

-

- 从上往下匹配,第一条命中的说了算。 -

-
    - {ov.routes.map((r, i) => ( -
  1. - {i + 1} - {r.name} - - {r.conditions.length === 0 ? ( - // 兜底规则要标出来。少了它,用户会以为「没有兜底」 - // 而反复调试一条其实一直在生效的规则。 - - 兜底 - - ) : ( - r.conditions.join(" 且 ") - )} - - → {r.to} -
  2. - ))} -
-
- - )} - - {/* 「为什么没走我以为的那条」和「走了哪条」是同一个问题的两面 */} {section === "routing" && } {/* 分组这个概念只在真的有组的时候出现 */} @@ -854,34 +1260,20 @@ export default function Config({
)} + {section === "upstreams" && ( + + )} + {section === "gateway" && ( -
-

监听与访问

-
-
地址
-
- {ov.listen.bind} : {ov.listen.port} -
-
客户端密钥
-
- {ov.clients.map((c) => `${c.name} ${c.key}`).join(",")} -
- {ov.listen.exposed && ( - <> -
来源白名单
-
{ov.listen.allow_from.join(",") || "(全放行)"}
- - )} -
- {ov.listen.exposed && ( -

- 监听在非本机地址上,局域网里的机器能连过来 - - 密钥强制校验 - -

- )} -
+ + )} + + {section === "gateway" && ( + + )} + + {section === "gateway" && ( + )} {/* diff --git a/src/Keys.tsx b/src/Keys.tsx new file mode 100644 index 00000000..12fadd7e --- /dev/null +++ b/src/Keys.tsx @@ -0,0 +1,391 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Tip } from "./ui/Tooltip"; +import { Dialog } from "./ui/Dialog"; +import type { Overview, PatchOp } from "./types"; + +/** + * 网关密钥。 + * + * **这是访问控制的唯一入口。**端口决定谁能敲门,密钥决定谁能进来 —— + * 没有密钥,即使从 127.0.0.1 也连不上(core 里是硬行为,不分内外网)。 + * + * 在此之前这一整页不存在:密钥是「监听与访问」里的一行只读文本, + * 建不了、改不了、删不了、也绑不了路由。而下面每一件事都依赖它: + * + * · **区分客户端。**所有客户端共用一把密钥时,`RequestFacts.client` + * 永远是同一个值 —— 于是流量和会话里分不出是谁发的,每客户端并发 + * 上限形同虚设,按密钥绑路由也无从谈起。 + * · **绑路由。**一把密钥绑一条路由;没绑就走默认路由。 + * · **吊销。**某个客户端的配置泄露了,要能只换它那一把,而不是全换。 + */ +/** + * 这把密钥能看到哪些模型。 + * + * 三态,而且**第三态是「一个都不给」** —— 一个写成 `[]` 的空列表。 + * 那不是坏状态,是「临时停掉这个客户端」的正当用法,所以界面上要能 + * 明确选到它,而不是只能通过「删掉最后一条」意外抵达。 + * + * 三态之间**每个方向都要能走回去**。第一版只有「全部 → 限制」这一 + * 扇单向门:进了限制态就再也回不到全部,因为协议里当时没有「把这个 + * 键抹掉」的说法。回去的那条路是写 `null` —— 和路由解绑同一个做法。 + */ +function AllowCell({ + client, + allow, + busy, + onPatch, +}: { + client: string; + allow: string[] | null; + busy: boolean; + onPatch: (ops: PatchOp[]) => void; +}) { + const [adding, setAdding] = useState(""); + if (allow === null) { + return ( +
+ 全部 + +
+ ); + } + return ( +
+ {allow.length === 0 && ( + + 一个都不给 + + )} + + {allow.map((m, i) => ( + + {m} + + + ))} + setAdding(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && adding.trim()) { + onPatch([ + { op: "append", path: `/clients/${client}/allow`, item: adding.trim() }, + ]); + setAdding(""); + } + }} + className="w-24 rounded border border-neutral-300 bg-transparent px-1 font-mono tw-label outline-none focus:border-neutral-500 dark:border-neutral-700" + /> +
+ ); +} + +export default function Keys({ + ov, + configVersion, + onChanged, +}: { + ov: Overview; + configVersion: string | null; + onChanged: () => void; +}) { + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(null); + const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(null); + + const routes = ov.routes ?? []; + const defaultRoute = ov.default_route ?? "默认"; + + async function patch(ops: PatchOp[], tag: string) { + if (!configVersion) { + setErr("还没读到配置版本,稍等一下再试"); + return false; + } + setBusy(tag); + setErr(null); + try { + await invoke("patch_config", { ops, baseVersion: configVersion }); + onChanged(); + return true; + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + return false; + } finally { + setBusy(null); + } + } + + async function create() { + const name = newName.trim(); + if (!name) return; + if (ov.clients.some((c) => c.name === name)) { + setErr(`已经有一把叫「${name}」的密钥了`); + return; + } + setBusy("new"); + try { + // **密钥由 core 生成。**字母表(去掉了 0/O、1/I/l)和长度是安全 + // 决定,在界面里再写一份的话,迟早只有一处被改。 + const key = await invoke("new_key"); + // 名字和密钥**一次写入**。分两次的话,中间那一刻配置里有一把 + // 没有密钥的 client —— 那是个加载不了的状态。 + const ok = await patch( + [{ op: "append", path: "/clients", item: `name: ${name}\nkey: ${key}` }], + "new", + ); + if (ok) { + setAdding(false); + setNewName(""); + } + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + setBusy(null); + } + } + + async function regenerate(name: string) { + setBusy(name); + try { + const key = await invoke("new_key"); + await patch([{ op: "replace", path: `/clients/${name}/key`, value: key }], name); + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + setBusy(null); + } + } + + return ( +
+
+
+

网关密钥

+

+ 没有密钥连不上,本机也一样。 +

+ +
+ + {adding && ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void create(); + if (e.key === "Escape") setAdding(false); + }} + className="flex-1 rounded border border-neutral-300 bg-transparent px-2 py-1 tw-body outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + + +
+ )} + + + + + + + + + + + + + {ov.clients.map((c) => ( + + + + + + + + + ))} + +
名字密钥路由并发上限可见模型 +
{c.name}{c.key} + {/* + 不绑就是走默认路由 —— 选项里把它写出来,而不是留一个 + 空白。**空白读起来是「还没配」,而它其实一直在生效。** + */} + + + { + const raw = e.target.value.trim(); + const v = raw === "" ? null : Number(raw); + if (v !== null && (!Number.isFinite(v) || v < 1)) { + setErr("并发上限要是一个 1 以上的整数,或者留空表示不限"); + return; + } + if ((c.max_concurrent ?? null) === v) return; + void patch( + [ + { + op: "replace", + path: `/clients/${c.name}/max_concurrent`, + value: v, + }, + ], + c.name, + ); + }} + className="w-16 rounded border border-transparent bg-transparent px-1 py-0.5 font-mono tw-body hover:border-neutral-300 focus:border-neutral-500 focus:outline-none dark:hover:border-neutral-700" + /> + + {/* + **三态,而且第三态是「一个都不给」。**留空 = 只按方言 + 过滤;写了 glob = 再按它保留;写一个空列表 = 这把密钥 + 看不到任何模型,也就用不了 —— 那是「临时停掉这个客户端」 + 的正当用法,而不是一个坏状态。 + */} + void patch(ops, c.name)} + /> + + + + + {/* + **最后一把不给删。**删光之后谁也连不上,而且配置会 + 加载失败(`validate` 要求至少一把)—— 那时网关起不来, + 用户看到的是「core 连续失败」,而原因在三步之前。 + */} + + + +
+ + {err &&

{err}

} +
+ + !o && setConfirmDelete(null)} + danger + title={`删掉密钥「${confirmDelete}」?`} + description="用着它的客户端会立刻连不上,要重新配一把。配置有版本历史,删错了能回滚。" + footer={ + <> + + + + } + /> +
+ ); +} diff --git a/src/Proxies.tsx b/src/Proxies.tsx new file mode 100644 index 00000000..d36b5a29 --- /dev/null +++ b/src/Proxies.tsx @@ -0,0 +1,238 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Tip } from "./ui/Tooltip"; +import type { Overview, PatchOp } from "./types"; + +const KINDS: { id: string; label: string; what: string }[] = [ + { + id: "socks5h", + label: "SOCKS5h", + what: "域名原样发给代理,由代理端解析 DNS。本地 DNS 不可信时这是唯一能用的一档。", + }, + { + id: "socks5", + label: "SOCKS5", + what: "本地解析 DNS,把 IP 发给代理。", + }, + { id: "http", label: "HTTP", what: "" }, + { id: "https", label: "HTTPS", what: "" }, +]; + +/** + * 出站代理。 + * + * **在此之前这一段完全没有入口。**上游表格里有一列「代理」,下拉的候选 + * 来自 `ov.proxies` —— 而那只是一个名字数组,代理本身没有任何界面能增删 + * 改。没手写过 `config.yaml` 的人,看到的是一个只有「直连」和「跟随系统」 + * 的下拉:**一个只能引用、无法创建的引用。** + * + * 密码这一栏是单向的:服务端只给「有没有认证」,不给内容 —— 这个视图会 + * 进日志、进诊断包、进用户贴出来的截图。所以界面上能设、能清,但设完就 + * 看不见了,和上游的 key 是同一条纪律。 + */ +export default function Proxies({ + ov, + configVersion, + onChanged, +}: { + ov: Overview; + configVersion: string | null; + onChanged: () => void; +}) { + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(null); + const [adding, setAdding] = useState(false); + const [f, setF] = useState({ name: "", kind: "socks5h", addr: "", user: "", pass: "" }); + + const proxies = ov.proxies ?? []; + + async function patch(ops: PatchOp[], tag: string) { + if (!configVersion) { + setErr("还没读到配置版本,稍等一下再试"); + return false; + } + setBusy(tag); + setErr(null); + try { + await invoke("patch_config", { ops, baseVersion: configVersion }); + onChanged(); + return true; + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + return false; + } finally { + setBusy(null); + } + } + + return ( +
+
+

代理

+

+ 声明一次,每个上游各自选用。 +

+ +
+ + {adding && ( +
+
+ setF({ ...f, name: e.target.value })} + className="w-48 rounded border border-neutral-300 bg-transparent px-2 py-1 outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + + setF({ ...f, addr: e.target.value })} + className="w-44 rounded border border-neutral-300 bg-transparent px-2 py-1 font-mono outline-none focus:border-neutral-500 dark:border-neutral-700" + /> +
+ {KINDS.find((k) => k.id === f.kind)?.what && ( +

+ {KINDS.find((k) => k.id === f.kind)?.what} +

+ )} +
+ 认证(可不填) + setF({ ...f, user: e.target.value })} + className="w-32 rounded border border-neutral-300 bg-transparent px-2 py-1 outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + setF({ ...f, pass: e.target.value })} + className="w-32 rounded border border-neutral-300 bg-transparent px-2 py-1 outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + + + 写进配置文件 + + +
+
+ + +
+
+ )} + + {proxies.length === 0 && !adding ? ( +

+ 还没有代理。上游默认直连;要走代理先在这里建一个。 +

+ ) : ( + + + + + + + + + + + + {proxies.map((p) => ( + + + + + + + + + ))} + +
名字类型地址认证在用 +
{p.name}{p.kind}{p.addr}{p.has_auth ? "有" : "—"} + {p.used_by > 0 ? `${p.used_by} 家上游` : "没人用"} + + {/* + **还有上游在用就不给删。**删了之后那几家的 `proxy` + 指向一个不存在的名字,配置整份加载失败 —— 网关起不来, + 而用户看到的是「core 连续失败」,原因在三步之前。 + */} + 0 + ? `还有 ${p.used_by} 家上游在用它。先把它们改成别的,再删。` + : "删掉它。" + } + > + + +
+ )} + + {err &&

{err}

} +
+ ); +} diff --git a/src/Routes.tsx b/src/Routes.tsx new file mode 100644 index 00000000..d771f9a0 --- /dev/null +++ b/src/Routes.tsx @@ -0,0 +1,436 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Tip } from "./ui/Tooltip"; +import { Dialog } from "./ui/Dialog"; +import type { Overview, PatchOp, RouteView } from "./types"; + +/** + * 路由。 + * + * **一条路由是一组按顺序求值的规则,一把密钥绑一条。**默认路由是 + * 「没绑定时走的那条」,不是「所有人都要过的那条」—— 这个区别是整个 + * 模型的支点:前者意味着求值永远只看一张规则表。 + * + * 在此之前这一页只能看不能建:`to` 能通过策略组的下拉改,而 `when` 的 + * 十三个条件、`set`、`deny`、`guard` 全部只读,新建一条规则更无从谈起。 + * **一个只能查看的路由页,等于没有路由这个功能。** + */ +export default function Routes({ + ov, + configVersion, + onChanged, +}: { + ov: Overview; + configVersion: string | null; + onChanged: () => void; +}) { + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(null); + const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(null); + const [addRuleTo, setAddRuleTo] = useState(null); + + const routes = ov.routes ?? []; + const defaultRoute = ov.default_route ?? "默认"; + const targets = [ + ...ov.groups.map((g) => [g.name, `${g.name}(组)`] as const), + ...ov.providers.map((p) => [p.name, p.name] as const), + ]; + + async function patch(ops: PatchOp[], tag: string) { + if (!configVersion) { + setErr("还没读到配置版本,稍等一下再试"); + return false; + } + setBusy(tag); + setErr(null); + try { + await invoke("patch_config", { ops, baseVersion: configVersion }); + onChanged(); + return true; + } catch (e) { + setErr(typeof e === "string" ? e : String(e)); + return false; + } finally { + setBusy(null); + } + } + + return ( +
+ {/* + 默认路由单独一块,因为它在模型里就是单独的一个字段 + (顶层的 `default_route`)—— 不是某条路由身上的标志。 + */} +
+
+

默认路由

+ +

+ 没绑路由的密钥走这条。不是所有人都要过的那条。 +

+
+
+ +
+
+

路由

+

+ 一条路由里,从上往下匹配,第一条命中的决定去向。 +

+ +
+ + {adding && ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setAdding(false); + }} + className="flex-1 rounded border border-neutral-300 bg-transparent px-2 py-1 tw-body outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + + +
+ )} + +
+ {routes.map((r) => ( +
+
+ {r.name} + {r.default && ( + + 默认 + + )} + + {/* + 绑了这条的密钥。默认路由这里通常是空的 —— 走它的人是 + 「没绑」,不是「绑了它」。把这件事说出来,否则空白读起来 + 像是「没人用」。 + */} + {r.clients.length > 0 + ? `${r.clients.join("、")} 绑了它` + : r.default + ? "没绑路由的密钥走它" + : "还没有密钥绑它"} + + + 0 + ? `还有 ${r.clients.length} 把密钥绑着它,删了它们会退回默认路由。` + : "删掉这条路由。" + } + > + + +
+ + {addRuleTo === r.name && ( + setAddRuleTo(null)} + onCreate={async (item) => { + const ok = await patch( + [{ op: "append", path: `/routes/${r.name}/rules`, item }], + `rule-${r.name}`, + ); + if (ok) setAddRuleTo(null); + }} + /> + )} + +
    + {r.rules.map((rule, i) => ( +
  1. + {i + 1} + {rule.name} + + {rule.conditions.length === 0 ? ( + // 兜底规则要标出来。少了它,用户会以为「没有兜底」 + // 而反复调试一条其实一直在生效的规则。 + + 兜底 + + ) : ( + rule.conditions.join(" 且 ") + )} + + + → {rule.to} + + +
  2. + ))} + {r.rules.length === 0 && ( +
  3. + 这条路由一条规则都没有。绑上它的密钥会匹配不到任何规则,请求全部失败。 +
  4. + )} +
+
+ ))} +
+ + {err &&

{err}

} +
+ + !o && setConfirmDelete(null)} + danger + title={`删掉路由「${confirmDelete?.name}」?`} + description={ + confirmDelete && confirmDelete.clients.length > 0 + ? `${confirmDelete.clients.join("、")} 绑着它,删掉之后它们会退回默认路由。` + : "这条路由没有密钥绑着,删掉不影响任何请求。" + } + footer={ + <> + + + + } + /> +
+ ); +} + +/** + * 加一条规则。 + * + * **只放最常用的三个条件**(模型、是否带工具、输入长度)。`when` 一共 + * 十三个维度,全铺开是一张没人读得完的表 —— 而剩下那些在文本模式里写 + * 一行就完事。这里要解决的是「新建一条规则」这件事本身不可能,不是 + * 「每个条件都得有个控件」。 + */ +function NewRule({ + route, + targets, + busy, + onCancel, + onCreate, +}: { + route: string; + targets: readonly (readonly [string, string])[]; + busy: boolean; + onCancel: () => void; + onCreate: (item: string) => void; +}) { + const [name, setName] = useState(""); + const [model, setModel] = useState(""); + const [tools, setTools] = useState(false); + const [tokens, setTokens] = useState(""); + const [to, setTo] = useState(targets[0]?.[0] ?? ""); + + function build(): string { + const when: string[] = []; + if (model.trim()) when.push(`model: "${model.trim()}"`); + if (tools) when.push("tools: true"); + if (tokens.trim()) when.push(`input_tokens: "${tokens.trim()}"`); + const lines = [`name: ${name.trim()}`]; + if (when.length > 0) lines.push(`when: { ${when.join(", ")} }`); + lines.push(`to: ${to}`); + return lines.join("\n"); + } + + return ( +
+
+ setName(e.target.value)} + className="min-w-52 flex-1 rounded border border-neutral-300 bg-transparent px-2 py-1 outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + 去向 + +
+ +
+ 当 + setModel(e.target.value)} + className="w-52 rounded border border-neutral-300 bg-transparent px-2 py-1 font-mono outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + setTokens(e.target.value)} + className="w-40 rounded border border-neutral-300 bg-transparent px-2 py-1 font-mono outline-none focus:border-neutral-500 dark:border-neutral-700" + /> + + + + 都留空 = 兜底 + + +
+ +
+ + {route} / {build().replace(/\n/g, " ")} + + + +
+
+ ); +} diff --git a/src/index.css b/src/index.css index eee46150..04e6c0d7 100644 --- a/src/index.css +++ b/src/index.css @@ -5,6 +5,56 @@ :root { color-scheme: light dark; } + +/* --------------------------------------------------------------- 窗口色 + * + * **源列表和窗口底色单独一套 token,不走 Tailwind 的 neutral。** + * + * neutral 是纯灰。一整条纯灰的侧栏贴在 macOS 上就是「一块网页的 + * 侧边栏」——系统自己的侧栏永远带一点冷调,因为它是半透的、后面压着 + * 桌面。这里的中性色统一往蓝偏一点点,是选出来的,不是继承来的。 + * + * 只覆盖窗口外壳(源列表、窗口底、分隔线)。内容区继续用 Tailwind 的 + * 色阶——把整站色板一次换掉是另一件事,不该混在这次里。 + */ +:root { + --chrome-ground: #f7f8fa; /* 窗口底 */ + --chrome-rail: #edeff3; /* 源列表 */ + --chrome-hair: #dce0e6; /* 分隔线 */ + --chrome-strong: #1b1f27; /* 选中项的字 */ + --chrome-text: #333a45; /* 普通项的字 */ + --chrome-dim: #737b88; /* 页脚、次要信息 */ + --chrome-hover: rgb(0 0 0 / 4.5%); +} +@media (prefers-color-scheme: dark) { + :root { + --chrome-ground: #0c0e11; + --chrome-rail: #1b1e24; + --chrome-hair: #272c34; + --chrome-strong: #ffffff; + --chrome-text: #d8dce3; + --chrome-dim: #8a919d; + --chrome-hover: rgb(255 255 255 / 5.5%); + } +} + +/* 选中项。 + * + * **跟系统强调色走,不写死蓝色。**用户在系统设置里把强调色换成粉色, + * 侧栏应该跟着变;写死的那一刻它又变回一个网页。`AccentColor` 是 + * CSS Color 4 的系统色,WKWebView 认得;不认的浏览器落到前一行的蓝, + * 那也是 macOS 的默认强调色。 + */ +@utility tw-selected { + background: rgb(10 132 255 / 22%); + background: color-mix(in srgb, AccentColor 22%, transparent); + color: var(--chrome-strong); +} +@utility tw-selected-mark { + color: #0a84ff; + color: AccentColor; +} + body { margin: 0; font: 13px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; diff --git a/src/types.ts b/src/types.ts index 228628f0..17f31e62 100644 --- a/src/types.ts +++ b/src/types.ts @@ -450,6 +450,49 @@ export interface SpeedResult { error: string | null; } +/** 一个出站代理。**密码不在这里** —— 服务端只给「有没有认证」 */ +export interface ProxyView { + name: string; + /** `socks5` / `socks5h` / `http` / `https` */ + kind: string; + addr: string; + has_auth: boolean; + /** 有几家上游在用它。删之前要知道 */ + used_by: number; +} + +/** 一类客户端辅助请求的处置 */ +export interface ProbeView { + id: string; + label: string; + what: string; + /** `intercept` / `route` / `passthrough` */ + mode: string; +} + +export interface LimitsView { + max_concurrent: number; + per_provider: number; + queue_depth: number; + queue_timeout_secs: number; +} + +/** 一条规则 */ +export interface RuleView { + name: string; + to: string; + /** `when` 的人话摘要。空 = 兜底 */ + conditions: string[]; +} + +/** 这台机器上的一张网卡(`GET /interfaces`)。同一张网卡可以有多个地址 */ +export interface NicView { + /** `en0`、`lo0`、`utun3` */ + name: string; + addr: string; + loopback: boolean; +} + // —— 配置(双向同步) —— export interface ConfigText { path: string; @@ -460,12 +503,25 @@ export interface ConfigText { export type PatchValue = string | number | boolean | null; -export interface PatchOp { - op: "replace"; - /** 按名字定位:`/providers/官方/base_url`。下标会在重排之后指向另一个东西 */ - path: string; - value: PatchValue; -} +/** + * 一次配置改动。 + * + * 路径一律**按名字**定位:`/providers/官方/base_url`、`/clients/codex`。 + * 下标会在用户重排之后指向另一个东西,而那种错误完全静默。 + */ +export type PatchOp = + | { op: "replace"; path: string; value: PatchValue } + /** 往块式列表末尾加一项。`item` 是那一项的 YAML 片段,不带前导的 `- ` */ + | { op: "append"; path: string; item: string } + /** 删掉列表里的一项。`path` 指向**那一项** */ + | { op: "remove"; path: string } + /** + * 把一个列表清成空的(`[]`)。 + * + * **和「把这个键删掉」不是一回事**:`allow` 不写 = 跟客户端方言走, + * `allow: []` = 一个都不给。界面上那是两个不同的选项。 + */ + | { op: "clear"; path: string }; export interface ConfigVersion { version: string; @@ -510,11 +566,14 @@ export interface ProviderView { redact_explicit?: boolean; } +/** 一条路由 —— 一组规则,加上绑了它的密钥 */ export interface RouteView { name: string; - to: string; - /** 空 = 兜底 */ - conditions: string[]; + /** 没绑路由的密钥走的就是这条 */ + default: boolean; + /** **显式绑了这条的密钥。**默认路由这里通常是空的 —— 走它的人是「没绑」 */ + clients: string[]; + rules: RuleView[]; } export interface GroupView { @@ -533,6 +592,10 @@ export interface ClientView { name: string; key: string; max_concurrent: number | null; + /** 绑的那条路由。`null` = 走默认路由 */ + route?: string | null; + /** 能看到哪些模型。三态:不写 / 写非空 / 写 `[]`(一个都不给) */ + allow?: string[] | null; } export interface ListenView { @@ -595,14 +658,19 @@ export interface ConfigAt { export interface Overview { providers: ProviderView[]; - /** 配置里定义过的代理名 —— 换代理要从这里选,手打会打错 */ - proxies?: string[]; + /** 配置里定义过的代理 —— 换代理要从这里选,手打会打错 */ + proxies?: ProxyView[]; routes: RouteView[]; groups: GroupView[]; clients: ClientView[]; listen: ListenView; /** 三条防线各自的状态。界面要能配,不只是显示 */ security?: SecurityView; + /** 没绑路由的密钥走哪条 */ + default_route?: string; + /** 客户端自己发的辅助请求怎么处理 */ + client_probes?: ProbeView[]; + limits?: LimitsView; } /** diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx new file mode 100644 index 00000000..68108a57 --- /dev/null +++ b/src/ui/icons.tsx @@ -0,0 +1,151 @@ +/** + * 源列表的图标。 + * + * **手写,不引图标库。**九个 16px 的描边图形大约一百行,而 + * `lucide-react` 是一个依赖、一整套用不到的图形、以及一份需要跟着升级 + * 的东西。这个代码库已经有手写 SVG 图表和手写 PNG 编码器的先例,理由 + * 是同一个。 + * + * 画法对齐 SF Symbols 的观感:16×16 画布、1.5 描边、圆头圆角、留 2px + * 边距。**不要改成填充式** —— 描边和填充混在一列里,眼睛会把填充的那 + * 个读成「选中」。 + * + * 最要紧的一条:**这九个图形必须两两分得开**。路由 / 网关 / 客户端是 + * 三个容易混的概念,收起来只剩图标时,认错的代价是点错页。所以它们用 + * 的是三种不同的骨架(分叉的箭头、上下两层的机架、一台笔电),而不是 + * 三个方块加不同的小装饰。 + */ + +type P = { className?: string; size?: number }; + +function S({ size = 16, className, children }: P & { children: React.ReactNode }) { + return ( + + ); +} + +/** 概览 —— 仪表盘指针 */ +export const IconDashboard = (p: P) => ( + + + + +); + +/** 流量 —— 一列长短不一的记录 */ +export const IconFlow = (p: P) => ( + + + + + +); + +/** 会话 —— 两个叠起来的气泡 */ +export const IconSession = (p: P) => ( + + + + +); + +/** 发现 —— 放大镜。看证据,不是配策略 */ +export const IconFindings = (p: P) => ( + + + + +); + +/** 防护 —— 盾牌带勾。配策略,不是看证据 */ +export const IconGuard = (p: P) => ( + + + + +); + +/** 路由 —— 一条进来,分叉出去 */ +export const IconRoute = (p: P) => ( + + + + + + +); + +/** 网关 —— 上下两层的机架 */ +export const IconGateway = (p: P) => ( + + + + + + +); + +/** 客户端 —— 一台笔电 */ +export const IconClient = (p: P) => ( + + + + +); + +/** 上游 —— 一摞机器。和「网关」的两层机架区分开:这是**很多家**,那是**一台** */ +export const IconServer = (p: P) => ( + + + + + +); + +/** 密钥 —— 一把钥匙。**不用锁**:锁在这一列里会被读成「安全」,而安全是另一组 */ +export const IconKey = (p: P) => ( + + + + + +); + +/** 设置 —— 两条推子。齿轮留给系统设置,别撞 */ +export const IconSettings = (p: P) => ( + + + + + + + + +); + +/** + * 收起 / 展开源列表。 + * + * 用的是系统那个图形:一个矩形,左边一栏填实。**填的那一栏就是源列表 + * 本身**,所以它同时说明了「这个按钮管的是左边那条」和「它现在是开 + * 的」—— 换成箭头就只剩方向,说不出管的是什么。 + */ +export const IconSidebar = (p: P) => ( + + + + +); diff --git a/src/useRequests.ts b/src/useRequests.ts index ad55b46c..75ac5f5f 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -49,8 +49,36 @@ export function useRequests() { * 上游只留最新那条 —— 失败每次都会报,攒着只是同一句话的副本。 */ const [rotated, setRotated] = useState[]>([]); - /** 配置换过几次。App 用它决定要不要重新拉概览 */ + /** + * 当前配置的版本号。改配置时必须带上它(乐观并发的凭据),App 也用它 + * 决定要不要重新拉概览。 + * + * **不能只从事件流里拿。**`config_reloaded` 报的是「变化」,而这里要 + * 的是「现在是什么」—— 全新启动、谁也没改过配置时那个事件永远不来, + * 于是它一直是 `null`,而所有写配置的入口都卡在「还没读到配置版本」 + * 上:防护页的三态、监听方式、上游字段、策略组,一个都写不进去。 + * 表现是应用看起来是只读的,而错误提示说「稍等一下再试」—— 等多久都 + * 不会好。 + * + * 所以挂载时主动读一次当前值,之后再由事件流跟进。 + */ const [configVersion, setConfigVersion] = useState(null); + useEffect(() => { + let alive = true; + void (async () => { + try { + const c = await invoke<{ version: string }>("get_config"); + // 事件先到就听事件的 —— 它比这次读取更新 + if (alive) setConfigVersion((v) => v ?? c.version); + } catch { + // core 还没起来。事件流起来之后第一次配置变更会补上,而在那 + // 之前写配置本来也做不了。 + } + })(); + return () => { + alive = false; + }; + }, []); const store = useRef(new Map()); const pending = useRef([]); const frame = useRef(null); diff --git a/vite.config.ts b/vite.config.ts index dd5d143e..8c4190b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,4 +9,17 @@ export default defineConfig({ // 构建产物给 Tauri 打包用 build: { outDir: "dist", emptyOutDir: true }, clearScreen: false, + test: { + /** + * **把工作树里的副本挡在外面。** + * + * `.claude/worktrees/` 下是同一个仓库的另一个检出,里面有一整套同名 + * 的测试文件。vitest 的默认排除项不包含它,于是套件会被悄悄翻倍: + * 这里一度报 8 个文件 68 个测试,而仓库里只有 4 个文件 38 个测试。 + * + * 多跑一遍不是问题,**跑的是旧代码才是** —— 那些副本停在创建工作树 + * 那天,既可能掩盖真文件的失败,也可能报出一个早已修好的失败。 + */ + exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"], + }, });