From c3a2bbdf78cb9f24103e9bacc56a9024d94588e8 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:09:56 +0800 Subject: [PATCH 1/7] feat: a collapsible icon rail, and a window that can be dragged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The window could not be moved at all.** The title bar is `Overlay`, so there is no real title bar to grab — the whole window relies on `data-tauri-drag-region`, and that attribute works by calling a command gated on `core:window:allow-start-dragging`. That permission is not in the `core:window` default set, so it has to be listed, and it was not. Nothing failed and nothing logged; the drag regions were simply inert. The `description` on this capability file already warned that a missing Tauri v2 permission fails silently. It now also names this instance, because the general warning did not stop the specific case. ## The sidebar It read as a web admin panel, and the reason was not the palette. A macOS source list has six identifying features and this had none of them: no icons, a flat opaque background, a neutral-grey selection instead of the accent colour, heavy group headings, 172px of width, and rows tighter than the system's while the gaps between groups were much looser — the opposite rhythm to the platform's. **Icons, and they carry the collapsed state.** Nine 16px stroke glyphs, hand-drawn as inline SVG rather than pulling in an icon library: this codebase already hand-writes its charts and its PNG encoder for the same reason. Routing, Gateway and Clients get three different skeletons — a forking arrow, a two-tier rack, a laptop — because those three are the concepts most easily confused, and with labels hidden a mistaken glyph costs a wrong click. **The rail collapses to 60px**, which hands 136px back to the content. For an app left open watching charts, that is the only change here that improves the main surface rather than the chrome. Labels move into tooltips, so collapsing defers information rather than discarding it. The choice is remembered, read in the state initialiser rather than an effect so the first frame is already the right width. ⌘⌥S toggles it — the same key Finder and Mail use, matched on `e.code` because ⌥ turns `s` into `ß` on macOS. **Group headings are gone, replaced by hairlines.** They were eating roughly a third of the list's height to say something a rule also says. The cost is real and worth stating: a line can say "these two are not those two", it cannot say "this group is called Security" — and that group was deliberately split out of Monitoring. **A palette for the window chrome**, separate from Tailwind's neutrals. A pure grey sidebar is exactly what a web sidebar looks like; the system's own is always slightly cool because it is translucent over the desktop. These neutrals carry a blue bias — chosen, not inherited. **The selection follows the system accent colour** via CSS `AccentColor` with a hard-coded blue behind it. Someone who sets their accent to pink should see a pink selection; writing the blue in is the moment it turns back into a web page. ## Also `vitest` was collecting the test files inside `.claude/worktrees/`, a second checkout of this same repository. The suite reported 8 files and 68 tests where the repository has 4 and 38. Running them twice is not the problem — **running a stale copy is**: those files stop at the day the worktree was made, so they can hide a failure in the real ones or report one that was fixed months ago. Excluded explicitly. `*.tsbuildinfo` is now ignored. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + src-tauri/capabilities/default.json | 3 +- src/App.tsx | 272 ++++++++++++++++++++++------ src/index.css | 50 +++++ src/ui/icons.tsx | 133 ++++++++++++++ vite.config.ts | 13 ++ 6 files changed, 415 insertions(+), 59 deletions(-) create mode 100644 src/ui/icons.tsx 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/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/App.tsx b/src/App.tsx index 6d6929a9..732dc483 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,18 @@ 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, + IconSettings, + IconSidebar, +} from "./ui/icons"; import { RowMenu } from "./ui/ContextMenu"; import Sessions from "./Sessions"; import Dashboard from "./Dashboard"; @@ -48,16 +60,20 @@ type Surface = | "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 +87,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 +96,9 @@ const SOURCES: { group: string; items: { id: Surface; label: string }[] }[] = [ items: [ // 路由原来埋在配置页中段,和「监听与访问」「诊断包」并列 —— // 而它是这个产品区别于一个普通代理的核心概念,不该要滚两屏才看见。 - { id: "routing", label: "路由" }, - { id: "config", label: "网关" }, - { id: "clients", label: "客户端" }, + { id: "routing", label: "路由" , icon: IconRoute }, + { id: "config", label: "网关" , icon: IconGateway }, + { id: "clients", label: "客户端" , icon: IconClient }, ], }, { @@ -92,7 +108,7 @@ const SOURCES: { group: string; items: { id: Surface; label: string }[] }[] = [ // 那是两类完全不同的东西:一个写进系统的登录项,一个写进 config.yaml。 // 分界线就是这个:改的是这个 macOS 应用,还是改网关的配置文件。 group: "应用", - items: [{ id: "settings", label: "设置" }], + items: [{ id: "settings", label: "设置" , icon: IconSettings }], }, ]; @@ -241,6 +257,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 +321,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 +452,98 @@ export default function App() { return ( -
+
{/* 源列表。整条都是拖拽区 —— 窗口用的是 Overlay 标题栏(红绿灯浮在 内容上),没有一条真的标题栏可以抓,不给拖拽区窗口就挪不动。 + + **可以收起。**收起之后只剩图标,内容区多出 136px —— 对一个开着 + 不关、一直在看图表的应用,这是唯一真正改善主界面的方向。名字进 + 悬浮说明,所以收起来不是把信息丢掉,是把它推迟到需要的时候。 + + **没有分组标题,只有细分隔线。**四个标题原本吃掉列表约三分之一 + 的高度,而它们说的事情分隔线也说得出:这两项和上面那两项不一样。 + 代价是分组的**名字**没了 —— 认下这笔,换来整列读起来是一个对象, + 而不是四个小区块。 */} @@ -485,7 +617,31 @@ export default function App() { (split ? "overflow-hidden" : "overflow-y-auto") } > -
+ {/* + 标题栏那一条。整条是拖拽区,按钮不是 —— 拖拽区只作用在带那个 + 属性的元素上,不带的子元素照常可点。 + + **收起源列表的按钮放在这儿,不放在源列表里。**收起之后源列表 + 只有 60px 宽,按钮塞进去要么挤掉一个图标位,要么小到点不准; + 而放在内容这一侧,它在两种状态下都在同一个位置。系统应用 + (访达、邮件)也是这么放的。 + */} +
+ + + +
{error && (
{error} 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/ui/icons.tsx b/src/ui/icons.tsx new file mode 100644 index 00000000..faf22afb --- /dev/null +++ b/src/ui/icons.tsx @@ -0,0 +1,133 @@ +/** + * 源列表的图标。 + * + * **手写,不引图标库。**九个 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 IconSettings = (p: P) => ( + + + + + + + + +); + +/** + * 收起 / 展开源列表。 + * + * 用的是系统那个图形:一个矩形,左边一栏填实。**填的那一栏就是源列表 + * 本身**,所以它同时说明了「这个按钮管的是左边那条」和「它现在是开 + * 的」—— 换成箭头就只剩方向,说不出管的是什么。 + */ +export const IconSidebar = (p: P) => ( + + + + +); 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/**"], + }, }); From af65fcec16bcda5473d856b1c99b4fce0d426113 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:13:48 +0800 Subject: [PATCH 2/7] fix: quieten the collapsed status dot, and give rows their full height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things from looking at it running. **The status dot was shouting.** Expanded, it sits next to the words "运行中" and reads as punctuation. Collapsed, it becomes the only colour in an otherwise empty 60px column — a fully saturated green dot making a loud announcement that nothing is wrong. Healthy now renders at 45% opacity and 6px instead of 8px: still visible, but you have to look for it. Amber and red stay at full strength, because those are the ones that have earned a glance. **Rows go 26px to 28px**, which is the system metric. 26 came from the compact variant of the sidebar sketches, but that variant had no icons — with icons the row has nothing in the spare vertical space and the whole list reads as undersized in a tall window. The empty area below the last item is left alone. Ten items in a tall window leaves a gap, and every macOS source list has one; the only way to close it would be to invent something to put there. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 732dc483..8c372f00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -510,7 +510,7 @@ export default function App() { className={ "relative flex items-center rounded-md " + (railOpen - ? "h-[26px] w-full gap-2 px-2 text-left tw-body " + ? "h-[28px] w-full gap-2 px-2 text-left tw-body " : "mx-auto h-[40px] w-[40px] justify-center ") + (on ? "tw-selected font-medium" : "hover:bg-[var(--chrome-hover)]") } @@ -591,11 +591,19 @@ export default function App() { status?.gateway_addr ? `${c.text} · ${status.gateway_addr}` : c.text } > + {/* + **一切正常的时候它不该抢眼。**展开时这个点旁边有「运行中」 + 三个字,它是个标点;收起之后它成了空列里唯一的颜色,一个 + 高饱和的绿点在那儿大声说一件没什么可说的事。 + + 所以正常态压到 45% 不透明、直径 6px —— 看得见,但要主动 + 去看。出问题时才放回满饱和:琥珀和红是真的需要被扫到的。 + */} Date: Tue, 15 Sep 2026 02:57:44 +0800 Subject: [PATCH 3/7] fix: read the config version at startup instead of waiting for a change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `configVersion` is the optimistic-concurrency token every write carries, and it was only ever set by the `config_reloaded` event. That event reports a *change*; on a fresh launch where nobody has touched the config it never arrives, so the token stayed null and all six config-writing entry points were dead — the guard page's three-state controls, the listen mode, the upstream fields, the policy groups. The app looked read-only, and the error said "haven't read the config version yet, try again in a moment", which would never come true. Read the current version once on mount; the event stream keeps it current from there, and wins if it gets there first. Co-Authored-By: Claude Opus 5 --- src/useRequests.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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); From 1f1b9b1970d69f58c446202a75717b146cef88da Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:57:44 +0800 Subject: [PATCH 4/7] feat: bridge key generation and the interface list to the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two control-plane endpoints the desktop side had no way to reach. `new_key` generates a gateway key in core rather than in the UI: the alphabet and the length are security decisions, and keeping a second copy here guarantees that one day only one of them gets changed. `interfaces` is asked fresh every time, never cached. Plugging in a cable, joining another network or bringing up a VPN changes the list, and a cached one only puts an address in the picker that no longer exists — the consequence of choosing it is a gateway that will not start. Co-Authored-By: Claude Opus 5 --- src-tauri/Cargo.lock | 2 +- src-tauri/src/control.rs | 20 ++++++++++++++++++++ src-tauri/src/lib.rs | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c0d4f1bb..813f0953 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#4ce02594bae486f33226f6ce629608051efba04d" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 24f9c647..e6cb0b50 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?; 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, From 5d6fbdd14dead4d8a064bb9dc8b770b8cf38c251 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:58:00 +0800 Subject: [PATCH 5/7] feat: pages for keys, routes and proxies, and a listen mode you can pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model the UI presented did not match the one core implements, and three of its four nouns had no page at all. **Keys.** The only access control there is — port decides who can knock, key decides who gets in, and without a key even 127.0.0.1 is refused. It used to be one read-only line inside "listen and access": you could not create, rotate, revoke or bind one. Everything else depends on it. With every client sharing one key, `RequestFacts.client` is always the same value, so traffic and sessions cannot tell them apart, per-client concurrency limits do nothing, and binding a route to a key is not expressible. The model-allow control is three-state, and the third state is "none at all" — an empty list. That is not a broken state, it is how you park a client, so it has to be reachable on purpose rather than by deleting the last entry. Every transition between the three works in both directions. **Routes.** A key walks exactly one route. Unbound keys walk the default route — the one you land on, not one everybody passes through. So two keys share a rule set by naming the same route, not by copying rules. **Proxies.** Previously a reference with no way to create the thing being referenced: the upstream table had a proxy column whose options came from a list of names, and nothing in the app could add to it. The password is write-only here — the view goes into logs, diagnostic bundles and the screenshots users post, so it can be set and cleared but never read back. **Listen mode.** Which interface the gateway binds to, which is not an allow-list: loopback, one LAN interface, or all of them. Interfaces are enumerated live, and an address in the config that no longer exists is still listed — dropping it would silently show a different address and look like the setting changed by itself. Switching rebinds the way nginx reloads, so there is no restart button. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 118 ++++++++--- src/Config.tsx | 524 +++++++++++++++++++++++++++++++++++++++++------ src/Keys.tsx | 391 +++++++++++++++++++++++++++++++++++ src/Proxies.tsx | 238 +++++++++++++++++++++ src/Routes.tsx | 436 +++++++++++++++++++++++++++++++++++++++ src/types.ts | 90 +++++++- src/ui/icons.tsx | 18 ++ 7 files changed, 1713 insertions(+), 102 deletions(-) create mode 100644 src/Keys.tsx create mode 100644 src/Proxies.tsx create mode 100644 src/Routes.tsx diff --git a/src/App.tsx b/src/App.tsx index 8c372f00..65d85007 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,8 @@ 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"; @@ -28,6 +30,8 @@ import { IconGuard, IconRoute, IconSession, + IconKey, + IconServer, IconSettings, IconSidebar, } from "./ui/icons"; @@ -56,6 +60,8 @@ type Surface = | "security" | "guard" | "routing" + | "keys" + | "upstreams" | "config" | "settings" | "clients"; @@ -96,6 +102,8 @@ const SOURCES: { items: [ // 路由原来埋在配置页中段,和「监听与访问」「诊断包」并列 —— // 而它是这个产品区别于一个普通代理的核心概念,不该要滚两屏才看见。 + { 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 }, @@ -112,16 +120,29 @@ const SOURCES: { }, ]; -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" }; } /** 可排序表头。箭头只出现在当前排序列上 —— 每列都挂一个等于没挂。 */ @@ -460,10 +481,15 @@ export default function App() { 源列表。整条都是拖拽区 —— 窗口用的是 Overlay 标题栏(红绿灯浮在 内容上),没有一条真的标题栏可以抓,不给拖拽区窗口就挪不动。 - **可以收起。**收起之后只剩图标,内容区多出 136px —— 对一个开着 + **可以收起。**收起之后只剩图标,内容区多出 116px —— 对一个开着 不关、一直在看图表的应用,这是唯一真正改善主界面的方向。名字进 悬浮说明,所以收起来不是把信息丢掉,是把它推迟到需要的时候。 + **收起宽度 80px 不是审美选的,是红绿灯定的。**标题栏是 Overlay, + 三颗灯浮在内容上,最右那颗绿灯的右边缘落在约 71pt 处。侧栏窄于 + 这个数,右边框就会从绿灯身上穿过去 —— 之前用 60px 正是如此。 + 80 给了它 9pt 余量。**改窄之前先量一遍那三颗灯。** + **没有分组标题,只有细分隔线。**四个标题原本吃掉列表约三分之一 的高度,而它们说的事情分隔线也说得出:这两项和上面那两项不一样。 代价是分组的**名字**没了 —— 认下这笔,换来整列读起来是一个对象, @@ -472,7 +498,7 @@ export default function App() {