@bysir/herdr-web 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +317 -625
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -4,795 +4,487 @@
|
|
|
4
4
|
<img src="assets/logo.png" alt="herdr-web" width="96" />
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<b>English</b> · <a href="README.zh-CN.md">简体中文</a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
A terminal in your browser, built for running [`herdr`](https://github.com/zbysir/herdr).
|
|
12
|
+
One Go binary with the frontend baked in. Works on phones.
|
|
13
|
+
|
|
14
|
+
**Voice compose** is the point of this project: dictate on a tablet, select the words that came out
|
|
15
|
+
wrong and say them again, then hand the whole paragraph to an agent's input line. A phone is enough
|
|
16
|
+
to get by; a tablet in landscape gives you 211 columns — that is a workstation.
|
|
8
17
|
|
|
9
|
-
|
|
18
|
+
This document covers **installing, using and configuring** it. Why each thing works the way it does,
|
|
19
|
+
and what it cost to learn, lives in the [documents listed at the end](#documents) — those are the
|
|
20
|
+
real substance of this project. They are in Chinese.
|
|
10
21
|
|
|
11
|
-
##
|
|
22
|
+
## Install
|
|
12
23
|
|
|
13
24
|
```bash
|
|
14
|
-
npm install -g @bysir/herdr-web #
|
|
15
|
-
herdr-web #
|
|
25
|
+
npm install -g @bysir/herdr-web # easiest if you have node; upgrades come free
|
|
26
|
+
herdr-web # listens on 127.0.0.1 only
|
|
16
27
|
```
|
|
17
28
|
|
|
18
|
-
|
|
29
|
+
No node (common on servers):
|
|
19
30
|
|
|
20
31
|
```bash
|
|
21
32
|
curl -fsSL https://raw.githubusercontent.com/zbysir/herdr-web/master/install.sh | sh
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
Installs into `~/.local/bin`. To install somewhere else, the variable has to go to `sh`, **not** to `curl`:
|
|
25
36
|
|
|
26
37
|
```bash
|
|
27
|
-
curl -fsSL …/install.sh | HERDR_WEB_INSTALL_DIR=/opt/bin sh #
|
|
28
|
-
HERDR_WEB_INSTALL_DIR=/opt/bin curl -fsSL …/install.sh | sh #
|
|
38
|
+
curl -fsSL …/install.sh | HERDR_WEB_INSTALL_DIR=/opt/bin sh # right
|
|
39
|
+
HERDR_WEB_INSTALL_DIR=/opt/bin curl -fsSL …/install.sh | sh # wrong — curl gets it, the script never sees it
|
|
29
40
|
```
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
The wrong one **does not fail**; it quietly installs to the default directory. Same shape for `HERDR_WEB_INSTALL_VER=v0.1.0` to pin a version.
|
|
32
43
|
|
|
33
|
-
|
|
44
|
+
The installer **always verifies sha256** and refuses to install if neither `sha256sum` nor `shasum` exists — there is a login shell behind this thing.
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
Other ways in:
|
|
36
47
|
|
|
37
48
|
```bash
|
|
38
|
-
make build && ./herdr-web #
|
|
39
|
-
HERDR_WEB_HOST=0.0.0.0 ./herdr-web #
|
|
49
|
+
make build && ./herdr-web # from source (frontend → internal/webui/dist → go build)
|
|
50
|
+
HERDR_WEB_HOST=0.0.0.0 ./herdr-web # listen on the LAN, and print a QR code for your phone
|
|
40
51
|
```
|
|
41
52
|
|
|
42
|
-
`go install github.com/zbysir/herdr-web/cmd/herdr-web@latest`
|
|
43
|
-
前端产物是 `make build` 生成后 embed 进去的,不入版本库,所以 `go install` 拿不到。那样装出来的
|
|
44
|
-
只能配 `--web <目录>` 指一份自己构建的前端,或者干脆只用命令行子命令。要能开页面就走上面那三条。
|
|
53
|
+
`go install github.com/zbysir/herdr-web/cmd/herdr-web@latest` works too, but **what you get has no frontend**: the web assets are produced by `make build` and embedded, and they are not in the repo, so `go install` can't see them. That binary is only useful with `--web <dir>` pointing at a frontend you built yourself, or for the CLI subcommands. Use one of the three above if you want the page.
|
|
45
54
|
|
|
46
|
-
**Windows
|
|
55
|
+
**No native Windows build** — install it inside WSL. Not laziness: the terminal in the browser needs a real PTY (Go side uses `creack/pty`, whose Windows implementation is a `return nil, ErrUnsupported` stub) and herdr itself speaks over a unix socket. Inside WSL it is simply the Linux build, fully functional; the browser end was always cross-platform, so `http://localhost:7788/` on Windows works fine. On win32 the npm package prints that explanation instead of installing something that cannot run.
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
To run it as a service that starts at boot, see [Daemon](#daemon). To upgrade, see [Updating](#updating).
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
**Environment variables are the only source of configuration** (no config file; the only flag is `--web`). The full list, how to set it, and a few common setups are under [Configuration](#configuration). Subcommands: `herdr-web --help`.
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
On startup it prints the addresses you can reach it at. When listening on `0.0.0.0` it scores the interfaces and marks the one your phone can actually reach with `← use this one from your phone` (the pile of OrbStack / VPN virtual interfaces gets pushed to the bottom); that is the address encoded in the QR code.
|
|
53
62
|
|
|
54
|
-
|
|
63
|
+
**Pair each device once.** The startup banner carries a one-time pairing code (5 minutes, single use) and its QR code — scan it from your phone and you are in, zero typing. After that your bookmark holds no secret (the credential lives in an `HttpOnly` cookie), and changing Wi-Fi, changing subnets or rebooting costs you nothing. To pair another device, run `herdr-web pair` on the machine.
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
Two ways to scan:
|
|
57
66
|
|
|
58
|
-
-
|
|
59
|
-
-
|
|
67
|
+
- **Your camera app** (works everywhere): the code is just a link with `?pair=`; scanning opens it and you land already paired.
|
|
68
|
+
- **"Scan with camera" inside the pairing page**: opens the rear camera, points at the code on the host screen, pairs on recognition without navigating. This button **only appears when it can work** — it needs `BarcodeDetector` (the system decoder, which saves tens of KB of JS; macOS uses Vision, Android uses ML Kit, and **iOS Safari and Chrome on Linux do not have it**) plus a camera (only granted in a secure context — plain http on a LAN gets nothing). If either is missing the button is not rendered at all, rather than left there to fail on click.
|
|
60
69
|
|
|
61
|
-
|
|
70
|
+
If neither is convenient, type the 8-digit code into the pairing page; it submits itself once you have typed 8 characters. What the in-page scanner reads is reduced to the `pair=` part and goes through the same `POST /auth/pair`, so the security model is unchanged (only someone at the machine can produce a code).
|
|
62
71
|
|
|
63
72
|
```bash
|
|
64
|
-
herdr-web pair #
|
|
65
|
-
herdr-web devices #
|
|
66
|
-
herdr-web revoke <id> #
|
|
67
|
-
herdr-web unlock #
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
网页上顶栏最右那个 ⚙ 是**设置面板**,「设备」页里看谁配过对、**登出这台**、踢掉别的。踢人和全部
|
|
71
|
-
踢掉都要点两下才生效。**网页上不出配对码**(连已配对的设备也不行),理由见下面「安全」。
|
|
72
|
-
|
|
73
|
-
**配对码用完了就回机器前** `herdr-web pair`。这不是偷懒 —— 见下。
|
|
74
|
-
|
|
75
|
-
原来那把永不过期的 `~/.herdr-web/token` 降级成**只能引导**:旧书签第一次打开会自动换成设备凭据、并把 URL 里的 token 抹掉,之后就该 `rm ~/.herdr-web/token`。细节和为什么这么设计看 [SECURITY.md](SECURITY.md)。
|
|
76
|
-
|
|
77
|
-
连上之后**自动敲 `herdr`**。想敲别的、或者不想自动敲:`HERDR_WEB_ONCONNECT`(设成空串就留在 shell 里)。地址栏里加一段路径(`/work`)就是**另一个 herdr session**,见下面「[一个 URL 一个 session](#一个-url-一个-sessionname)」。顶栏原来那个「敲 herdr」按钮去掉了 —— 自动敲之后它一天用不上一次,而软键条预设里有现成的「敲 herdr」键,要就自己放一个上去。
|
|
78
|
-
|
|
79
|
-
**管理页在 `http://127.0.0.1:<端口+1>/`**(启动横幅里有):看证书状态、点一下签发/续期、生成 DNS 的 `.env` 片段、出配对码、踢设备。它**只绑 loopback,公网上不存在**,所以不需要登录 —— 能连上它的东西已经有你的 shell 了。为什么不做成「主服务上一个需要认证的页面」:认证是会失效的控制,「碰不到」是个性质;而且**管理页不能依赖它自己要管的那个证书**(证书一坏就打不开修证书的页面)。
|
|
80
|
-
|
|
81
|
-
## 一个 URL 一个 session(`/{name}`)
|
|
82
|
-
|
|
83
|
-
地址栏里加一段就是**另一个 herdr**:
|
|
84
|
-
|
|
85
|
-
```
|
|
86
|
-
https://herdr.bysir.top/ 默认 session(老行为,敲的是 `herdr`)
|
|
87
|
-
https://herdr.bysir.top/work 敲 `herdr --session work` —— 没有这个 session 就新建一个
|
|
88
|
-
https://herdr.bysir.top/scratch 再来一个,和上面那个互不相干
|
|
73
|
+
herdr-web pair # print a fresh one-time pairing code + QR
|
|
74
|
+
herdr-web devices # list paired devices (label / last seen / last IP / expiry)
|
|
75
|
+
herdr-web revoke <id> # kick one (all = everything); the next request gets 401
|
|
76
|
+
herdr-web unlock # clear the global "too many failures" circuit breaker
|
|
89
77
|
```
|
|
90
78
|
|
|
91
|
-
|
|
92
|
-
workspace / tab / pane。所以书签存成 `/work` 和 `/scratch`,两个标签页就是两套工作现场,
|
|
93
|
-
关掉浏览器再回来还在(session 是持久的,网页断开只是客户端断开)。
|
|
79
|
+
The ⚙ at the right end of the top bar is the **settings panel**; its "Devices" page shows who has paired, lets you **sign this device out**, and kick others. Kicking one and kicking everyone both take two clicks. **The web UI never issues a pairing code** (not even to an already-paired device) — see [Security](#security) for why.
|
|
94
80
|
|
|
95
|
-
|
|
81
|
+
**Out of codes? Go back to the machine and run `herdr-web pair`.** That is not laziness either — see below.
|
|
96
82
|
|
|
97
|
-
-
|
|
98
|
-
(`~/.config/herdr/sessions/<name>/herdr.sock`,`herdr session list --json` 里就是这个),
|
|
99
|
-
所以页面上每个请求都带着 session 名。这是这个功能里唯一真正危险的地方 —— 拿默认 session 的
|
|
100
|
-
socket 去投一个 `/work` 页面上选的 pane,话会**静默进另一个 herdr**,而两边屏幕上都看不出
|
|
101
|
-
异常。服务端因此**不给不合法的名字兜底**(不退回默认 session,直接报错)。
|
|
102
|
-
- **名字只能是 `[A-Za-z0-9._-]`、首字符字母数字、最长 40。** 因为它要被拼进一条敲进登录
|
|
103
|
-
shell 的命令行,也要被拼进 socket 路径。不合法时页面上会直接说,而不是给你开一个别的 session。
|
|
104
|
-
- **`HERDR_WEB_ONCONNECT` 不参与带 session 的 URL**(包括设成空串「什么都别敲」那种):
|
|
105
|
-
地址栏里点名要哪个 session,比一个全局默认具体。`/` 还是老规矩。
|
|
106
|
-
- **顶栏左边会显示当前 session 名**(设置 →「终端」页底下也有一行,连着那个 session 的 socket
|
|
107
|
-
路径)。默认 session 不显示这个标签 —— 没有标签就是默认那个。
|
|
108
|
-
- 一个进程最多同时盯 16 个 session(每个都带一条 agent 状态订阅)。超了会说,重启清空。
|
|
109
|
-
- 「添加到主屏幕」存的是 manifest 里的 `start_url`(`/`),所以从主屏图标进的是默认 session。
|
|
110
|
-
要一个直达 `/work` 的图标,用浏览器书签。
|
|
111
|
-
- `herdr session list` / `stop` / `delete` 在终端里管这些 session,herdr-web 这边只负责「开/接上」。
|
|
83
|
+
The old never-expiring `~/.herdr-web/token` is demoted to **bootstrap only**: an old bookmark exchanges it for a device credential on first open and scrubs the token out of the URL, after which you should `rm ~/.herdr-web/token`. Details and reasoning in [SECURITY.md](SECURITY.md) (Chinese).
|
|
112
84
|
|
|
113
|
-
|
|
85
|
+
Once connected it **types `herdr` for you**. To type something else, or nothing: `HERDR_WEB_ONCONNECT` (set it to an empty string to stay in the shell). Adding a path segment to the URL (`/work`) gives you **a different herdr session** — see [First run](#first-run). The old "run herdr" button in the top bar is gone: with autotyping it earns its place less than once a day, and the soft key bar ships a preset for it if you want one.
|
|
114
86
|
|
|
115
|
-
|
|
87
|
+
**The admin page is at `http://127.0.0.1:<port+1>/`** (also in the startup banner): certificate status, one-click issue/renew, generated DNS `.env` snippets, pairing codes, device kicking. It is **bound to loopback and does not exist on the public internet**, so it needs no login — anything that can reach it already has your shell. Why not "an authenticated page on the main server": authentication is a control that can fail, unreachability is a property; and the admin page must not depend on the very certificate it exists to fix (a broken certificate would lock you out of the page that repairs it).
|
|
116
88
|
|
|
117
|
-
##
|
|
89
|
+
## First run
|
|
118
90
|
|
|
119
|
-
|
|
91
|
+
On startup it prints the addresses you can reach it at. When listening on `0.0.0.0` it scores the
|
|
92
|
+
interfaces and marks the one your phone can actually reach with `← use this one from your phone`;
|
|
93
|
+
that is the address encoded in the QR code.
|
|
120
94
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
| **拉回** | 把远端输入框里已有的内容抓进 textarea 编辑(远端按过 Tab 补全就用它) |
|
|
128
|
-
| **自动拉回** | 默认 500ms 一拍。切了 pane 自动换成新 pane 的内容;**本地有草稿时绝不覆盖**,只在状态行提示 |
|
|
129
|
-
| **双向** | 本地改动跟着推回远端输入框(不回车)。默认关,见下面的注意事项 |
|
|
130
|
-
| **图** | 传图片,路径插在**光标处**。手机上点它会给「相机 / 相册」;电脑上截完图直接在框里 `⌘V`,或者把文件拖进来。不开发件箱也能传 —— 软键条里配个 `act:img` 或者整页粘贴 |
|
|
131
|
-
| `↑` | 框空时取回上一条投过的(本地留 30 条) |
|
|
132
|
-
| `Esc` | **转发给终端**。Esc 在纯 textarea 里没意义,而 agent 那边到处要用它(`/usage` 之类浮层靠它退出);焦点不动,可以连按 |
|
|
95
|
+
**Pair each device once.** The startup banner carries a one-time pairing code (5 minutes, single use)
|
|
96
|
+
and its QR code — scan it from your phone and you are in, zero typing. After that your bookmark holds
|
|
97
|
+
no secret (the credential lives in an `HttpOnly` cookie), and changing Wi-Fi, changing subnets or
|
|
98
|
+
rebooting costs you nothing. Three ways to scan: your camera app (the code is a link with `?pair=`),
|
|
99
|
+
"scan with camera" inside the pairing page (only shown when it can work — it needs `BarcodeDetector`
|
|
100
|
+
and a camera, which requires a secure context), or typing the 8-digit code into the pairing page.
|
|
133
101
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
粘贴走的是挂在 window 上的**捕获阶段**监听:得抢在 xterm 那个隐藏 textarea 之前,否则剪贴板里只有图片时它会往终端里粘一段空文本。落在发件箱 textarea 里的粘贴放过去,交给它自己处理(那儿能插在光标处)。
|
|
141
|
-
|
|
142
|
-
手机照片先在浏览器里缩到长边 2400 再传(顺手把 iPhone 的 HEIC 转成 PNG/JPEG,因为 agent 读不了 HEIC)。服务端按**魔数**认类型,只收 png / jpg / gif / webp,改后缀或改 content-type 骗不过去。上限 25 MB。传过的图不会自动删,攒多了自己清 `~/.herdr-web/uploads/`。
|
|
143
|
-
|
|
144
|
-
状态行会一直显示「这段话现在会投给谁」,`⟳` 表示正在跟随焦点;鼠标悬停能看到当前用的轮询间隔。
|
|
145
|
-
|
|
146
|
-
### 是轮询,不是推送
|
|
147
|
-
|
|
148
|
-
herdr 有 `events.subscribe` 推送通道,但 agent 一 working 就是刷屏级的量,所以这里用轮询:每 `HERDR_WEB_POLL_MS`(默认 500ms)问一次「焦点在哪个 pane + 那个输入框里是什么」。
|
|
149
|
-
|
|
150
|
-
**切 pane 到 textarea 更新的实测延迟**(同机,8 次取样):
|
|
151
|
-
|
|
152
|
-
| 轮询间隔 | 最快 | 中位 | 最慢 |
|
|
153
|
-
|---|---|---|---|
|
|
154
|
-
| 200ms | 138ms | 318ms | 550ms |
|
|
155
|
-
| **500ms(默认)** | ~300ms | ~500ms | ~800ms |
|
|
156
|
-
| 1200ms | 408ms | 794ms | 818ms |
|
|
157
|
-
|
|
158
|
-
地板是一次 sync 的耗时,因为 herdr 的每次调用都可能撞上一个 ~100ms 的 tick(原因见 [HERDR-API.md](HERDR-API.md) 的「100ms 的坎」)。想临时试手感:URL 上加 `?poll=200&push=400`,会覆盖服务端下发的默认值。
|
|
159
|
-
|
|
160
|
-
几个要知道的:
|
|
102
|
+
```bash
|
|
103
|
+
herdr-web pair # print a fresh one-time pairing code + QR
|
|
104
|
+
herdr-web devices # list paired devices (label / last seen / last IP / expiry)
|
|
105
|
+
herdr-web revoke <id> # kick one (all = everything); the next request gets 401
|
|
106
|
+
herdr-web unlock # clear the global "too many failures" circuit breaker
|
|
107
|
+
```
|
|
161
108
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- **远端正开着选择框 / 确认框时会拒绝投递**(清不空就不投,否则就是「残留 + 新文本」一起回车)。去那个 pane 按 `Esc` 收掉再投。
|
|
165
|
-
- **agent pane 上认不出输入框时也不投**(屏幕上正开着分页器 / 编辑器 / 某个全屏控件)。这时候「拉回」也不会往框里塞东西 —— 认不出就是认不出,不会退回屏幕最后一行。shell pane 天生读不到输入行,那边不受影响,投稿照常。
|
|
166
|
-
- socket 在**跑 herdr server 的那台机器**上。现在只连本机(或 `HERDR_WEB_SOCKET` 指到的路径)。
|
|
109
|
+
**The web UI never issues a pairing code** (not even to an already-paired device) — see
|
|
110
|
+
[Security](#security). Out of codes? Go back to the machine and run `herdr-web pair`.
|
|
167
111
|
|
|
168
|
-
|
|
112
|
+
Once connected it **types `herdr` for you**. To type something else, or nothing:
|
|
113
|
+
`HERDR_WEB_ONCONNECT` (an empty string means stay in the shell).
|
|
169
114
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
115
|
+
**A path segment in the URL is a different herdr session**: `/work` types
|
|
116
|
+
`herdr --session work` and creates it if needed; `/scratch` is another one. Two bookmarks are two
|
|
117
|
+
working contexts that survive closing the browser. Names are `[A-Za-z0-9._-]`, 40 characters max;
|
|
118
|
+
an invalid one is an error rather than a silent fallback to the default session — using the wrong
|
|
119
|
+
socket would **silently deliver your words into another herdr**.
|
|
173
120
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
38 个 tab / 4 个 workspace,一趟是 workspace → tab → pane → zoom 四段盲走。
|
|
121
|
+
**The admin page is at `http://127.0.0.1:<port+1>/`**: certificate status, one-click issue/renew,
|
|
122
|
+
generated DNS `.env` snippets, pairing codes, device kicking. It is bound to loopback and does not
|
|
123
|
+
exist on the public internet, so it needs no login — anything that can reach it already has your shell.
|
|
178
124
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
125
|
+
**Local shell only.** To reach another machine, ssh from inside herdr — herdr does that itself, so
|
|
126
|
+
this layer implements no host management and no key storage, and the "the browser can touch your
|
|
127
|
+
private keys" attack surface never exists.
|
|
182
128
|
|
|
183
|
-
|
|
184
|
-
herdr 那边一切焦点,画面自己就跟过来了;键盘那套操作一个字都没变,Mac 上照旧敲键盘。这条是
|
|
185
|
-
刻意的:手机端「只接管一个 pane + 做一套图形界面管面板」的做法能做得更花,代价是**两套使用
|
|
186
|
-
习惯**和第二个真相源。所以这个面板只做「去哪儿」,不做增删改。
|
|
129
|
+
## What you get
|
|
187
130
|
|
|
188
|
-
###
|
|
131
|
+
### Outbox (voice compose)
|
|
189
132
|
|
|
190
|
-
|
|
133
|
+
The strip with a textarea at the bottom of the page is the outbox; the ✎ in the top bar toggles it
|
|
134
|
+
and it is **on by default**. You dictate or type in there, fix what came out wrong, then hand the
|
|
135
|
+
whole paragraph to one of herdr's panes.
|
|
191
136
|
|
|
192
|
-
|
|
|
137
|
+
| Control | What it does |
|
|
193
138
|
|---|---|
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
答案,交给「刚动过」去定),实际用起来不对:黄点那个正在跑的会被十几个闲着的埋掉,而列表里
|
|
203
|
-
最想一眼看到的恰恰是它。`等你` / `完成` 两档在行上额外挂一个小标签,别的状态只有那个点的
|
|
204
|
-
颜色 —— 每行都塞标签就没有重点了。
|
|
139
|
+
| **Target** | Defaults to "follow herdr's current pane" — nothing to pick, it goes to whatever you have focused in herdr. You can also pin one from the dropdown |
|
|
140
|
+
| **Post** `⌘↵` / `Ctrl↵` | Clears the remote input line first, then submits the whole thing. `Enter` inserts a newline and does not submit |
|
|
141
|
+
| **Pull back** | Grabs what is already in the remote input line into the textarea for editing (useful when the remote side has been Tab-completing) |
|
|
142
|
+
| **Auto pull** | Every 500ms by default. Switching panes swaps in the new pane's content; **never overwrites a local draft**, it just says so in the status line |
|
|
143
|
+
| **Two-way** | Local edits get pushed back into the remote input line (without Enter). Off by default — see the caveats below |
|
|
144
|
+
| **Image** | Upload an image; the path is inserted **at the cursor**. On a phone it offers camera / library; on a desktop just `⌘V` a screenshot into the box, or drop a file. You do not need the outbox open for this — bind `act:img` on the soft key bar, or paste anywhere on the page |
|
|
145
|
+
| `↑` | With an empty box, recalls the last thing you posted (30 kept locally) |
|
|
146
|
+
| `Esc` | **Forwarded to the terminal.** Esc means nothing inside a plain textarea, while the agent needs it constantly (overlays like `/usage` close with it); focus does not move, so you can press it repeatedly |
|
|
205
147
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
148
|
+
Uploading does not need the outbox open: bind `act:img` on the soft key bar, or **paste anywhere on
|
|
149
|
+
the page** (an image in the clipboard is uploaded directly). Where the path lands depends on whether
|
|
150
|
+
the outbox is open — appended to your draft, or typed straight into the terminal.
|
|
209
151
|
|
|
210
|
-
|
|
152
|
+
→ Why a separate box at all, how images actually work, the two-way caveats, measured polling
|
|
153
|
+
latency: [OUTBOX.md](OUTBOX.md)
|
|
211
154
|
|
|
212
|
-
|
|
213
|
-
`pane.agent_status_changed`,收到就记 `time.Now()`)。所以:
|
|
155
|
+
### Soft key bar
|
|
214
156
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
(升级、改配置)不丢时间。不能按 `pane_id` 存:那是 herdr 里的位置编号,pane 一开一关就
|
|
219
|
-
重新分配给别人了,会张冠李戴。herdr 重启之后终端 id 全是新的,旧记录自然对不上 —— 存盘时
|
|
220
|
-
只写这会儿还在的终端,文件自己就不会长胖。
|
|
221
|
-
- **状态不存盘**,只存时间。存了状态的话,重启后拿旧状态一比就会把「停机期间变的」记成
|
|
222
|
-
「刚刚变的」。
|
|
223
|
-
- 订阅没连上(herdr server 没在跑)时,列表底下会说一句,免得空着的时间列看着像坏了。
|
|
224
|
-
- 显示用 `3m` `2h` `4d` 这种紧凑写法(不到 45 秒算「刚刚」),完整时间在 title 里。手机上这一列
|
|
225
|
-
只有几十像素,「3 分钟前」四个字放不下。
|
|
157
|
+
Phones have no Ctrl key, and herdr's `ctrl+b` prefix depends on one. The keys live **on the server**
|
|
158
|
+
(`~/.herdr-web/softkeys.json`), so phone / tablet / desktop share one set of definitions, edited in
|
|
159
|
+
Settings → Soft keys.
|
|
226
160
|
|
|
227
|
-
|
|
161
|
+
- The "Keys" field takes a **key spec**; space-separated entries fire in sequence — `ctrl+b c` is the
|
|
162
|
+
prefix plus c, one tap.
|
|
163
|
+
- Supports `ctrl+x` `alt+x` `shift+tab`, named keys (`esc tab enter space bs del ins up down left
|
|
164
|
+
right home end pgup pgdn f1-f12`) and literal text (`text:/new`; quote it if it has spaces).
|
|
165
|
+
- `sticky:ctrl` / `sticky:alt` are **sticky** modifiers: tap once to light it up, then a letter sends
|
|
166
|
+
the combination.
|
|
167
|
+
- `act:` actions run in the browser and send no bytes: `act:kbd` (system keyboard), `act:img`
|
|
168
|
+
(upload), `act:panes` (pane list), `act:files` (file browsing), `act:clip` / `act:paste`
|
|
169
|
+
([copy and paste on a phone](MOBILE.md#手机上怎么复制--粘贴)).
|
|
170
|
+
- Every key has a **"double-tap"** checkbox; close pane / close tab / `/clear` ship with it on —
|
|
171
|
+
keys sit close together and a misfire cannot be undone.
|
|
172
|
+
- "Load presets" pours sixty-odd keys into "My keys", after which every one of them is yours to edit.
|
|
228
173
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
- 第二行给的是 **agent 自己写的会话标题**(Claude Code 那个「图片识别」之类),没有才退回
|
|
232
|
-
cwd —— shell pane 的标题只是 `user@host:path`,不如路径。
|
|
233
|
-
- **跳完不自动聚焦终端**(手机上那一下会把系统键盘顶出来,而刚跳过去多半是要看);宽屏上顺手聚上。
|
|
234
|
-
- 投稿目标不用管:默认那条「跟随 herdr 当前 pane」自己就跟过去了。本地有草稿时目标仍然锁在
|
|
235
|
-
原来那个 pane 上 —— 为 A 写的话不该因为你去 B 看了一眼就投给 B。
|
|
236
|
-
- 「全屏」开关记在本地(`localStorage`)。手机上默认开:多 pane 平铺读不了,去了不放大等于没去。
|
|
237
|
-
- `zoomed` 是**整个 tab** 的状态,不是某个 pane 的(herdr 放大的永远是当前焦点 pane)。
|
|
238
|
-
所以「这个 tab 只有一个 pane」会回 `zoomed:false`,那不是失败,界面上单独说一句。
|
|
174
|
+
Key specs are parsed into bytes **on the server**, so a typo is reported at save time — telling you
|
|
175
|
+
which key and where it stopped making sense — rather than shipped as a key that does nothing.
|
|
239
176
|
|
|
240
|
-
|
|
177
|
+
### Pane list · notices
|
|
241
178
|
|
|
242
|
-
|
|
179
|
+
The ▦ in the top bar (or `act:panes` on the soft key bar; on a phone you can also tap herdr's own
|
|
180
|
+
`switch`) opens a list of panes, one per row — **tap one and you are there, zoomed full screen**.
|
|
181
|
+
You can filter (tab / title / path / pane id) and show only panes running an agent. The list
|
|
182
|
+
refreshes itself every 4 seconds.
|
|
243
183
|
|
|
244
|
-
|
|
184
|
+
When an agent stops to wait for you (or has just finished), **a card appears in the top right
|
|
185
|
+
carrying what it said**, and a badge lights up on the ▦. Tapping the card jumps there. Opening the
|
|
186
|
+
pane list is what marks them read.
|
|
245
187
|
|
|
246
|
-
|
|
188
|
+
It is an index, not a second interface: after the tap you are looking at the same herdr terminal, and
|
|
189
|
+
every keyboard habit is unchanged.
|
|
247
190
|
|
|
248
|
-
|
|
191
|
+
→ Sort order, the "3 minutes ago" column, when a notice fires, how the badge counts, system
|
|
192
|
+
notifications: [MOBILE.md](MOBILE.md)
|
|
193
|
+
How that text is scraped off the screen: [COMPOSER.md](COMPOSER.md)
|
|
249
194
|
|
|
250
|
-
|
|
195
|
+
### File browsing
|
|
251
196
|
|
|
252
|
-
|
|
197
|
+
The agent says "the plot is at `/tmp/plot-3.png`" — **tap that path and look at it**. Absolute paths
|
|
198
|
+
open directly; `./out/a.png` resolves against that pane's cwd. The 📁 in the top bar (or `act:files`)
|
|
199
|
+
is the fallback: it starts from every pane's cwd + the upload directory + home + temp, `..` walks all
|
|
200
|
+
the way to `/`, and you can paste an absolute path to open it.
|
|
253
201
|
|
|
254
|
-
|
|
202
|
+
Images (png / jpg / gif / webp, identified by magic number) are shown, text is shown as-is, anything
|
|
203
|
+
else downloads. From the viewer you can **hand the file to the agent** in one tap (its absolute path
|
|
204
|
+
goes into the outbox).
|
|
255
205
|
|
|
256
|
-
|
|
257
|
-
|
|
206
|
+
**There is no boundary by default** — anyone who can open this page already has a login shell, so an
|
|
207
|
+
allowlist would not stop them and would only get in the way daily. If you want one, set
|
|
208
|
+
`HERDR_WEB_FILE_ROOTS` (that is a real jail); to remove the feature, `HERDR_WEB_FILES=0`.
|
|
258
209
|
|
|
259
|
-
|
|
210
|
+
→ The short-lived link route and the four hard rules on it (never `text/html`, why SVG is safe to
|
|
211
|
+
render): [SECURITY.md](SECURITY.md)
|
|
260
212
|
|
|
261
|
-
|
|
262
|
-
- 改一处定义,条上所有引用一起变;
|
|
263
|
-
- ✕ 只是去掉一个引用,定义还在库里,随时再拖上去。删定义会把条上的引用一起清掉(顺手 toast 说清了几处 —— 不然就是「保存完少了个键」)。
|
|
213
|
+
### Phones and tablets
|
|
264
214
|
|
|
265
|
-
|
|
215
|
+
When a program has mouse reporting on (herdr does), touch gestures are taken over entirely:
|
|
266
216
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
拖动是**按住 250ms 才算拿起**(触屏;鼠标走 6px 就算拖)。这一页要能上下滚,而键本身就是拖动的把手 —— 手指落在键上往下划,是滚页面还是拖这个键,只能靠「有没有按住」区分。给键写死 `touch-action: none` 页面就滚不动了(键铺满整页),`pan-y` 又会把「往下拖到第二行」吃成滚动。拿起来之后在 `touchmove` 上 `preventDefault` 挡住滚动 —— 手指在按住期间没动过,浏览器还没开始滚,这时候拦得住。
|
|
270
|
-
|
|
271
|
-
- 「按键」一栏写**按键谱**,空格分隔可以连发多下 —— `ctrl+b c` 就是 herdr 的前缀加 c,一下点出来。
|
|
272
|
-
- 支持 `ctrl+x` `alt+x` `shift+tab`、具名键(`esc tab enter space bs del ins up down left right home end pgup pgdn f1-f12`)、原样文本。
|
|
273
|
-
- 原样文本两种写法等价:`"herdr" enter` 和 `text:/new enter`(`text:` 是给平板手输准备的 —— 编辑器里本来就有 `sticky:` / `act:` 前缀,找引号反而麻烦;带空格的仍要引号:`text:"git status"`)。
|
|
274
|
-
- 预设分 8 组(前缀 / 标签 / Pane / 工作区 / 终端按键 / 文本 / Claude 命令 / 网页端动作),「载入预设」一下全进「我的按键」。herdr 那几组抄的是 `herdr --default-config` 的 `[keys]` 默认值,改过 keybinding 的人自己改;「Claude 命令」是 `/new` `/clear` `/compact` `/usage` `/context` `/model` `/resume` `/cost`,都带回车,一下点完。
|
|
275
|
-
- 每个键有个**「两下」**勾选框:勾上的键要点两次才真发出去 —— 第一下只是举起来(键变红,文字不变,免得按键变宽把手指底下的键挪走),3 秒不点、或者点了别的键就放下。软键条上键挨得近,关 pane / 关标签这种误触没法撤销。预设里 `关 pane` `关标签` `关工作区` `断开` `/clear` 默认就带。
|
|
276
|
-
- `Ctrl` / `Alt` 是**粘滞**的:点一下亮起,再敲一个字母就发出对应组合键,然后自动灭掉。手机虚拟键盘的 keydown 不可靠,所以这层是在数据流上做的,不依赖按键事件。写法是 `sticky:ctrl` / `sticky:alt`。
|
|
277
|
-
- `act:` 是**网页端自己处理**的动作,不发任何字节:`act:kbd` 呼出 / 收起系统键盘,`act:img` 传图(弹相机 / 相册,路径按「发件箱开没开」决定去草稿还是直接敲进终端),`act:panes` 开「面板一览」(上面那节),`act:clip` 把机器上的剪贴板取到手机剪贴板,`act:paste` 把手机剪贴板粘进终端(后两个见「[手机上怎么复制 / 粘贴](#手机上怎么复制--粘贴)」——**手机上这两条只能是点出来的**,浏览器不给定时器碰剪贴板)。服务端只认白名单里这几个,写错了保存时就报错,不会下发一个点了没反应的键。
|
|
278
|
-
`act:panes` 放在软键条上是有讲究的:手机上键盘一弹起来顶栏整段就收掉了,那时候顶栏那个入口点不到,而软键条正好在拇指底下。
|
|
279
|
-
- 按键谱在**服务端**解析成字节再下发,前端只管照发;写错了保存时会告诉你是第几个按键、哪里不认。回包里 `send` 是**解析好的字节**、`spec` 是你写的谱 —— 编辑器回传时两个字段都在,服务端**认 `spec`**。拿 `send` 当谱重解一次的话,Tab 的 `"\t"` 去掉空白就是空串,报「按键谱是空的」而用户什么都没改(踩过)。
|
|
280
|
-
|
|
281
|
-
## 手机
|
|
282
|
-
|
|
283
|
-
xterm.js 的触屏支持基本只有「点一下聚焦隐藏 textarea」,剩下全靠自己补。有程序在收鼠标上报时(herdr 这种),触屏手势整个由本项目接管:
|
|
284
|
-
|
|
285
|
-
| 手势 | 行为 |
|
|
217
|
+
| Gesture | Behaviour |
|
|
286
218
|
|---|---|
|
|
287
|
-
|
|
|
288
|
-
|
|
|
289
|
-
|
|
|
290
|
-
| 双击 | 显示 / 收起系统键盘 |
|
|
291
|
-
|
|
292
|
-
抓取**只认长按**。曾经试过「手指落在框线附近就立刻抓」,翻车了:agent 自己画的框(Claude Code 每个 pane 一个圆角框)竖边同样贯穿整屏,从字符层面和 herdr 的 pane 边框分不出来,于是在框边上一划就变成往 agent 里拖鼠标 —— 手指想滚屏,屏幕上却在选文字。**滑动永远是滑动**,换挡要先按住。
|
|
293
|
-
|
|
294
|
-
按住之后按**像素吸附**到附近的贯穿线(`SNAP_PX = 24`,约一根手指的落点误差),不是按格数。这条也是踩出来的:原来只允许差一格,而平板上 211 列宽的屏幕一格才 ~6px,手指偏十几个 px 就把 press 落进 pane 里,agent 收到一次拖动、屏幕上什么也没发生 —— 表现就是「手机上根本拖不动 pane」。
|
|
295
|
-
|
|
296
|
-
只认**贯穿的长线**(框线字符占了这一列 / 这一行 70% 以上、至少 6 格),agent 画的短横线(消息分隔、「2 new messages」那种)都挡在外面。挡不住的是 agent 的外框竖边,但既然只是「按下点挪最多 24px」,猜错了也就是这一次拖动落在框边上。代价:2×2 布局里那条横向分界只占半屏宽,吸不到,得按准一点。
|
|
297
|
-
|
|
298
|
-
长按期间允许手指飘 16px(`HOLD_SLOP`)。原来是 8px,太苛刻 —— 按住不动的手指本来就会飘十几个 px,一飘长按就被撤销,表现是「长按没反应」。没开鼠标上报的普通 shell 下不抓取(那儿拖一下没有意义),长按仍然是「什么都不做」。
|
|
299
|
-
|
|
300
|
-
端到端验过:在一个独立的 herdr session 里竖分屏,长按分界线**右边 3 格**再拖,分界从第 45/46 列移到 40/41 列;正好在贯穿竖线上竖划,发出去的仍然只有滚轮上报。
|
|
301
|
-
|
|
302
|
-
### 手机上怎么复制 / 粘贴
|
|
303
|
-
|
|
304
|
-
先说结论:手机上要**两个软键**(设置 →「软键条」→ 载入预设,「网页端动作」组里的
|
|
305
|
-
**📋 取** 和 **📥 粘**,拖到条上)。原因是下面两条,一条比一条反直觉。
|
|
306
|
-
|
|
307
|
-
**第一条:herdr 复制到的是「跑 herdr 那台机器」的剪贴板,不是手机的。**
|
|
308
|
-
|
|
309
|
-
手机上长按拖选(那一下会被翻成鼠标拖动发给 herdr,配上 herdr 自己的 `copy_on_select` 就是
|
|
310
|
-
一次复制),herdr 弹「copied 84 chars to clipboard」——**那 84 个字进的是 Mac 的剪贴板**
|
|
311
|
-
(`pbpaste` 读出来一字不差),浏览器一无所知,手机上哪儿都粘不出来。看着像「复制失败」,
|
|
312
|
-
其实是复制成功了、只是落在另一台设备上。
|
|
313
|
-
|
|
314
|
-
所以有了 **📋 取**(`act:clip`):点一下,服务端读机器上的剪贴板(`pbpaste` /
|
|
315
|
-
`wl-paste` / `xclip`,见 `internal/clip`)交给网页,网页写进手机剪贴板。之后在手机上随便哪儿
|
|
316
|
-
长按粘贴都是它。
|
|
317
|
-
|
|
318
|
-
反方向是 **📥 粘**(`act:paste`):点一下读手机剪贴板,按**括号粘贴**送进终端(多行不会被
|
|
319
|
-
当成一行一次回车)。触屏上没法 `⌘V`,也没法长按呼出终端的粘贴菜单(单指手势被接管了),
|
|
320
|
-
这个键是唯一的入口。
|
|
321
|
-
|
|
322
|
-
**为什么不能做成一个「自动同步」**:浏览器只在**用户手势**里给读写剪贴板,定时器里偷偷做
|
|
323
|
-
一律被拒 —— 而且是静默的。所以两个方向各要一次点击,这一下是浏览器的硬要求,不是没做。
|
|
324
|
-
|
|
325
|
-
触屏上**选不了字**(单指手势整个被上面那套接管了),所以另一条复制路径是 **herdr 自己的
|
|
326
|
-
COPY 模式**:`ctrl+b` 前缀进去,`hjkl` 选、`y` 复制。它走 **OSC 52** —— 终端里的程序把文本推给
|
|
327
|
-
网页,网页再写系统剪贴板。
|
|
328
|
-
|
|
329
|
-
**但浏览器不一定让写,而且以前是静默失败的。** 两条限制叠在一起:`navigator.clipboard` 只在
|
|
330
|
-
安全上下文里存在(局域网 http 上压根没这个对象),而且手机浏览器要求这一次写发生在**用户
|
|
331
|
-
手势**里。COPY 模式和「选中即复制」的触发点都不是点击,于是在手机上被拒 —— 屏幕上选区好好的、
|
|
332
|
-
一句提示都没有,剪贴板里还是上一次的东西。
|
|
333
|
-
|
|
334
|
-
现在写不进去就在底部出一条**「点一下复制」**:那一下点击本身就是手势,按一下就进剪贴板。
|
|
335
|
-
连 `execCommand` 也被拒的话,它把文本摊在一个**已经全选好**的框里,长按 → 「拷贝」。
|
|
336
|
-
|
|
337
|
-
还有一个**桌面上也会踩**的坑:**标签页不可见时 Chrome 让 `writeText` 那个 promise 永远挂着**
|
|
338
|
-
(实测 26 秒既不 resolve 也不 reject,剪贴板也确实没变)。光 `await` 的话「写不进去」永远发现
|
|
339
|
-
不了,所以那一步有 1.2 秒上限,超时就当失败、往后面两条路走。
|
|
340
|
-
|
|
341
|
-
两条相关的:
|
|
342
|
-
|
|
343
|
-
- **「选中即复制」是鼠标那一档的设置**,触屏上没有选区,手机上开它不起作用。
|
|
344
|
-
- 想在电脑上看那条提示长什么样:URL 上加 **`?nocopy=1`**,两条写剪贴板的路都当成失败
|
|
345
|
-
(和 `?poll=` / `?push=` 一样是调试参数)。
|
|
346
|
-
|
|
347
|
-
为什么要这么绕:xterm.js 只把 `wheel` 翻译成鼠标上报、完全不管 touch,所以 herdr 这种「占着备用屏幕(本地没 scrollback 可滚)+ 开了鼠标上报」的程序在手机上两头都不响应,彻底滚不动。而点击和长按又都会落到隐藏 textarea 上,浏览器顺手就把键盘顶出来 —— 在 TUI 里十次里有九次只是想点个 pane,不是想打字。
|
|
348
|
-
|
|
349
|
-
做法是在 `touchstart` 上**无条件** `preventDefault`(单指手势),一次性掐掉聚焦、长按气泡、双击缩放和浏览器补发的兼容鼠标事件,然后自己在 `touchend` 里按位移和时长把手势分成上面几类。
|
|
350
|
-
|
|
351
|
-
**为什么连「没有鼠标上报」的时候也要吃掉**:不吃的话浏览器会补发兼容鼠标事件,xterm 当成「按下 + 拖选」——手指一滑变成选中文字、终端一动不动(还没 attach herdr、或者 pane 里跑着不收鼠标的程序时必现)。触屏上想滚屏远比想选字常见,所以这里选滚屏。代价:触屏不再能拖选(要复制用桌面鼠标,或者 herdr 自己的 COPY 模式),点一下也不再由浏览器顺手聚焦 textarea,改成自己在 `touchend` 里 focus。
|
|
352
|
-
|
|
353
|
-
**手势时长用事件自带的时间戳**(`e.timeStamp`),不是处理函数里的 `Date.now()`。终端忙着重绘时定时器和事件派发都会被推后,实测一次 60ms 的点击在处理函数里量出来是 994ms,于是被「超过 500ms 算长按,什么都不做」挡掉 —— 表现就是「输出一多点哪儿都没反应」。事件时间戳是事件产生时打的,不受处理延迟影响。
|
|
354
|
-
|
|
355
|
-
**换 pane 不靠盲敲**:顶栏 ▦ 或软键条上的 `act:panes` 开「面板一览」,点一行直接跳过去并铺满
|
|
356
|
-
(见上面那节)。按键那条通道只能表达「下一个 tab」这种相对导航,而中间每一步的屏幕正好都是
|
|
357
|
-
手机上读不了的那个平铺状态。
|
|
358
|
-
|
|
359
|
-
连上时也不自动抢焦点(触屏设备),否则一连上键盘就顶出来。要打字:双击终端,或点软键条最左边的 ⌨。键盘状态跟着 textarea 的 focus/blur 走,所以用户自己收起键盘时按钮高亮也会跟着灭。
|
|
360
|
-
|
|
361
|
-
**顶栏在手机上收成一行**:状态只留那个彩点(完整文字进 `title`)、连上之后不显示「连接」、字号 `A−/A+` 和明暗 `◐` 挪进设置 →「终端」页。七个图标在 393px 上排不下,折成两行就白吃掉 ~36px(约三行终端),而这三个都是一次调完的东西。
|
|
362
|
-
|
|
363
|
-
**键盘一弹起来,顶栏整条收掉**,只留 8px 的一条缝(点一下放回来)。那一刻可见高度只剩 ~430px,而顶栏里的东西那时候一个都用不上 —— 正在打字的人要的是软键条和发件箱,「连接」是连之前的事。收起是临时的:手动点开只管这一次,键盘一收就自动恢复常态,不留状态(否则下次打字时顶栏在不在全靠碰)。
|
|
219
|
+
| One-finger vertical swipe | Converted to SGR wheel reports by line height — `CSI < 64/65 ; col ; row M` — and sent to the program; with mouse reporting off it scrolls the local scrollback |
|
|
220
|
+
| Tap | With mouse reporting, sends `CSI < 0 ; col ; row M/m` (clicking panes and tabs both work) and **does not pop the system keyboard**; without it, focuses the hidden textarea (a tap there does mean "I want to type"). It goes out immediately, with no delay |
|
|
221
|
+
| Long press (≈380ms) | **Grab**: press the left button and hold, plus `CSI < 32` motion reports, so moving afterwards is a drag — this is how you resize herdr's pane borders on a phone. Releasing sends the matching `m` |
|
|
364
222
|
|
|
365
|
-
|
|
223
|
+
**There is no double tap.** It used to be the "show / hide the system keyboard" gesture, and it is gone — one gesture was costing the feel of every other tap. To tell "this is a tap" from "this is the first half of a double tap", every tap has to sit out the double-tap window (320ms) before it can be sent, so clicking panes and clicking things inside Claude all lag a beat; and without the wait, that first tap **leaks into the program in the pane** — Claude Code has its own clickable UI (expanding a block, **picking an option**), and a leaked tap picks the option for you. Paying "every click is imprecise and might answer for you" for one keyboard shortcut is not worth it.
|
|
366
224
|
|
|
367
|
-
|
|
225
|
+
The keyboard now comes from **buttons** only: the ⌨ soft key (`act:kbd` — the first key in the factory set, and the soft key bar is on by default on phones) and the "system keyboard" button in the top bar (drag it on under Settings → Top bar). A button press means you want the keyboard — nothing to guess, and no delay.
|
|
368
226
|
|
|
369
|
-
|
|
227
|
+
**The outbox and the soft key bar are one dock**: drag either side edge to change its width (when an
|
|
228
|
+
IME covers half the screen, shrink the whole dock into what is left), and the three handles on the
|
|
229
|
+
top edge of the key area set the height and the boundaries; double-tap any handle to reset. A phone
|
|
230
|
+
in portrait (< 440px) switches to another tier: no handles, full width, one horizontally-scrolling
|
|
231
|
+
row of keys. **Landscape and portrait keep separate sets**, swapped on rotation.
|
|
370
232
|
|
|
371
|
-
|
|
233
|
+
**The top bar is yours to arrange**, and **layouts are stored per kind of device**: the six keys you
|
|
234
|
+
arranged on a phone do not follow you to the desktop, while the definitions stay shared.
|
|
372
235
|
|
|
373
|
-
|
|
236
|
+
→ Why the gestures are split this way, how the keyboard is handled, copy and paste on a phone, the
|
|
237
|
+
details of the dock and the top bar: [MOBILE.md](MOBILE.md)
|
|
374
238
|
|
|
375
|
-
|
|
239
|
+
### Settings panel
|
|
376
240
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
241
|
+
The ⚙ at the right end of the top bar, in four pages: **Terminal** (font size / light-dark, kitty
|
|
242
|
+
protocol / Option as Meta / copy on select / synchronized output, herdr's switch opening our pane
|
|
243
|
+
list, the badge on the panel icon), **Top bar**, **Soft keys**, **Devices**. Above the tabs there is
|
|
244
|
+
one more row: which layout profile this device uses. The three overlays (pane list / files /
|
|
245
|
+
settings) are mutually exclusive.
|
|
382
246
|
|
|
383
|
-
|
|
247
|
+
### Keyboard
|
|
384
248
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
- 宽度一过线(转横屏、平板、桌面)把手和存着的那份尺寸自己就回来了,两档互不影响。
|
|
249
|
+
herdr's shortcuts are almost all `ctrl+b` plus an ordinary key, which legacy encoding can express.
|
|
250
|
+
The kitty protocol covers what legacy cannot and is on by default (Settings → Terminal):
|
|
251
|
+
`Ctrl+Shift+letter`, `Ctrl+digit`, `Ctrl+Enter` / `Shift+Enter` / `Ctrl+Tab`.
|
|
389
252
|
|
|
390
|
-
|
|
253
|
+
Keys the browser keeps for itself: on macOS `⌘W` `⌘T` `⌘N` `Ctrl+Tab`; on Windows/Linux also
|
|
254
|
+
`Ctrl+W` `Ctrl+T` `Ctrl+N` `Ctrl+Shift+I/J/C`. Installing as a PWA gets some of them back.
|
|
391
255
|
|
|
392
|
-
|
|
256
|
+
Copy `⌘C` (or `Ctrl+Shift+C`) · paste `⌘V` · clear `⌘K` · `Option` is Meta by default. Copy and
|
|
257
|
+
paste on a phone is a different story — herdr copies to the clipboard of **the machine running
|
|
258
|
+
herdr** — see [MOBILE.md](MOBILE.md#手机上怎么复制--粘贴).
|
|
393
259
|
|
|
394
|
-
|
|
260
|
+
## Configuration
|
|
395
261
|
|
|
396
|
-
|
|
262
|
+
**Environment variables are the only source of configuration.** There is no config file, and the only flag is `--web` (point at a frontend directory during development). It is funnelled through [viper](https://github.com/spf13/viper) in `internal/config/` (`SetEnvPrefix("HERDR_WEB")` + `AutomaticEnv()`), so settings and variable names map one to one.
|
|
397
263
|
|
|
398
|
-
|
|
264
|
+
Not reading a config file is deliberate: there is a login shell behind this port, so "which configuration is actually in effect" has to be visible at a glance — environment variables are right there in `ps`, in the systemd unit, in the launchd plist. Add "there might also be a yaml in some directory" and the first half day of any incident goes into finding out which one won. Same reason there is no "flags override environment": one setting with two entry points means having to specify precedence.
|
|
399
265
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
| 序列 | 用途 | 状态 |
|
|
403
|
-
|---|---|---|
|
|
404
|
-
| `CSI ? 1049 h` | 备用屏幕 | xterm.js 原生 |
|
|
405
|
-
| `CSI ? 1000/1002/1003 h` + `1006` | 鼠标点击/拖拽/移动 + SGR 坐标 | xterm.js 原生 |
|
|
406
|
-
| `CSI ? 2004 h` | 括号粘贴 | xterm.js 原生 |
|
|
407
|
-
| `CSI ? 1004 h` | 焦点进出上报 | xterm.js 原生 |
|
|
408
|
-
| `CSI ? 2026 h` | 同步输出(防画面撕裂) | xterm.js 原生,另加了重绘看门狗(见下) |
|
|
409
|
-
| `OSC 8` | 终端超链接 | xterm.js 原生,点击在新标签页打开 |
|
|
410
|
-
| `OSC 52` | 程序写系统剪贴板 | ClipboardAddon |
|
|
411
|
-
| `OSC 10;? / 11;?` | 查询前景/背景色(判断明暗) | xterm.js 不回,**本项目自己回** |
|
|
412
|
-
| `CSI ? 2031 h` | 主题变更通知 | xterm.js 不支持,**本项目自己发** `CSI ? 997 ; 1/2 n` |
|
|
413
|
-
| `CSI > 7 u` | kitty 键盘协议 | xterm.js 不支持,**本项目补了消歧子集** |
|
|
414
|
-
|
|
415
|
-
那几个开关在顶栏最右的 ⚙ →「终端」页里。**「程序请求的终端能力」那张列表去掉了**:它是当初补协议时的调试视图,日常没人看(能力本身还照样记着,`DEC 2031` 的主题通知要用)。
|
|
416
|
-
|
|
417
|
-
## 键盘
|
|
418
|
-
|
|
419
|
-
herdr 的快捷键基本都是 `ctrl+b` 前缀加一个普通键,legacy 编码就能表达,所以不依赖 kitty 协议。kitty 协议补的是 legacy 表达不了的组合,默认开着(设置 →「终端」里可关):`Ctrl+Shift+字母` → `CSI 编码;6u`、`Ctrl+数字` → `CSI 编码;5u`、`Ctrl+Enter` / `Shift+Enter` / `Ctrl+Tab`。
|
|
420
|
-
|
|
421
|
-
**每个 herdr session 有自己的 socket**:默认 session 是 `~/.config/herdr/herdr.sock`,`herdr --session x` 是 `~/.config/herdr/sessions/x/herdr.sock`。发件箱连的是 `HERDR_WEB_SOCKET`「那一个」,所以要对着非默认 session 用发件箱,得把这个变量指过去。
|
|
422
|
-
|
|
423
|
-
**`Esc` 也在里面,而且是最要紧的一个**:程序声明 kitty 的 disambiguate flag(`CSI > 1 u`,herdr 和 Claude Code 都会)之后,Esc 必须编成 `CSI 27 u`。bare `0x1b` 是**所有**转义序列的前缀,程序收到它没法立刻判断这是一次真实的 Esc 还是一段序列的开头,只能等超时或者丢掉 —— 表现就是「网页上按 Esc 没反应」,`/usage` 之类的浮层退不出来。软键条上的 `Esc` 和发件箱里转发的 Esc 走同一套编码(服务端解析出来的字节不知道 kitty 开没开,所以孤立的 ESC 到前端会按当前模式重编)。
|
|
424
|
-
|
|
425
|
-
抢不回来的键(浏览器自己吃掉):macOS 上是 `⌘W` `⌘T` `⌘N` `Ctrl+Tab`;Windows/Linux 上还多 `Ctrl+W` `Ctrl+T` `Ctrl+N` `Ctrl+Shift+I/J/C`。真要用这些,把页面装成 PWA 能拿回一部分。
|
|
426
|
-
|
|
427
|
-
复制 `⌘C`(或 `Ctrl+Shift+C`)· 粘贴 `⌘V` · 清屏 `⌘K` · `Option` 默认当 Meta。
|
|
428
|
-
|
|
429
|
-
## 代码结构
|
|
430
|
-
|
|
431
|
-
```
|
|
432
|
-
cmd/herdr-web/ main:flag、子命令、监听、启动横幅、网卡打分
|
|
433
|
-
internal/
|
|
434
|
-
config/ 环境变量(viper,只认 env)、路径、部署形态(TLS 档位 / 暴露声明 / 白名单)
|
|
435
|
-
auth/ 配对码 + 设备凭据(只存哈希)+ 限速封锁(gate.go)
|
|
436
|
-
acme/ DNS-01 自动签发和续期(只 import 用到的 provider,见包注释)
|
|
437
|
-
tlsgen/ 本地 CA + 短期叶子证书 / 指定的真证书,都带热重载
|
|
438
|
-
ctl/ ~/.herdr-web/ctl.sock:子命令和跑着的服务之间的通道
|
|
439
|
-
herdr/ herdr socket 客户端(一次调用一条连接)
|
|
440
|
-
composer/ 按 agent 分派抽输入框 + testdata 里的真机抓屏
|
|
441
|
-
outbox/ 列目标 / 拉回 / 清空 / 投稿 / 推草稿
|
|
442
|
-
softkeys/ 软键条配置 + 按键谱解析(data.go 是从旧 JS 版生成的,不是手抄的;
|
|
443
|
-
testdata/js-snapshot.json 存着当时的快照,测试比对前 6 组)
|
|
444
|
-
uploads/ 图片落盘(按魔数认类型)
|
|
445
|
-
clip/ 读这台机器的剪贴板(pbpaste / wl-paste / xclip)—— herdr 的复制
|
|
446
|
-
落在**跑 herdr 那台机器**上,手机要拿到只能由这一侧读出来
|
|
447
|
-
server/ HTTP 路由 + PTY/WebSocket + 静态资源
|
|
448
|
-
guard.go 是门卫(Host 白名单 / Origin / 安全响应头)
|
|
449
|
-
authapi.go 是配对和设备管理的口
|
|
450
|
-
session.go 是「一个 URL 一个 herdr session」的分派(每个 session
|
|
451
|
-
一个 socket、一份发件箱、一条状态订阅)
|
|
452
|
-
webui/ embed 前端产物(dist 由 make build 拷进来)
|
|
453
|
-
qr/ 启动时在终端画二维码
|
|
454
|
-
version/ 版本号的唯一出处(goreleaser 用 ldflags 注进来)
|
|
455
|
-
selfupdate/ 查 GitHub Releases + 缓存 + 下载校验 + 原地换二进制
|
|
456
|
-
service/ 装成 launchd / systemd 常驻服务(plist / unit 生成 + 环境快照)
|
|
457
|
-
assets/ 图标(herdr 的羊关在浏览器窗口里)。**别手改 svg**,
|
|
458
|
-
改 assets/make-logo.py 再跑一遍 —— 羊的剪影是从 herdr 复用的
|
|
459
|
-
一条 1800+ 字符描图路径,而同一份图形要出圆角版 / 方角版 / 三种 png
|
|
460
|
-
web/ Vite + React + TS + Tailwind v4 + shadcn 风格组件
|
|
461
|
-
public/ 图标和 manifest(Vite 原样拷进 dist,走 / 根路径)
|
|
462
|
-
src/term/ xterm.js 胶水:补协议、触屏手势、重绘看门狗(命令式,不套 React)
|
|
463
|
-
src/hooks/ useCompose(发件箱状态机)、useViewportHeight
|
|
464
|
-
src/components/ Dock.tsx 是底部面板的外壳(发件箱 + 软键条共用的边框 / 宽度 / 高度)
|
|
465
|
-
Pairing.tsx 是配对页(没配对时只渲染它)
|
|
466
|
-
SettingsPanel.tsx 是设置面板,软键条编辑器和设备管理是它的两页
|
|
467
|
-
QrScan.tsx 是配对页里的扫码器(BarcodeDetector + 后摄)
|
|
468
|
-
reference/ 最早的 Python 原型,那三份文档里的「已验证」都是拿它验的
|
|
469
|
-
npm/herdr-web/ npm 根包 @bysir/herdr-web:一个 JS 壳,按平台找二进制
|
|
470
|
-
scripts/npm-*.mjs 把 goreleaser 产物摊成 npm 包 / 按顺序发布
|
|
471
|
-
install.sh 没有 node 时的装法(下载 + 强制校验 sha256)
|
|
472
|
-
.goreleaser.yaml 交叉编译 + archive + checksums(只出 darwin / linux)
|
|
473
|
-
.github/workflows/ ci.yml 每次推都跑;release.yml 打 tag 就发 GitHub + npm
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
命令行是 [cobra](https://github.com/spf13/cobra)(`cmd/herdr-web/main.go`):根命令起服务,`pair` / `devices` / `revoke` / `unlock` / `version` / `update` / `service` 是子命令,`--help` 和补全脚本白送。**标志只有一个** `-w, --web`(开发时指前端目录),别的配置一律环境变量 —— 同一个设置两个入口就得规定谁盖谁,不值当。
|
|
477
|
-
|
|
478
|
-
`make test` 跑 Go 测试 + 前端 typecheck。`make dev` 前端热更新(后端另开一个 `go run ./cmd/herdr-web`,vite 把 `/api` 和 `/pty` 转过去)。
|
|
479
|
-
|
|
480
|
-
### 发版
|
|
266
|
+
### How to set it
|
|
481
267
|
|
|
482
268
|
```bash
|
|
483
|
-
|
|
484
|
-
make release V=v0.1.0 # 打 tag 并推上去,剩下的 GitHub Actions 干
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
推上 tag 之后 `release.yml` 会:`make test` → goreleaser(交叉编译 4 个平台、出 archive 和 `checksums.txt`、建 GitHub Release)→ 把 archive 摊成 npm 包 → **先发 4 个平台子包、最后发根包**。顺序反了会有一段时间 `npm install` 装出一个没有二进制的壳。
|
|
488
|
-
|
|
489
|
-
**Release 建好了但 npm 那步挂了**(发过一次,见下)用同一个 workflow 补发,不重新编译:
|
|
490
|
-
|
|
491
|
-
```bash
|
|
492
|
-
gh workflow run release.yml -f tag=v0.1.0
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
它会去下已经发出去的那批 archive 再打包,所以补发的二进制和 Release 里的**逐字节相同**。
|
|
496
|
-
|
|
497
|
-
**发布 workflow 只能有一个,别再拆出去。** npm 的 Trusted Publisher(OIDC)一个包只能绑一个
|
|
498
|
-
workflow 文件名,绑的就是 `release.yml`;再开一个会发包的 workflow,从它发就对不上 OIDC。
|
|
499
|
-
|
|
500
|
-
需要一个仓库 secret:`NPM_TOKEN`(**Automation** 类型 —— 另外两档在开了 2FA 的账号上发包会要交互式
|
|
501
|
-
验证码,CI 里没人输)。配了 Trusted Publisher 之后可以去掉它,但**先发一版确认 OIDC 真的生效**再删。
|
|
502
|
-
|
|
503
|
-
Trusted Publisher 是**按包**配的,5 个包(根包 + 4 个平台子包)每个都要配一遍,都填 `release.yml`、
|
|
504
|
-
Environment name **留空**(我们的 workflow 没声明 environment,填了任何值 OIDC 都会对不上)。
|
|
505
|
-
少配一个的表现是下次发版在「发 npm」那步中途失败。
|
|
506
|
-
|
|
507
|
-
**tag 要推到装着 `release.yml` 的那个远端**,也就是 GitHub。这个仓库有两个远端(`origin`
|
|
508
|
-
是自建 git,`github` 才是 GitHub),所以 `make release` **不写死 origin** —— 它按 push URL 里的
|
|
509
|
-
`github.com` 认,认不出来就拒绝发版。推错远端是最难查的一种:tag 打上去了、命令也成功了,
|
|
510
|
-
Actions 那边一直没动静,而「没动静」和「还在排队」长得一模一样。要覆盖:
|
|
511
|
-
`make release V=vX.Y.Z RELEASE_REMOTE=xxx`。
|
|
512
|
-
|
|
513
|
-
三处名字必须对得上,改一个就要改另外两个:`.goreleaser.yaml` 的 `name_template`、`internal/selfupdate.AssetName`(自更新下载)、`scripts/npm-build.mjs`。对不上的表现是 `herdr-web update` 下载 404。
|
|
514
|
-
|
|
515
|
-
`make release-dry` 跑完会**把工作区还回去**:`npm-build.mjs` 把版本号写进入库的
|
|
516
|
-
`npm/herdr-web/package.json`(干跑时是 `0.1.1-next` 这种快照号)。不还的话紧接着
|
|
517
|
-
`make release` 会说「工作区不干净」而你什么都没改,或者那个 `-next` 版本号被顺手提交进去。
|
|
518
|
-
|
|
519
|
-
发版路上踩过、已经修掉的三个(都是**静默**失败):
|
|
520
|
-
|
|
521
|
-
- `web/tsconfig.tsbuildinfo` 曾经入库。它是 `tsc -b` 的增量缓存,`make test` 每跑一次就改写它,
|
|
522
|
-
紧接着 goreleaser 判定 `git is in a dirty state` 直接拒绝发版。构建缓存一律不入库。
|
|
523
|
-
- `make web` 里那句 `rm -rf $(WEBDIST)` 会删掉入库的 `internal/webui/dist/.gitkeep`。那个文件是
|
|
524
|
-
承重的:空目录上 `go:embed all:dist` 报 `cannot embed directory dist: contains no embeddable
|
|
525
|
-
files`,新 clone 连 `go build` 都过不了。所以 `web` 和 `clean` 两个目标都会把它写回来。
|
|
526
|
-
- 首发之后有几分钟,npm 的 packument 读路径还没物化(`version` 端点和 search 都查得到,packument
|
|
527
|
-
却 404)。这时候 `npm i` 拿到 404 会**静默跳过** optional 依赖,装出一个没有二进制的壳。
|
|
528
|
-
等几分钟重装就好,壳里那段报错会提示重装。
|
|
529
|
-
|
|
530
|
-
**为什么终端那层不是 React 组件**:它要直接摸 xterm 的 parser、逐字节收 WebSocket、按 rAF 补重绘 —— 套上 React 的渲染周期只会碍事。React 那边只拿一个 ref 挂载它,再订阅几个状态回调。
|
|
531
|
-
|
|
532
|
-
### 配色(改界面之前先看这段)
|
|
533
|
-
|
|
534
|
-
token 全定在 `web/src/index.css` 的 `@theme` 里(暗亮各一份),组件里**不写具体颜色**,只用这些名字:
|
|
535
|
-
|
|
536
|
-
- 灰阶四档:`bg`(画布 / 终端)→ `bar`(顶栏、底部面板、浮层)→ `ctl`(控件)→ `ctl-hi`(控件 hover);
|
|
537
|
-
分隔线 `line` / `line-hi`;文字 `fg` / `muted` / `faint`。全是 S=0 的**纯灰** —— 原来那套偏蓝的板岩灰
|
|
538
|
-
和终端里的彩色输出叠在一起会显脏。
|
|
539
|
-
- 绿只当强调色:`brand` 给文字 / 图标 / 描边,`brand-bg` + `brand-line` + `brand-fg` 是主按钮那一套填充。
|
|
540
|
-
**打开 / 选中态是「淡绿底 + 绿边 + 绿字」,不是整块涂满** —— 顶栏上五六个图标可能同时是打开的,
|
|
541
|
-
涂满的话整条栏全是色块,什么都不突出。饱和填充只留给一屏一个的主操作(投稿 / 保存 / 配对)和粘滞
|
|
542
|
-
修饰键那种「按下去了必须一眼看见」的状态。
|
|
543
|
-
- 圆角两档:控件 `rounded-md`(6px)、浮层 `rounded-card`(12px)。字号:正文 13px,次要一律 `text-xs`,
|
|
544
|
-
别再写 `text-[11.5px]` 这种一次性数值。
|
|
545
|
-
- 终端只有**灰阶和光标**跟着 token 走(`src/term/themes.ts`):底色 = `bg`、光标 = 品牌绿、选区是半透明的绿。
|
|
546
|
-
红黄蓝品青那六个色相一个都没动 —— 那是别人程序的输出颜色,diff 的红绿、agent 的高亮全靠它们。
|
|
547
|
-
- `accent` 是旧名字(原来那个亮蓝),现在留成 `brand` 的别名防止漏改,新代码别用它。
|
|
548
|
-
|
|
549
|
-
## 几个坑(已经处理了,记下来免得回头再踩)
|
|
550
|
-
|
|
551
|
-
- **WebSocket 不能并发写,写崩了是整个进程一起死。** gorilla/websocket 撞上并发写会
|
|
552
|
-
`panic: concurrent write to websocket connection`,而这个 panic 发生在 handler 自己起的
|
|
553
|
-
goroutine 里 —— net/http 只兜得住 handler 本身那一层,所以**进程直接退出,所有人的终端
|
|
554
|
-
一起断**。一条 PTY 连接上有三个写者:PTY 数据、25 秒一次的 ping、退出时的 exit + close。
|
|
555
|
-
线上炸过一次,是 ping 正好撞上一批二进制帧(和「开了几个浏览器」无关,每条连接各有自己的
|
|
556
|
-
conn;但连接越多、重连越频繁越容易撞)。现在全部收口到 `wsWriter`,`ws_test.go` 里那个
|
|
557
|
-
并发测试去掉锁就会复现同一条 panic。顺带两件:写入加了 10 秒超时(手机断网时 TCP 缓冲
|
|
558
|
-
填满会让 `WriteMessage` 一直阻塞、把锁也占着,那样 PTY 读循环都推不动了),ping 的
|
|
559
|
-
goroutine 改成 select 到 done 上(`Ticker.Stop()` 不关 channel,光 Stop 那个 goroutine
|
|
560
|
-
会永远卡在接收上,连着 conn 一起泄漏 —— 手机频繁重连时一条一个地攒)。
|
|
561
|
-
|
|
562
|
-
- **`HERDR_*` 会让 herdr 拒绝启动**。如果本服务是在 herdr 的 pane 里起的,子进程继承到就会报 `nested herdr is disabled by default`。`internal/server/pty.go` 的 `dropEnv` 把 `HERDR_* / TMUX / ZELLIJ / ITERM_* / CLAUDECODE` 这些痕迹都清了。
|
|
563
|
-
- **xterm.js 6.0 会「收下重绘请求但不画」**:DEC 2026 同步输出开着时把范围攒起来等 ESU;绘制在 rAF 里,后台标签页完全不跑。herdr 常驻开着 2026、一帧几 KB 还会被拆成多次 write,攒漏一次屏幕上就留一块空白。缓冲区没坏,所以只补重绘:数据流停下来 180ms 后强制画一次,2026 卡着就自己补个 ESU。频繁出现可以在设置 →「终端」里关掉同步输出。
|
|
564
|
-
- **改尺寸会闪一下全黑,要拿「冻帧」盖住**。呼输入法(`visualViewport` 一变就重排)时最明显。原因是叠起来的:xterm 的 WebGL 渲染器一改 `canvas.width` 绘制缓冲就清空、`FitAddon.fit()` 在 resize 前还主动 `renderService.clear()` 一次,而重画最快也要等下一个 rAF(2026 同步输出开着时得等 ESU);herdr 收到 SIGWINCH 之后自己又清屏重绘一遍,加起来几十毫秒。xterm 没有同步重绘的口子,所以延迟一个都去不掉 —— 改尺寸之前把 `.xterm-screen` 里那几层 canvas 合成一张图铺在终端上,等新画面画上(`onRender`)再多留 120ms 淡出。两个前提:WebGL 要开 `preserveDrawingBuffer`(合成完不丢缓冲,否则 `drawImage` 拿到的是空图),以及**快照读不出东西时要放弃冻帧**(后台标签页 rAF 不跑、画布压根没画过,糊一张空图上去比闪一下更糟)。另外行列数没变就不碰 xterm:键盘动画期间 `visualViewport` 会连着报好几次,白 resize 一次就白闪一次。
|
|
565
|
-
- **herdr 的主题不跟浏览器切换**:`~/.config/herdr/config.toml` 里 `[theme] auto_switch = false`。改成 `true` 之后,网页上切明暗就能直接切 herdr 的配色。
|
|
566
|
-
- **别把 `HERDR_WEB_SETTLE_MS` 调成 0**:详见「配置」那节。
|
|
567
|
-
- **重连必须先把终端复位**。一条 WebSocket 对应一个 PTY,断开时服务端就把 PTY 杀了,所以每次「连接」都是一个**全新的登录 shell**;但 xterm 实例是复用的,上一次 herdr 打开的私有模式还留在里面。表现是重连之后屏幕不但没好,还往命令行里灌乱码:鼠标移动上报(1003+1006)还开着,指针 / 手写笔一动就发 `ESC [ < 35;120;36 M`,zsh 的 ZLE 把认不出的 `ESC [ <` 前缀吃掉、余下的自插进命令行,于是屏幕上是 `35;120;36M35;115;37M…`(实测复现过:`➜ ~ 35;16;5M35;26;8M`)。kitty 键盘协议的 flags 同理留着,Esc 会被编成 `CSI 27 u`,新 shell 里显示 `[27u`。`connect()` 现在先 `term.reset()` 再连,顺手清掉我们自己攒的 kitty flags / 能力清单 / 粘滞修饰键。
|
|
568
|
-
- **「连接」按钮随时能按,所以连之前要自己收掉旧连接**。不收:服务端会再起一个登录 shell,两个 shell 的输出往同一个 xterm 里灌,屏幕当场花掉,而且旧 PTY 只要连接还在就一直活着。旧连接的回调也要一起摘掉 —— close 是异步的,旧连接的 `onclose` 会把新连接的状态改成「已断开」。
|
|
569
|
-
|
|
570
|
-
## 配置
|
|
571
|
-
|
|
572
|
-
**配置只有一个来源:环境变量。** 没有配置文件,命令行也只有一个 `--web`(开发时指前端目录)。用 [viper](https://github.com/spf13/viper) 收口在 `internal/config/`(`SetEnvPrefix("HERDR_WEB")` + `AutomaticEnv()`),配置项和变量名一一对应。
|
|
573
|
-
|
|
574
|
-
不读配置文件是故意的:这个口后面是一个登录 shell,「现在生效的到底是哪份配置」必须一眼看得见 —— 环境变量在 `ps` / systemd unit / launchd plist 里都是明摆着的,再多一个「某个目录下可能还有个 yaml」,出事时先得花半天确认哪份生效。同理也不做「命令行标志盖过环境变量」那一套:一个设置两个入口,就得规定谁盖谁。
|
|
575
|
-
|
|
576
|
-
### 怎么设
|
|
577
|
-
|
|
578
|
-
```bash
|
|
579
|
-
# 试一下:写在命令前面,只对这一次生效
|
|
269
|
+
# Try something: prefix the command, applies to this run only
|
|
580
270
|
HERDR_WEB_PORT=8000 HERDR_WEB_ONCONNECT= ./herdr-web
|
|
581
271
|
|
|
582
|
-
#
|
|
272
|
+
# Permanent: in ~/.zshrc (when you start it by hand in a terminal)
|
|
583
273
|
export HERDR_WEB_HOST=0.0.0.0
|
|
584
274
|
export HERDR_WEB_TLS=auto
|
|
585
275
|
|
|
586
|
-
#
|
|
587
|
-
# systemd
|
|
276
|
+
# Permanent: launchd (macOS) in the plist's EnvironmentVariables;
|
|
277
|
+
# systemd in the unit's Environment= / EnvironmentFile=
|
|
588
278
|
```
|
|
589
279
|
|
|
590
|
-
|
|
280
|
+
Three rules, all about not guessing:
|
|
591
281
|
|
|
592
|
-
-
|
|
593
|
-
-
|
|
594
|
-
-
|
|
282
|
+
- **An explicit empty string counts.** `HERDR_WEB_ONCONNECT=` means "type nothing on connect"; it does not fall back to the default `herdr`. Every switch with a default depends on this to be turnable off.
|
|
283
|
+
- **A malformed integer is treated as unset** (falls back to the default) rather than silently becoming 0; below-minimum values are clamped. `HERDR_WEB_DEVICE_TTL_DAYS=9O` (letter O) will not turn device credentials into "never expires".
|
|
284
|
+
- **Booleans accept `1` / `true`** (any case); anything else is off.
|
|
595
285
|
|
|
596
|
-
|
|
286
|
+
Changes take effect on restart — configuration is read once at startup. To confirm what was read, look at the startup banner: shell, data directory, herdr socket, TLS tier and paired device count are all printed there.
|
|
597
287
|
|
|
598
|
-
###
|
|
288
|
+
### Basics
|
|
599
289
|
|
|
600
|
-
|
|
|
290
|
+
| Variable | Default | Meaning |
|
|
601
291
|
|---|---|---|
|
|
602
|
-
| `HERDR_WEB_PORT` | `7788` |
|
|
603
|
-
| `HERDR_WEB_HOST` | `127.0.0.1` |
|
|
604
|
-
| `HERDR_WEB_TOKEN` |
|
|
605
|
-
| `HERDR_WEB_SHELL` | `$SHELL` |
|
|
606
|
-
| `HERDR_WEB_ONCONNECT` | `herdr` |
|
|
607
|
-
| `HERDR_WEB_ONCONNECT_MS` | `250` |
|
|
608
|
-
| `HERDR_WEB_DIR` | `~/.herdr-web` |
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
292
|
+
| `HERDR_WEB_PORT` | `7788` | Port |
|
|
293
|
+
| `HERDR_WEB_HOST` | `127.0.0.1` | Listen address; `0.0.0.0` opens it to the LAN |
|
|
294
|
+
| `HERDR_WEB_TOKEN` | reads `~/.herdr-web/token` | **Legacy**; only good for bootstrapping once (exchanged for a device credential). Not generated on new installs |
|
|
295
|
+
| `HERDR_WEB_SHELL` | `$SHELL` | The shell run inside the PTY |
|
|
296
|
+
| `HERDR_WEB_ONCONNECT` | `herdr` | Typed into the PTY on connect (Enter included). **Set it to an empty string to type nothing.** **Session URLs ignore this** (`/work` always types `herdr --session work`, see [First run](#first-run)) — to always land in a session, bookmark the URL rather than setting this |
|
|
297
|
+
| `HERDR_WEB_ONCONNECT_MS` | `250` | How long to wait before typing that line. The wait starts **after the shell's first output** — an rc file touching `stty`, or a completion plugin initialising, **silently swallows** characters typed too early. If the auto-typed line does not land, raise it |
|
|
298
|
+
| `HERDR_WEB_DIR` | `~/.herdr-web` | Data directory, in two layers: configuration and files (`softkeys.json` / `tls/` / `uploads/`) at the root, **internal data** (device credentials, passkey public keys) under `data/` — those two are not meant to be hand-edited, and tampering is reported in the terminal. **Keep the path short**: a unix socket (`ctl.sock`) is opened inside it, and beyond ~100 bytes it cannot bind, which breaks the subcommands |
|
|
299
|
+
| `HERDR_WEB_FILES` | on | `=0` turns file browsing off: `/api/files/*` and `/_f/` all 404, and the 📁 in the top bar is not drawn (an entry point that opens onto a wall of 404s is worse than no entry point) |
|
|
300
|
+
| `HERDR_WEB_FILE_ROOTS` | empty | Comma-separated directories. Set, this is **a real allowlist** (a jail) and only those trees are visible. **Empty means no boundary** — the reasoning is in [File browsing](#file-browsing). `~` is expanded; non-absolute entries are discarded (relative to what? keeping them only makes the prefix check pass somewhere surprising) |
|
|
301
|
+
|
|
302
|
+
### Outbox / talking to herdr
|
|
303
|
+
|
|
304
|
+
| Variable | Default | Meaning |
|
|
613
305
|
|---|---|---|
|
|
614
|
-
| `HERDR_WEB_SOCKET` | `$HERDR_SOCKET_PATH`
|
|
615
|
-
| `HERDR_WEB_POLL_MS` | `500` |
|
|
616
|
-
| `HERDR_WEB_PUSH_MS` | `700` |
|
|
617
|
-
| `
|
|
306
|
+
| `HERDR_WEB_SOCKET` | `$HERDR_SOCKET_PATH` or `~/.config/herdr/herdr.sock` | The herdr socket the outbox connects to. **Do not rely on `HERDR_SOCKET_PATH`**: `dropEnv` strips `HERDR_*`, and this process may not have been started from a herdr pane at all |
|
|
307
|
+
| `HERDR_WEB_POLL_MS` | `500` | How often the outbox checks "where is focus, what is in the input line". Minimum 200 |
|
|
308
|
+
| `HERDR_WEB_PUSH_MS` | `700` | With "two-way" on, how long after you stop typing the draft is pushed. Minimum 100 |
|
|
309
|
+
| `HERDR_WEB_NOTICE_MS` | `4000` | How often notices (the cards and the unread badge) ask "anything new". **`0` turns the whole notice feature off** and the frontend stops polling. Anything under 1000 is treated as 1000 — this tick only reads memory on the server (it does not touch the herdr socket), but a notice is inherently 2.5 seconds behind the state change (debounce), so polling harder cannot beat that |
|
|
310
|
+
| `HERDR_WEB_SETTLE_MS` | `120` | How long to wait between two `pane.read` calls (to defeat the one-frame snapshot lag). **Never 0**: herdr sometimes answers in 1-2ms, both reads land on the same frame, and the clear loop misreads that as "cannot be cleared". The clear path has its own 120ms floor |
|
|
618
311
|
|
|
619
|
-
###
|
|
312
|
+
### Exposure / TLS / credentials
|
|
620
313
|
|
|
621
|
-
|
|
314
|
+
Details in [SECURITY.md](SECURITY.md) (Chinese).
|
|
622
315
|
|
|
623
|
-
|
|
|
316
|
+
| Variable | Default | Meaning |
|
|
624
317
|
|---|---|---|
|
|
625
|
-
| `HERDR_WEB_EXPOSED` |
|
|
626
|
-
| `HERDR_WEB_TLS_CERT` / `_KEY` |
|
|
627
|
-
| `HERDR_WEB_ACME_DNS` |
|
|
628
|
-
| `HERDR_WEB_ACME_EMAIL` |
|
|
629
|
-
| `HERDR_WEB_ACME_STAGING` |
|
|
630
|
-
| `HERDR_WEB_TLS` |
|
|
631
|
-
| `
|
|
632
|
-
| `
|
|
633
|
-
| `
|
|
634
|
-
| `
|
|
635
|
-
| `
|
|
636
|
-
| `
|
|
637
|
-
| `
|
|
638
|
-
| `
|
|
639
|
-
| `
|
|
640
|
-
| `
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
318
|
+
| `HERDR_WEB_EXPOSED` | off | `=1` **declares that this port is reachable from the internet** (frp / port forwarding / tunnels). Behind frp the process usually listens on 127.0.0.1 and every request also comes from 127.0.0.1, so "is the listen address local" tells you nothing; it cannot be detected, only declared. Once declared: TLS is mandatory and loopback-without-pairing is turned off |
|
|
319
|
+
| `HERDR_WEB_TLS_CERT` / `_KEY` | empty | Use the certificate you supply. If you own a domain and got a real certificate via DNS-01, take this route — zero browser warnings, no profiles to install, least friction |
|
|
320
|
+
| `HERDR_WEB_ACME_DNS` | empty | Let herdr-web **get its own certificate**; the value is the DNS provider: `cloudflare` / `alidns` / `tencentcloud` / `route53` / `digitalocean` / `huaweicloud`. It uses DNS-01, so nothing has to reach you from outside — behind NAT, or with the domain pointed at a LAN address, it still works. **Where to get each provider's token and what scope it needs: [DNS.md](DNS.md)** (Chinese) |
|
|
321
|
+
| `HERDR_WEB_ACME_EMAIL` | empty | ACME account email. Can be empty, but then you get no expiry reminders either |
|
|
322
|
+
| `HERDR_WEB_ACME_STAGING` | off | `=1` uses Let's Encrypt staging. **Turn it on while debugging**: production allows 5 certificates per domain set per week, and a few attempts lock you out for a week |
|
|
323
|
+
| `HERDR_WEB_TLS` | see notes | `auto` self-signed (local CA + 397-day leaf, re-issued automatically when the IP changes) / `off` plaintext / `proxy` something in front already terminated TLS. Default: exposed or listening on the LAN → `auto`, purely local → `off` |
|
|
324
|
+
| `HERDR_WEB_LAN_PORT` | off | Opens a **second listener** on `0.0.0.0:<port>` with a self-signed certificate whose SANs track your current LAN addresses, so a page loaded through a tunnel can probe for a direct LAN route and switch to it — two public hops per keystroke become one switch hop. One manual step per device that cannot be skipped: **open it once and click through the certificate warning**; until then the probe fails at the TLS handshake and the page quietly stays on the tunnel. It has to be TLS — an https page's fetch to an `http://` target is active mixed content and is blocked unconditionally, so a plaintext LAN port cannot be probed at all. Not needed when the main port already serves self-signed TLS on the LAN. **The direct origin holds its own credential** (cookies are host-only), so the same tablet shows up twice in the device panel — the switch carries a one-time pairing code across for you — and **passkeys do not work there**, because a WebAuthn RP ID has to be a domain and a bare IP is not one (installing the CA does not change that). Details: [DEPLOY.md](DEPLOY.md) |
|
|
325
|
+
| `HERDR_WEB_HOSTNAME` | empty | Domains allowed in the `Host` header, comma separated. **IPs always pass, domains must be listed** — this is the only defence against DNS rebinding, and anything else gets a 421 |
|
|
326
|
+
| `HERDR_WEB_PUBLIC_URL` | empty | The address you **actually visit** (`https://herdr.example.com:17788`). With frp the public port is often not the local one, and without this the QR code in the banner is useless. The domain in it is allowlisted automatically |
|
|
327
|
+
| `HERDR_WEB_DEVICE_TTL_DAYS` | `90` | How long a device credential survives without use (renewed on every use). `0` = **never expires** |
|
|
328
|
+
| `HERDR_WEB_RPID` | derived | The domain a passkey is bound to. Defaults to the first `HERDR_WEB_HOSTNAME`, or `localhost` when purely local. **A bare IP is not a valid value** — such deployments cannot use passkeys |
|
|
329
|
+
| `HERDR_WEB_REAUTH_HOURS` | `24` | Once a passkey is registered, how long a session credential remains valid after the last biometric check. `0` = no re-verification (passkeys serve only as the login / new-device path). **Does nothing at all while no passkey is registered** |
|
|
330
|
+
| `HERDR_WEB_LEGACY_TOKEN` | `on` | `on` / `loopback` (the old token only works locally) / `off`. Once migrated, just delete the token file |
|
|
331
|
+
| `HERDR_WEB_TRUST_LOOPBACK` | off | `=1` exempts requests from 127.0.0.1 from pairing. **Never turn this on behind frp or a reverse proxy** — there, public requests also arrive from 127.0.0.1, i.e. everyone is "local". When on, it additionally requires `Host` to be a loopback literal |
|
|
332
|
+
| `HERDR_WEB_TRUST_PROXY` | off | `=1` is required to read `X-Forwarded-For`. With no trusted proxy in front, leaving it on lets an attacker forge the source IP with a header and walk around per-IP rate limiting |
|
|
333
|
+
| `HERDR_WEB_INSECURE` | off | `=1` permits "exposed but no TLS". No legitimate use beyond temporary debugging |
|
|
334
|
+
| `HERDR_WEB_UPDATE_CHECK` | on | `=0` disables automatic update checks. With it off the process makes **no outbound requests at all** — a hard requirement in the kind of environment where an internal machine must not dial out. Only the automatic check is disabled; `herdr-web update --check` still works |
|
|
335
|
+
|
|
336
|
+
### Troubleshooting
|
|
337
|
+
|
|
338
|
+
| Variable | Default | Meaning |
|
|
645
339
|
|---|---|---|
|
|
646
|
-
| `HERDR_WEB_DEBUG_INPUT` |
|
|
340
|
+
| `HERDR_WEB_DEBUG_INPUT` | off | `=1` logs every batch of bytes written into the PTY as hex (including the auto-typed line, prefixed `onconnect`). The only way to answer "what exactly did that key send" — guessing does not work |
|
|
647
341
|
|
|
648
|
-
###
|
|
342
|
+
### Read but not prefixed with `HERDR_WEB_`
|
|
649
343
|
|
|
650
|
-
|
|
|
344
|
+
| Variable | When it matters |
|
|
651
345
|
|---|---|
|
|
652
|
-
| `SHELL` | `HERDR_WEB_SHELL`
|
|
653
|
-
| `HERDR_SOCKET_PATH` | `HERDR_WEB_SOCKET`
|
|
346
|
+
| `SHELL` | The shell run inside the PTY when `HERDR_WEB_SHELL` is unset (falling back to `/bin/zsh`) |
|
|
347
|
+
| `HERDR_SOCKET_PATH` | Fallback herdr socket when `HERDR_WEB_SOCKET` is unset. **Do not count on it being there**: `dropEnv` strips `HERDR_*` from child processes (to prevent nesting), and this process may not have started from a herdr pane |
|
|
654
348
|
|
|
655
|
-
###
|
|
349
|
+
### A few common setups
|
|
656
350
|
|
|
657
351
|
```bash
|
|
658
|
-
# 1.
|
|
352
|
+
# 1. Purely local (default): plain http, since loopback is a secure context anyway
|
|
659
353
|
./herdr-web
|
|
660
354
|
|
|
661
|
-
# 2.
|
|
355
|
+
# 2. Phone / tablet on the LAN: self-signed TLS, pair by scanning the banner QR
|
|
662
356
|
HERDR_WEB_HOST=0.0.0.0 ./herdr-web
|
|
663
357
|
|
|
664
|
-
# 3.
|
|
665
|
-
#
|
|
358
|
+
# 3. Exposed through frp / a tunnel: EXPOSED must be declared (the process only
|
|
359
|
+
# listens on 127.0.0.1 and cannot tell whether anyone outside can reach it),
|
|
360
|
+
# PUBLIC_URL decides which address the QR code encodes
|
|
666
361
|
HERDR_WEB_EXPOSED=1 HERDR_WEB_TLS=proxy \
|
|
667
362
|
HERDR_WEB_PUBLIC_URL=https://herdr.example.com \
|
|
668
363
|
HERDR_WEB_HOSTNAME=herdr.example.com ./herdr-web
|
|
669
364
|
|
|
670
|
-
# 4.
|
|
365
|
+
# 4. Your own domain + a real certificate (zero browser warnings, least friction)
|
|
671
366
|
HERDR_WEB_HOST=0.0.0.0 HERDR_WEB_HOSTNAME=herdr.example.com \
|
|
672
367
|
HERDR_WEB_TLS_CERT=/etc/ssl/herdr/fullchain.pem \
|
|
673
368
|
HERDR_WEB_TLS_KEY=/etc/ssl/herdr/privkey.pem ./herdr-web
|
|
674
369
|
|
|
675
|
-
# 5.
|
|
370
|
+
# 5. Do not drop into herdr on connect (stay in the shell)
|
|
676
371
|
HERDR_WEB_ONCONNECT= ./herdr-web
|
|
677
372
|
```
|
|
678
373
|
|
|
679
|
-
##
|
|
374
|
+
## Daemon
|
|
680
375
|
|
|
681
|
-
|
|
376
|
+
Install it as a user-level service that starts on boot:
|
|
682
377
|
|
|
683
378
|
```bash
|
|
684
|
-
herdr-web service install # macOS → launchd LaunchAgent
|
|
685
|
-
herdr-web service status #
|
|
686
|
-
herdr-web service logs # tail -f
|
|
687
|
-
herdr-web service restart #
|
|
688
|
-
herdr-web service uninstall #
|
|
379
|
+
herdr-web service install # macOS → launchd LaunchAgent; Linux → systemd user unit
|
|
380
|
+
herdr-web service status # installed? running? PID? where are the logs?
|
|
381
|
+
herdr-web service logs # tail -f the log
|
|
382
|
+
herdr-web service restart # needed after replacing the binary
|
|
383
|
+
herdr-web service uninstall # stop and remove (data and logs untouched)
|
|
689
384
|
```
|
|
690
385
|
|
|
691
|
-
|
|
386
|
+
**Configuration is copied out of the current shell at install time.** So the order is "get the environment right, then install"; changing configuration means installing again (it is idempotent — overwrite and restart). To read it from a file:
|
|
692
387
|
|
|
693
388
|
```bash
|
|
694
389
|
herdr-web service install --env-file .env
|
|
695
390
|
```
|
|
696
391
|
|
|
697
|
-
|
|
392
|
+
What gets copied is every `HERDR_WEB_*`, plus `PATH` / `SHELL` / `HOME` / `USER` / `LOGNAME` / `LANG` / `LC_ALL` / `TERM` / `HERDR_SOCKET_PATH`. `install` prints the whole list — from then on, "which configuration is this machine's service actually using" can only be answered by the plist / unit, so it is cheapest to read it at install time.
|
|
698
393
|
|
|
699
|
-
|
|
394
|
+
**DNS provider credentials carry the `HERDR_WEB_` prefix too** (`HERDR_WEB_CLOUDFLARE_DNS_API_TOKEN`, `HERDR_WEB_ALICLOUD_ACCESS_KEY` and friends), so the rule above already copies them — exporting them in your shell is enough, no `--env-file` required. The prefix is not cosmetic: a bare `CLOUDFLARE_DNS_API_TOKEN` matches neither the prefix nor the allowlist, so it is not copied, and that failure only surfaces at the first issuance (or three months later, at the first renewal). lego still reads the bare names, but those can only reach the service through `--env-file`; when both are set, the prefixed one wins. Per-provider variable names are in [DNS.md](DNS.md).
|
|
700
395
|
|
|
701
|
-
|
|
396
|
+
Keys in `--env-file` go in **wholesale** (and override the current environment). The file is read at `install` time only and never touched again. In the list `install` prints, credentials show up as asterisks and a length — that output often lands in a pane with an agent in it.
|
|
702
397
|
|
|
703
|
-
|
|
398
|
+
The plist / unit is **0600** — its contents are exactly that environment in plaintext.
|
|
704
399
|
|
|
705
|
-
|
|
400
|
+
**Copying `PATH` is mandatory, and it is the most common failure after installing as a service**: launchd's default `PATH` is only `/usr/bin:/bin:/usr/sbin:/sbin`, so `HERDR_WEB_ONCONNECT=herdr` turns into `herdr: command not found` while the page just shows an empty shell with no clue why.
|
|
706
401
|
|
|
707
|
-
|
|
402
|
+
Why user-level rather than system-level: this process opens **your** shell. Running it as a root system service means the terminal in the browser is root's, permissions jump straight to maximum, and `~/.herdr-web` and `~/.config/herdr/herdr.sock` all point at somebody else's home.
|
|
708
403
|
|
|
709
|
-
|
|
404
|
+
Platform-specific traps:
|
|
405
|
+
|
|
406
|
+
| | File | Note |
|
|
710
407
|
|---|---|---|
|
|
711
|
-
| macOS | `~/Library/LaunchAgents/io.github.zbysir.herdr-web.plist` | LaunchAgent
|
|
712
|
-
| Linux | `~/.config/systemd/user/herdr-web.service` | `install`
|
|
408
|
+
| macOS | `~/Library/LaunchAgents/io.github.zbysir.herdr-web.plist` | A LaunchAgent starts **at login**, not at boot. On a machine with automatic login the two are equivalent; otherwise you have to log in once. "Start with nobody logged in" would require a system-level daemon in `/Library/LaunchDaemons`, which makes the shell root's — this project does not do that. |
|
|
409
|
+
| Linux | `~/.config/systemd/user/herdr-web.service` | `install` also runs `loginctl enable-linger`. **Without linger the service is stopped when you log out of ssh** — for a machine you want to reach at any time, that is the same as not running at all. If it fails it tells you to run `sudo loginctl enable-linger $USER`. |
|
|
713
410
|
|
|
714
|
-
|
|
411
|
+
Logs are at `~/.herdr-web/logs/herdr-web.log` on both platforms (deliberately identical, so the docs and `service logs` have one answer). On Linux `journalctl --user -u herdr-web` works as well.
|
|
715
412
|
|
|
716
|
-
`service status`
|
|
413
|
+
`service status` reporting "installed but not running" means **it crashes on start**, and the reason is only in the log — launchd and systemd both keep retrying with a few seconds of backoff, so without looking you would assume it is running.
|
|
717
414
|
|
|
718
|
-
Windows
|
|
415
|
+
Windows, and Linux without systemd (containers, WSL1), are told clearly that this cannot work and what to do instead, rather than being given something that will not run. On WSL2, add `[boot] systemd=true` to `/etc/wsl.conf` and `wsl --shutdown` to restart, and it works.
|
|
719
416
|
|
|
720
|
-
##
|
|
417
|
+
## Updating
|
|
721
418
|
|
|
722
419
|
```bash
|
|
723
|
-
herdr-web update #
|
|
724
|
-
herdr-web update --check #
|
|
725
|
-
herdr-web update --restart #
|
|
726
|
-
herdr-web version #
|
|
420
|
+
herdr-web update # check and upgrade
|
|
421
|
+
herdr-web update --check # check only, change nothing
|
|
422
|
+
herdr-web update --restart # upgrade, then restart the service
|
|
423
|
+
herdr-web version # current version + how it was installed
|
|
727
424
|
```
|
|
728
425
|
|
|
729
|
-
|
|
426
|
+
**How it upgrades depends on how it was installed**, and `update` works that out itself (from the executable's path, resolving symlinks first):
|
|
730
427
|
|
|
731
|
-
|
|
|
428
|
+
| Installed via | Upgrade action |
|
|
732
429
|
|---|---|
|
|
733
|
-
| npm |
|
|
734
|
-
| homebrew |
|
|
735
|
-
| `go install` |
|
|
736
|
-
| release archive / install.sh |
|
|
737
|
-
|
|
738
|
-
包管理器装的不自己动文件,是因为去改 `node_modules` / `Cellar` 里的东西,下次那个包管理器一升级就盖回去了,白忙一场。
|
|
739
|
-
|
|
740
|
-
自己换的那条路有三个点是刻意的:**先校验再落地**(`checksums.txt` 对不上就整个放弃)、**临时文件必须同目录**(跨目录 `rename` 会 EXDEV)、**不删旧的**(unix 上 rename 覆盖一个正在运行的可执行文件是允许的,老 inode 还被进程持着,所以当前进程能安全跑到自己退出)。
|
|
741
|
-
|
|
742
|
-
**换完文件不等于换了正在跑的那个进程。** 重启才生效,而重启会掐掉所有正在用的终端会话 —— 所以这一步默认不做,`--restart` 才做。
|
|
430
|
+
| npm | runs `npm install -g @bysir/herdr-web@latest` |
|
|
431
|
+
| homebrew | runs `brew upgrade herdr-web` |
|
|
432
|
+
| `go install` | runs `go install …@latest` |
|
|
433
|
+
| release archive / install.sh | **does it itself**: download → verify sha256 → write a temp file in the same directory → atomic `rename` |
|
|
743
434
|
|
|
744
|
-
|
|
435
|
+
Package-manager installs are not touched directly because editing things inside `node_modules` / `Cellar` gets overwritten the next time that package manager runs — wasted effort.
|
|
745
436
|
|
|
746
|
-
-
|
|
747
|
-
- **管理页**最上面横一条,带当前版本、该敲哪条命令、更新说明链接;
|
|
748
|
-
- 服务在跑的时候,后台每天查一次,发现新版本往**日志**里写一行(同一个版本只提一次,不会天天刷)。
|
|
437
|
+
Three things about the self-managed path are deliberate: **verify before landing** (a `checksums.txt` mismatch aborts everything), **the temp file must be in the same directory** (a cross-directory `rename` gives EXDEV), and **the old file is not deleted** (on unix, renaming over a running executable is allowed, the old inode is still held by the process, so the current process runs safely until it exits).
|
|
749
438
|
|
|
750
|
-
|
|
439
|
+
**Replacing the file is not the same as replacing the running process.** Only a restart takes effect, and a restart kills every terminal session in use — so it is not done by default, only with `--restart`.
|
|
751
440
|
|
|
752
|
-
|
|
441
|
+
New-version notices appear in three places:
|
|
753
442
|
|
|
754
|
-
|
|
443
|
+
- the last line of the **startup banner** (from cache, so no request is made on the startup path — on a slow network that would turn into "startup hangs for ten seconds");
|
|
444
|
+
- a strip at the top of the **admin page**, with the current version, the command to run and a link to the release notes;
|
|
445
|
+
- while the service is running, a daily background check writes one line to the **log** when a new version appears (once per version, not daily nagging).
|
|
755
446
|
|
|
756
|
-
|
|
757
|
-
- **凭据绑设备,不绑 IP。** 按 IP 记住信任两头都输:DHCP 会把你批准过的地址分给别人(客人连一下 Wi-Fi 就进你的 shell),而你自己换个 Wi-Fi 就要重新配对。
|
|
758
|
-
- **URL 里没有秘密。** `?pair=` / 旧 `?token=` 进来就换成 cookie 再 302 洗掉,所以浏览器历史、书签云同步、截图都不再是泄露渠道。
|
|
759
|
-
- **能撤销。** 命令行 `herdr-web devices` / `revoke`,网页上是设置 →「设备」里的「登出」/「踢掉」,下一个请求立刻 401。
|
|
760
|
-
- **配对码只能由坐在机器前的人产生**(`herdr-web pair` 或启动横幅),网页上任何路径都不出码,连已配对的设备也不行。两个理由:① 码创造的是一份**不随创造者一起被撤销**的独立凭据 —— 手机被人拿去一次、他配一台自己的进来,你之后把手机踢掉,他那台还在,等于绕过撤销做了持久化;② 码是打在终端里的,而那个终端往往是个 herdr pane,同 session 的 agent 能 `pane.read` 读到它 —— 要是外面的人能远程触发「打一个码」,「触发打印 + 被注入的 agent 读走」就是一条完整的远程配对链,人根本不用碰机器。在 L2 的第二因子做出来之前,「能读到那个终端」是系统里**唯一的带外因子**,不能动。
|
|
761
|
-
- **暴露出去又没 TLS 就拒绝启动**(以前只打一行警告,警告没人看)。自签走本地 CA + 397 天叶子,IP 变了自动重签、但设备信任的是 CA,所以不用重新点「继续访问」。
|
|
762
|
-
- **Host 白名单**挡 DNS rebinding(IP 一律放行,域名必须在 `HERDR_WEB_HOSTNAME` 里,否则 421)、**Origin 校验** + `SameSite=Strict` + 一个自定义头三道挡 CSRF、`/pty` 上没有 Origin 的 cookie 请求直接拒。
|
|
763
|
-
- **限速和封锁**:猜配对码前两次免罚,之后指数退避;15 分钟里 10 次封该 IP 15 分钟(重犯翻倍,上限 24 小时),换源 IP 的分布式尝试会触发全局熔断(只拒新配对,不动已有会话),终端上打告警。只数「猜短凭据」的失败 —— cookie 认不出来不算,不然刚 revoke 一台旧手机就把自己封了。**本机默认永不封**(否则解锁的入口也在门后面),但**声明了 `EXPOSED` 之后这个豁免自动关掉** —— 见下面 frp 那节,穿透进来的源 IP 全是 127.0.0.1,留着它整层限速就是空转。
|
|
764
|
-
- 安全响应头(CSP / nosniff / no-referrer / DENY)、PTY 并发上限 8、OSC 8 链接只放 `http/https/mailto`(终端上显示什么是程序说了算的)。
|
|
765
|
-
- **故意不发 HSTS**:自签证书配上 HSTS 会把「继续访问」那个口也焊死,而且清不掉。
|
|
447
|
+
Checks go to GitHub Releases' anonymous API, with results cached in `~/.herdr-web/update.json` (on disk, so frequent restarts do not mean checking every time; failures are stamped too, so a machine with no connectivity does not eat a timeout on every start). `HERDR_WEB_UPDATE_CHECK=0` disables the automatic check entirely — with it off, this process makes no outbound requests at all. Local builds (where `version` reports `dev`) neither check nor nag.
|
|
766
448
|
|
|
767
|
-
|
|
449
|
+
## Security
|
|
768
450
|
|
|
769
|
-
|
|
451
|
+
**This thing amounts to a shell over HTTP** (the outbox alone can make an agent run commands, even
|
|
452
|
+
without a PTY), so the door is designed on that premise. What is implemented:
|
|
770
453
|
|
|
771
|
-
|
|
454
|
+
- **Pair each device once.** A one-time code is exchanged for a per-device credential in an
|
|
455
|
+
`HttpOnly; SameSite=Strict` cookie; the server **stores only sha256** — the agents on this machine
|
|
456
|
+
read untrusted content all day, so the credential file being read by prompt injection is a daily
|
|
457
|
+
risk, not a theoretical one.
|
|
458
|
+
- **Credentials bind to a device, not an IP.** Changing networks costs nothing; trusting an IP loses
|
|
459
|
+
both ways.
|
|
460
|
+
- **No secrets in URLs.** `?pair=` is exchanged for a cookie and scrubbed with a 302, so bookmark
|
|
461
|
+
sync and screenshots stop being leak channels.
|
|
462
|
+
- **Revocable.** `herdr-web revoke`, or Settings → Devices; the next request gets 401.
|
|
463
|
+
- **Only someone at the machine can produce a pairing code** — no path on the web issues one. That
|
|
464
|
+
terminal is the only out-of-band factor in the system.
|
|
465
|
+
- **Refuses to start when exposed without TLS.** A Host allowlist blocks DNS rebinding; Origin +
|
|
466
|
+
`SameSite=Strict` + a custom header make three layers against CSRF; guessing a pairing code gets
|
|
467
|
+
exponential backoff, per-IP lockout and a global breaker.
|
|
468
|
+
- **Passkeys are the second factor** (the server stores only the public key). With one registered,
|
|
469
|
+
moving to a new device does not require going back to the machine, and session credential lifetime
|
|
470
|
+
can drop from three months to one day.
|
|
772
471
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
推荐 **frp 的 `type = tcp` + herdr-web 自己拿真证书**:TLS 端到端,frps 那台 VPS 上只看得到密文。用 frp 的 https 模式就是在 VPS 上解密,那台机器能看到你的整个终端画面。
|
|
776
|
-
|
|
777
|
-
```bash
|
|
778
|
-
HERDR_WEB_EXPOSED=1 HERDR_WEB_TLS_CERT=~/certs/herdr.example.com/fullchain.pem HERDR_WEB_TLS_KEY=~/certs/herdr.example.com/privkey.pem HERDR_WEB_HOSTNAME=herdr.example.com HERDR_WEB_PUBLIC_URL=https://herdr.example.com:17788 ./herdr-web
|
|
779
|
-
```
|
|
472
|
+
→ Threat model, the reasoning behind each choice, what is not built yet: [SECURITY.md](SECURITY.md)
|
|
473
|
+
Reaching it from the internet (frp / tunnels) and the four TLS tiers: [DEPLOY.md](DEPLOY.md)
|
|
780
474
|
|
|
781
|
-
|
|
475
|
+
## Documents
|
|
782
476
|
|
|
783
|
-
|
|
477
|
+
Everything below is in Chinese — that is where the "why" lives.
|
|
784
478
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
**配对码打在终端里,如果那是个 herdr pane,同 session 的别的 agent 能用 `pane.read` 读到它**(这个项目自己的发件箱就是这么读 pane 的)。窗口是 5 分钟 + 一次性,而且只有你主动出码时才存在(正因为这条,远程触发出码的口子被去掉了);再不放心就在 herdr 之外的终端里 `herdr-web pair`。
|
|
479
|
+
| What you want | Where |
|
|
480
|
+
|---|---|
|
|
481
|
+
| Outbox: why a separate box, how images work, measured polling latency | [OUTBOX.md](OUTBOX.md) |
|
|
482
|
+
| Reading the screen: scraping the input line, scraping what the agent said | [COMPOSER.md](COMPOSER.md) |
|
|
483
|
+
| herdr socket API semantics, verified by hand | [HERDR-API.md](HERDR-API.md) |
|
|
484
|
+
| The whole phone / tablet layer (gestures, keyboard, dock, top bar, notices, clipboard) | [MOBILE.md](MOBILE.md) |
|
|
485
|
+
| Security design and threat model; the rules on the file-serving route | [SECURITY.md](SECURITY.md) |
|
|
486
|
+
| Where to run it, public access, TLS tiers | [DEPLOY.md](DEPLOY.md) |
|
|
487
|
+
| Getting a DNS token from each provider and the scope it needs | [DNS.md](DNS.md) |
|
|
488
|
+
| Read before changing code (layout, releasing, colours, the silent traps) | [CLAUDE.md](CLAUDE.md) |
|
|
489
|
+
|
|
490
|
+
MIT.
|