diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b222bbed..c431b278 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,19 @@ # 这条流水线不重跑那四道门:tag 是从 main 上打的,而 main 上的每一个 # commit 都过过 `ci.yml`。这里只做 CI 做不了的那件事 —— 产出一个别人 # 能下载、能校验的文件。 +# +# **所有平台一起发,或者都不发。**各平台的 job 只构建、自检,把文件交给 +# 最后的 `publish`,Release 由它一次写成。各挂各的时候,先编完的那个就把 +# Release 建了出来,`releases/latest` 随即指向它,而别的平台的文件还在路上 +# —— 那几分钟里 `scripts/install.sh` 和 `twcore upgrade` 在那些平台上找不到 +# 文件;某个平台编挂了,发出去的就是缺一块的 Release。 name: Release on: push: tags: ["v*"] - # **排练。**手工触发时照常构建、照常自检,但什么都不发。 + # **排练。**手工触发时照常构建、照常自检,但什么都不发:文件留在这次 + # 运行的产物(Artifacts)里,`publish` 照样把它们逐个核对,只是不写 Release。 # # 理由是这条流水线唯一一个「发现了也没法在本次补救」的性质:tag 一旦 # 推上去,流水线错了就得把它撤回来。而这里新增的那条 Windows 支线要靠 @@ -72,16 +79,12 @@ jobs: shasum -a 256 twcore-aarch64-apple-darwin > twcore-aarch64-apple-darwin.sha256 cat twcore-aarch64-apple-darwin.sha256 - - uses: softprops/action-gh-release@v2 - # 排练不发布。 - if: github.ref_type == 'tag' + # 交给 `publish`,Release 只由它来写 + - uses: actions/upload-artifact@v4 with: - files: | - dist/twcore-aarch64-apple-darwin - dist/twcore-aarch64-apple-darwin.sha256 - # 发布说明留空,从 tag 的信息里来 —— 让一份自动生成的清单 - # 冒充发布说明,读的人得不到任何东西 - generate_release_notes: true + name: twcore-aarch64-apple-darwin + path: dist/ + if-no-files-found: error twcore-windows: name: twcore (Windows x64 + arm64) @@ -163,16 +166,11 @@ jobs: Get-Content "$out.sha256" } - - uses: softprops/action-gh-release@v2 - # 排练不发布。 - if: github.ref_type == 'tag' + - uses: actions/upload-artifact@v4 with: - files: | - dist/twcore-x86_64-pc-windows-msvc.exe - dist/twcore-x86_64-pc-windows-msvc.exe.sha256 - dist/twcore-aarch64-pc-windows-msvc.exe - dist/twcore-aarch64-pc-windows-msvc.exe.sha256 - generate_release_notes: true + name: twcore-windows + path: dist/ + if-no-files-found: error twcore-linux: name: twcore (${{ matrix.target }}) @@ -289,13 +287,84 @@ jobs: test -f "$T/twcore-$TARGET/twcore.service" "$T/twcore-$TARGET/twcore" --version + - uses: actions/upload-artifact@v4 + with: + name: twcore-${{ matrix.target }} + path: dist/ + if-no-files-found: error + + # **Release 只在这里写,而且只写一次。** + # + # 以前每个构建 job 各自用 action-gh-release 挂自己的文件,每一次都带着 + # `generate_release_notes`。Release 已经存在时,这个 action 照样再要一份 + # 生成的说明,接在原有正文后面 —— 于是 v0.44.0 到 v0.47.0 的说明各有四份 + # (四个 job),v0.30.0 到 v0.43.0 各有两份(macOS、Windows 两个 job)。 + publish: + name: Publish + needs: [twcore, twcore-windows, twcore-linux] + runs-on: ubuntu-latest + env: + # 发出去的就是这些,一个不多、一个不少。文件名是和桌面版的流水线、 + # `twcore upgrade`、`scripts/install.sh` 之间的约定,`twcore upgrade` 有 + # 一条测试读这份文件核对它。加一个平台要在这里加上它的文件 —— 否则 + # 下面的核对会指出多出来的那几个 + FILES: | + dist/twcore-aarch64-apple-darwin + dist/twcore-aarch64-apple-darwin.sha256 + dist/twcore-x86_64-pc-windows-msvc.exe + dist/twcore-x86_64-pc-windows-msvc.exe.sha256 + dist/twcore-aarch64-pc-windows-msvc.exe + dist/twcore-aarch64-pc-windows-msvc.exe.sha256 + dist/twcore-x86_64-unknown-linux-gnu + dist/twcore-x86_64-unknown-linux-gnu.sha256 + dist/twcore-x86_64-unknown-linux-gnu.tar.gz + dist/twcore-x86_64-unknown-linux-gnu.tar.gz.sha256 + dist/twcore-aarch64-unknown-linux-gnu + dist/twcore-aarch64-unknown-linux-gnu.sha256 + dist/twcore-aarch64-unknown-linux-gnu.tar.gz + dist/twcore-aarch64-unknown-linux-gnu.tar.gz.sha256 + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + # 排练也跑这一步:构建 job 交来的正好是清单上的文件,每个都对得上 + # 它的校验和 + - name: Every file is here, nothing else is, and each matches its checksum + run: | + set -euo pipefail + diff <(printf '%s' "$FILES" | sort) <(find dist -type f | sort) + cd dist + sha256sum -c ./*.sha256 + + # 同一个原因,这一个 job 也会接上第二份:Release 已经在了 —— 重跑这个 + # job(比如挂文件挂到一半断了),或者 Release 先手工建好了(v0.11.0 就是 + # 这样多出一份的)。所以只在它还不存在时生成 + # + # **「不存在」只认 gh 查不到时的那句 `release not found`。**网络断了、令牌 + # 不对、被限流,gh 一样失败;把那些也当成「不存在」,Release 其实在的话就又 + # 接上一份。所以别的失败让这一步挂掉,原话留在日志里 + - name: Notes only for a release that does not exist yet + id: notes + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: | + if out=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json tagName 2>&1); then + echo "generate=false" + elif grep -qx 'release not found' <<<"$out"; then + echo "generate=true" + else + echo "$out" >&2 + exit 1 + fi >> "$GITHUB_OUTPUT" + - uses: softprops/action-gh-release@v2 - # 排练不发布。 + # 排练到上面为止 if: github.ref_type == 'tag' with: - files: | - dist/twcore-${{ matrix.target }} - dist/twcore-${{ matrix.target }}.sha256 - dist/twcore-${{ matrix.target }}.tar.gz - dist/twcore-${{ matrix.target }}.tar.gz.sha256 - generate_release_notes: true + files: ${{ env.FILES }} + fail_on_unmatched_files: true + # GitHub 生成的说明:上一版以来合进 main 的 PR + generate_release_notes: ${{ steps.notes.outputs.generate }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3540ba39..99e9674d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ clean the diff is: protections.** Replay came close to being a legitimate way around redaction. - **One door into the control plane.** Every transport (unix socket, - Windows loopback port, and the remote port to come) hands its + Windows loopback port, and the remote control port) hands its connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. @@ -157,9 +157,15 @@ whatever sat in a `target/` directory that afternoon. 1. Bump `version` in the workspace `Cargo.toml`, land it on `main`. 2. Tag that commit `vX.Y.Z` and push the tag. -3. `release.yml` builds `twcore` for every target below, checks each - binary actually runs and reports the version on the tag, and attaches - them to a GitHub Release, each with a `.sha256` (` `). +3. `release.yml` builds `twcore` for every target below. It checks that + each binary is built for its target and, where the runner can execute + it, that it starts and reports the version on the tag. The Windows + arm64 binary is cross-compiled on an x64 runner, so only the machine + field in its PE header is checked. The Linux binaries are also checked + to need glibc 2.35 at most (Ubuntu 22.04). Once every target has + built, a single job attaches them all to a GitHub Release, each with a + `.sha256` (` `); if one target fails, nothing is + published. | Target | Files | |---|---| @@ -173,13 +179,15 @@ The bare binaries are what the desktop app's pipeline bundles and what the unit and the binary come from the same commit. The file names are a contract with both: `twcore upgrade` has a test that reads `release.yml`. -To try a change to `release.yml` without publishing, run it by hand -(`workflow_dispatch`): it builds and checks everything and uploads nothing. +To try a change to `release.yml` without publishing, run it by hand on +your branch (`gh workflow run release.yml --ref `): it builds and +checks everything, leaves the files as the run's artifacts, and publishes +nothing. -The desktop app pins `tw-api` (and the few other crates it uses: `tw-types`, -`tw-yaml`, `tw-guard`, `tw-watch`) to the same tag and bundles the binary -from that release. Those two have to come from one commit: the binary -speaks a protocol, and the app compiles a mirror of it. +The desktop app pins `tw-api` (and the few other crates it uses: +`tw-types`, `tw-yaml`, `tw-guard`, `tw-watch`, `tw-link`) to the same tag +and bundles the binary from that release. Those two have to come from one +commit: the binary speaks a protocol, and the app compiles a mirror of it. On macOS, Apple Silicon only, deliberately. An Intel user downloading a file that will not open is worse served than one who finds no download diff --git a/Cargo.toml b/Cargo.toml index 8b502174..2baa824a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,16 @@ edition = "2024" rust-version = "1.85" license = "MIT" repository = "https://github.com/ThinkWatchProject/ThinkWatch-Core" +homepage = "https://thinkwat.ch/core/" +# 这些 crate 不发 crates.io(企业版和桌面版按 tag 从 git 取),docs.rs 上 +# 没有它们 —— 文档指向项目自己的 +documentation = "https://thinkwat.ch/docs/core/" +readme = "README.md" +# 关键词和分类说的是网关本身,只有网关那几个 crate(twcore、tw-gateway、 +# tw-control)继承;其余的 crate 各有各的事,只继承上面几项。 +# crates.io 的规矩:关键词最多五个,分类只能用它列出的 slug +keywords = ["ai-gateway", "llm", "openai", "anthropic", "gemini"] +categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index b00dfe8f..3d2d9e65 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -5,7 +5,13 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Self-contained local AI gateway binary, and the engine behind ThinkWatch Lite" +homepage.workspace = true +documentation.workspace = true +readme.workspace = true +# `twcore --help` 的第一行也是它(main.rs 的 `about`) +description = "Self-contained AI gateway: the local engine behind ThinkWatch Lite, or a standalone gateway on a server" +keywords.workspace = true +categories.workspace = true [dependencies] tw-config = { workspace = true } diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index b6b39026..303d71d5 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -17,11 +17,8 @@ mod upgrade; use lockfile::{LockFile, LockOutcome}; #[derive(Parser)] -#[command( - name = "twcore", - version, - about = "The local AI gateway engine behind ThinkWatch Lite" -)] +// `about` 不写值就是 Cargo.toml 里的 description:一句话只写一处,两边不会各说各的 +#[command(name = "twcore", version, about)] struct Cli { /// Path to the configuration file; ~/.thinkwatch/config.yaml by default #[arg(long, global = true)] diff --git a/crates/tw-api/Cargo.toml b/crates/tw-api/Cargo.toml index 3ec5b7ed..bdbd3be3 100644 --- a/crates/tw-api/Cargo.toml +++ b/crates/tw-api/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Control-plane contract: request and response types, plus a client" [dependencies] diff --git a/crates/tw-breaker/Cargo.toml b/crates/tw-breaker/Cargo.toml index 0e3c8b47..733f746c 100644 --- a/crates/tw-breaker/Cargo.toml +++ b/crates/tw-breaker/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "The circuit-breaker state machine both gateways run: pure data and pure transitions, stored wherever the caller keeps it" [dependencies] diff --git a/crates/tw-config/Cargo.toml b/crates/tw-config/Cargo.toml index f037b21d..84dadad0 100644 --- a/crates/tw-config/Cargo.toml +++ b/crates/tw-config/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "YAML config schema, loading, and validation" [dependencies] diff --git a/crates/tw-control/Cargo.toml b/crates/tw-control/Cargo.toml index eb5256a6..ab8273a6 100644 --- a/crates/tw-control/Cargo.toml +++ b/crates/tw-control/Cargo.toml @@ -5,7 +5,12 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Control-plane API server over a unix socket" +homepage.workspace = true +documentation.workspace = true +readme.workspace = true +description = "Control-plane API server over a unix socket, a loopback port on Windows, and an optional remote control port" +keywords.workspace = true +categories.workspace = true [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-dialect/Cargo.toml b/crates/tw-dialect/Cargo.toml index 38cd577e..7a3e72a2 100644 --- a/crates/tw-dialect/Cargo.toml +++ b/crates/tw-dialect/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Request, response and stream conversion between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini" [dependencies] diff --git a/crates/tw-engine/Cargo.toml b/crates/tw-engine/Cargo.toml index c8254fe2..b5f6a48d 100644 --- a/crates/tw-engine/Cargo.toml +++ b/crates/tw-engine/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Routing rule engine and policy groups" [dependencies] diff --git a/crates/tw-gateway/Cargo.toml b/crates/tw-gateway/Cargo.toml index cd2c752f..6d84aee2 100644 --- a/crates/tw-gateway/Cargo.toml +++ b/crates/tw-gateway/Cargo.toml @@ -5,7 +5,12 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Data-plane HTTP server" +keywords.workspace = true +categories.workspace = true [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-guard/Cargo.toml b/crates/tw-guard/Cargo.toml index ad2b4a5c..9cec2a04 100644 --- a/crates/tw-guard/Cargo.toml +++ b/crates/tw-guard/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Guards shared by both gateways: outbound redaction and restoration, inspection of the tool calls an upstream returns, hidden characters, content filtering and an output length limit" [dependencies] diff --git a/crates/tw-link/Cargo.toml b/crates/tw-link/Cargo.toml index 7abeb067..1b2fa47f 100644 --- a/crates/tw-link/Cargo.toml +++ b/crates/tw-link/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Control-channel handshake and encryption (Noise NNpsk0), shared by core and the desktop app" [dependencies] diff --git a/crates/tw-observe/Cargo.toml b/crates/tw-observe/Cargo.toml index 7b08870f..b316444e 100644 --- a/crates/tw-observe/Cargo.toml +++ b/crates/tw-observe/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Event bus" [dependencies] diff --git a/crates/tw-pricing/Cargo.toml b/crates/tw-pricing/Cargo.toml index a39d0b21..9dcaa63f 100644 --- a/crates/tw-pricing/Cargo.toml +++ b/crates/tw-pricing/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Pricing: the public price table, user price sheets, and three-state cost (measured, estimated, unpriced)" [dependencies] diff --git a/crates/tw-secret/Cargo.toml b/crates/tw-secret/Cargo.toml index 88fb5138..1a41b16a 100644 --- a/crates/tw-secret/Cargo.toml +++ b/crates/tw-secret/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Environment variable interpolation, exec-sourced credentials, and secret masking" [dependencies] diff --git a/crates/tw-store/Cargo.toml b/crates/tw-store/Cargo.toml index 0bdb523c..d0ae45b8 100644 --- a/crates/tw-store/Cargo.toml +++ b/crates/tw-store/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Request history and runtime state on SQLite and the filesystem" [dependencies] diff --git a/crates/tw-types/Cargo.toml b/crates/tw-types/Cargo.toml index 5af0619a..5f637a6e 100644 --- a/crates/tw-types/Cargo.toml +++ b/crates/tw-types/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "A message for people: a stable code, its arguments, and the English sentence" [dependencies] diff --git a/crates/tw-watch/Cargo.toml b/crates/tw-watch/Cargo.toml index 1f78ee48..0390cfe0 100644 --- a/crates/tw-watch/Cargo.toml +++ b/crates/tw-watch/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Debounced directory watching: one signal per burst of changes" [dependencies] diff --git a/crates/tw-yaml/Cargo.toml b/crates/tw-yaml/Cargo.toml index fdc6cf49..093afc31 100644 --- a/crates/tw-yaml/Cargo.toml +++ b/crates/tw-yaml/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "YAML patch layer that makes minimal edits to the original text" [dependencies] diff --git a/docs/server.md b/docs/server.md index e0b2c29f..319ba244 100644 --- a/docs/server.md +++ b/docs/server.md @@ -3,9 +3,9 @@ [中文](server.zh-CN.md) ThinkWatch Core runs without a desktop: on a Linux machine it is started by -systemd from its configuration file, and the ThinkWatch Lite app on a Mac -connects to it over the network to show traffic and change settings. Clients -anywhere on the network send their requests to the server's gateway. +systemd from its configuration file, and ThinkWatch Lite on macOS, Windows or +Linux connects to it over the network to show traffic and change settings. +Clients anywhere on the network send their requests to the server's gateway. This page covers installing, configuring, starting, connecting and upgrading. Every field mentioned is described in the @@ -185,7 +185,7 @@ and enter: The app tests the connection before saving and says what is wrong if it fails: no answer (address, port, firewall, `enabled`), connection closed -(this Mac's address is probably not in `allow_from`), wrong key, or +(this computer's address is probably not in `allow_from`), wrong key, or different versions. `allow_from` for this port does not let the server itself in automatically; commands on the server use the local channel. @@ -193,21 +193,22 @@ A source that fails the handshake five times within a minute is ignored for a minute. Removing a network from `allow_from` also closes the connections already open from it. -The key is kept in the Mac's keychain. To replace it, run -`twcore control-key --rotate` on the server; connections made with the old -key are closed at once, and connected apps then have to be given the new -key. +The desktop app stores the key in its data directory, in a file readable +only by the user who runs the app, rather than in the system keychain. To +replace it, run `twcore control-key --rotate` on the server; connections +made with the old key are closed at once, and connected apps then have to +be given the new key. -A remote connection can do everything the app does on its own Mac except -three things, which the server refuses: stopping core (systemd runs it), -taking the diagnostic bundle, and changing `listen.control`, the section -it came in through. Do those on the server. +A remote connection can do everything the app does on its own computer +except three things, which the server refuses: stopping core (systemd +runs it), taking the diagnostic bundle, and changing `listen.control`, +the section it came in through. Do those on the server. ### Point clients at the server Clients use the server's gateway, `http://:8788`, with a gateway key -from `clients`. The desktop app can point the clients on the Mac at the -server (Clients page); on other machines, configure them by hand. +from `clients`. The desktop app can point the clients on its own computer at +the server (Clients page); on other machines, configure them by hand. ## Upgrading diff --git a/docs/server.zh-CN.md b/docs/server.zh-CN.md index 4f4b3aa0..3b253d38 100644 --- a/docs/server.zh-CN.md +++ b/docs/server.zh-CN.md @@ -2,7 +2,7 @@ [English](server.md) -ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,Mac 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,macOS、Windows 或 Linux 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 @@ -143,13 +143,13 @@ allowed sources: 192.168.1.0/24 同一来源一分钟内握手失败五次,之后一分钟不理它。从 `allow_from` 中删掉一个网段,已经从那里连着的连接也随即断开。 -密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 +桌面应用把密钥存放在其数据目录下的一个文件中,该文件只有运行应用的用户可以读取;不使用系统钥匙串。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 远程连接能做应用在本机能做的一切,只有三件事服务器会拒绝:停止 core(它由 systemd 管理)、生成诊断包、修改 `listen.control`(这条连接进来的那一节)。这三件事在服务器上操作。 ### 让客户端指向服务器 -客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把这台 Mac 上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把本机的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 ## 升级