dsh-plugin-tether 0.1.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/LICENSE +21 -0
- package/README.md +140 -0
- package/README.zh.md +147 -0
- package/cordis.patch.yml +21 -0
- package/index.js +503 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zexadev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<h1 align="center">DSH Tether</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Use the DeepSeek Harness on your dev machine, from your phone.</strong><br>
|
|
5
|
+
Across networks, peer to peer, through no server at all.<br>
|
|
6
|
+
No relay to configure, no shared Wi-Fi, no Linux on your phone.
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center"><sub>An independent community project. Not affiliated with, partnered with, authorised by, or endorsed by DeepSeek.<br>No DeepSeek employee or upstream DeepSeek Harness team member is involved in this repository.<br><a href="README.zh.md">中文</a> · English</sub></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<img src="assets/banner.jpg" alt="Phone tethered directly to a dev machine — no server in between" width="100%">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="../../releases/latest"><img src="https://img.shields.io/github/v/release/zexadev/dsh-tether?style=flat&label=release&color=4D6BFE" alt="Latest release"></a>
|
|
17
|
+
<a href="../../releases"><img src="https://img.shields.io/github/downloads/zexadev/dsh-tether/total?style=flat&label=downloads&color=4D6BFE" alt="Downloads"></a>
|
|
18
|
+
<a href="../../stargazers"><img src="https://img.shields.io/github/stars/zexadev/dsh-tether?style=flat&label=%E2%98%85&color=08C" alt="Stars"></a>
|
|
19
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat" alt="MIT"></a>
|
|
20
|
+
<img src="https://img.shields.io/badge/dsh-0.1.0--rc.7-4D6BFE?style=flat" alt="dsh 0.1.0-rc.7">
|
|
21
|
+
<img src="https://img.shields.io/badge/Android-4493F8?style=flat" alt="Android">
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="assets/phone-cellular.png" width="240" alt="A DSH session on the phone">
|
|
26
|
+
<img src="assets/phone-sidebar-drawer.png" width="240" alt="The sidebar as an overlay drawer">
|
|
27
|
+
<img src="assets/phone-settings.png" width="240" alt="Settings laid out for a phone">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
DSH Tether carries the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web interface to your phone over a direct peer-to-peer connection. The agent keeps running on the machine your code lives on, and the phone gets DSH's own full interface — conversations, tool calls, approvals and settings, not a reimplementation of them. Pair once with a 6-digit code; after that the two ends find each other whatever network they are on.
|
|
31
|
+
|
|
32
|
+
## The case it solves
|
|
33
|
+
|
|
34
|
+
**You are not on your machine's network, and you do not want a server in the middle.**
|
|
35
|
+
|
|
36
|
+
Reaching your own dev machine from a phone usually asks for one of two things: both ends on the same LAN, or a relay you deploy, configure and trust. This asks for neither. The two ends hole-punch to each other through [iroh](https://www.iroh.computer/), and once they do, nothing passes through a third party; the fallback relay only ever carries ciphertext it cannot read.
|
|
37
|
+
|
|
38
|
+
If you only use your phone on the same Wi-Fi as your machine, you don't need any of this — a LAN setup is simpler.
|
|
39
|
+
|
|
40
|
+
## Download and install
|
|
41
|
+
|
|
42
|
+
| Where | Download | How |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Machine (plugin) | — | `dsh plugin --profile web add github:zexadev/dsh-tether` |
|
|
45
|
+
| Machine (sidecar) | `dsh-tether-host-<platform>-<version>.tgz` from the [Release](../../releases/latest) | `dsh plugin --profile web add ./that-file.tgz` |
|
|
46
|
+
| Phone | `app-universal-release.apk` from the [Release](../../releases/latest) | Signed — install it directly |
|
|
47
|
+
|
|
48
|
+
The sidecar is a small Rust binary the plugin drives; it owns the iroh connection. **It currently ships with each Release and is not on npm yet**, so it needs that one extra step; once it is published, the first command alone will do.
|
|
49
|
+
|
|
50
|
+
You can also build the sidecar from source (needs Rust):
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
git clone https://github.com/zexadev/dsh-tether && cd dsh-tether
|
|
54
|
+
cargo build --release -p tether-host
|
|
55
|
+
dsh plugin --profile web add .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Pair
|
|
59
|
+
|
|
60
|
+
Start `dsh web` as you normally would, then click **Connect phone** at the bottom of the sidebar. You get one line to copy:
|
|
61
|
+
|
|
62
|
+
<p align="center"><img src="assets/pc-pairing.png" width="620" alt="The pairing string, shown on the computer"></p>
|
|
63
|
+
|
|
64
|
+
On the phone, open DSH Tether → **Add computer** → paste that whole line → name the computer → connect.
|
|
65
|
+
|
|
66
|
+
From then on the app connects by itself when you open it.
|
|
67
|
+
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
<table>
|
|
71
|
+
<tr>
|
|
72
|
+
<td width="50%" valign="top">
|
|
73
|
+
<h3>Direct across networks</h3>
|
|
74
|
+
<p>The two ends hole-punch to each other through iroh, and once connected nothing passes through a third party. Phone on 4G/5G and machine on home broadband works — no public IP, no tunnel service, no relay of your own. Only a failed hole-punch falls back to a relay, which carries ciphertext it cannot read.</p>
|
|
75
|
+
</td>
|
|
76
|
+
<td width="50%" valign="top">
|
|
77
|
+
<h3>DSH's own interface</h3>
|
|
78
|
+
<p>What runs on your phone is DSH's own web interface, not a reimplementation: conversations, tool calls, approvals and settings are all there. Narrow screens get adjustments — the sidebar becomes an overlay drawer, the settings dialog puts its navigation on top, and content takes the full width.</p>
|
|
79
|
+
</td>
|
|
80
|
+
</tr>
|
|
81
|
+
<tr>
|
|
82
|
+
<td width="50%" valign="top">
|
|
83
|
+
<h3>Several machines</h3>
|
|
84
|
+
<p><em>My computers</em>, at the bottom of the phone's sidebar, keeps every machine you have paired: switch, rename, remove, or add another. Changing machines never means re-entering credentials, and <em>Connect phone</em> on the computer mints a fresh code any time.</p>
|
|
85
|
+
</td>
|
|
86
|
+
<td width="50%" valign="top">
|
|
87
|
+
<h3>Approval notifications</h3>
|
|
88
|
+
<p>When the agent is waiting on your approval, the phone raises a system notification. You approve inside DSH's own interface — this plugin deliberately does not answer approvals for you; it only carries "it's waiting for you" to your lock screen.</p>
|
|
89
|
+
</td>
|
|
90
|
+
</tr>
|
|
91
|
+
</table>
|
|
92
|
+
|
|
93
|
+
## Security
|
|
94
|
+
|
|
95
|
+
- Pairing is a 6-digit CSPRNG code, valid 10 minutes, 3 attempts per window. After pairing, access is granted by the phone's iroh public key, which iroh's TLS verifies and nobody can forge.
|
|
96
|
+
- An unpaired peer cannot reach your interface: proxy streams are only served on a connection that already completed the control-stream handshake, and an unpaired connection may send at most 512 bytes before being rejected.
|
|
97
|
+
- Traffic is end-to-end encrypted by iroh (QUIC/TLS). When hole-punching succeeds it touches no third party; when it falls back, the relay only carries ciphertext it cannot read.
|
|
98
|
+
- The plugin's own two HTTP routes apply the same browser-trust rules dsh applies to `/api`: the `Host` must be a loopback authority, an explicit cross-site marker is refused, and an attached `Origin` must match the Host. Cross-site requests from a malicious page and DNS-rebinding attempts both get a 403.
|
|
99
|
+
- **Known limit**: those rules stop a browser from being used as a confused deputy; they do **not** stop a local process. A local process presents a loopback `Host`, so it can mint a pairing code and pair itself as a "phone" — meaning **an attacker who can already run code on your dev machine can turn that into long-term access**. Don't run this plugin on a machine where you run untrusted code.
|
|
100
|
+
|
|
101
|
+
## Known limitations
|
|
102
|
+
|
|
103
|
+
- Android only. iOS is not planned for now.
|
|
104
|
+
- The app connects when you open it and holds no background connection — Android's doze would not let it anyway.
|
|
105
|
+
- The interface on the phone is DSH's own; the narrow-screen fit comes from minimal injected styles, so a dsh layout change may need a follow-up here.
|
|
106
|
+
- Verified against dsh **`0.1.0-rc.7`**. dsh is in developer preview — check this line before assuming a newer dsh works.
|
|
107
|
+
|
|
108
|
+
## What has been verified
|
|
109
|
+
|
|
110
|
+
Direct cross-network connection is the whole point of the project, so here is the evidence rather than the claim. With the phone's Wi-Fi off and only 5G, and the machine on home broadband, the plugin reported a public address on the selected path — meaning the two ends punched through to each other and the relay was never used. The first screenshot above is the phone rendering DSH over that path; note the absent Wi-Fi icon in its status bar.
|
|
111
|
+
|
|
112
|
+
Pairing (including a wrong code being refused), approval delivery, and switching between saved machines were all verified on a real device. **The relay fallback has not yet been triggered on a real network.**
|
|
113
|
+
|
|
114
|
+
## Relationship to DeepSeek Harness
|
|
115
|
+
|
|
116
|
+
<p align="center"><img src="assets/dsh-mark.svg" height="36" alt="DeepSeek Harness"></p>
|
|
117
|
+
|
|
118
|
+
DSH Tether is an independent community project built on [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and its Cordis plugin mechanism. It **does not modify upstream source**: a pinned dsh runs unmodified, and this project joins it as an ordinary DSH plugin using only officially published extension points.
|
|
119
|
+
|
|
120
|
+
This repository is maintained independently by the community. It is not affiliated with, partnered with, authorised by, or endorsed by DeepSeek, and no DeepSeek employee or upstream team member is involved in its development, maintenance or governance. The DeepSeek Harness mark used in this README identifies the upstream this project serves; it does not imply any authorisation or endorsement. The phone app icon derives from that same upstream mark.
|
|
121
|
+
|
|
122
|
+
Upstream provides the agent capabilities, the plugin system and the web interface. This project provides:
|
|
123
|
+
|
|
124
|
+
- the peer-to-peer connection and pairing between machine and phone
|
|
125
|
+
- carrying that web interface to the phone, with a narrow-screen fit
|
|
126
|
+
- multi-machine management and system notifications on the phone
|
|
127
|
+
|
|
128
|
+
## Building from source
|
|
129
|
+
|
|
130
|
+
Needs Node ^22.19 || >=24 and Rust; building the APK also needs JDK 21 and the Android SDK/NDK (`scripts/setup-android-env.ps1` sets that up on Windows).
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
cargo build --release -p tether-host # the machine-side sidecar
|
|
134
|
+
cd app && pnpm install
|
|
135
|
+
pnpm exec tauri android build --apk --target aarch64
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<h1 align="center">DSH Tether</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>在手机上用你开发机里的 DeepSeek Harness。</strong><br>
|
|
5
|
+
跨网络,点对点直连,中间不经过任何服务器。<br>
|
|
6
|
+
不用配中转,不用同一个 WiFi,不用在手机上装 Linux。
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center"><sub>独立的社区开源项目,与深度求索不存在隶属、合作、授权或背书关系。<br>本仓库无深度求索员工或 DeepSeek Harness 上游官方团队成员参与。<br>中文 · <a href="README.md">English</a></sub></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<img src="assets/banner.jpg" alt="手机与开发机直接相连,中间没有服务器" width="100%">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="../../releases/latest"><img src="https://img.shields.io/github/v/release/zexadev/dsh-tether?style=flat&label=release&color=4D6BFE" alt="最新版本"></a>
|
|
17
|
+
<a href="../../releases"><img src="https://img.shields.io/github/downloads/zexadev/dsh-tether/total?style=flat&label=downloads&color=4D6BFE" alt="下载量"></a>
|
|
18
|
+
<a href="../../stargazers"><img src="https://img.shields.io/github/stars/zexadev/dsh-tether?style=flat&label=%E2%98%85&color=08C" alt="Stars"></a>
|
|
19
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat" alt="MIT"></a>
|
|
20
|
+
<img src="https://img.shields.io/badge/dsh-0.1.0--rc.7-4D6BFE?style=flat" alt="dsh 0.1.0-rc.7">
|
|
21
|
+
<img src="https://img.shields.io/badge/Android-4493F8?style=flat" alt="Android">
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="assets/phone-cellular.png" width="240" alt="手机上的 DSH 会话界面">
|
|
26
|
+
<img src="assets/phone-sidebar-drawer.png" width="240" alt="侧栏以抽屉方式覆盖">
|
|
27
|
+
<img src="assets/phone-settings.png" width="240" alt="设置页按手机版式重排">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
DSH Tether 把 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 Web 界面通过点对点直连带到手机上。agent 依旧跑在你代码所在的那台电脑,手机拿到的是 DSH 自己的完整界面——会话、工具调用、审批、设置全都在,不是另做一套。用 6 位码配对一次,之后两端在哪个网络都能互相找到。
|
|
31
|
+
|
|
32
|
+
## 它解决什么
|
|
33
|
+
|
|
34
|
+
**你不在电脑那个网络里,又不想中间有一台服务器。**
|
|
35
|
+
|
|
36
|
+
手机连回自己开发机,常见做法要么要求两端在同一个局域网,要么要求你部署并信任一台中转。这个项目两样都不要:两端靠 [iroh](https://www.iroh.computer/) 直接打洞,打通后流量不经过任何第三方;打不通才退回 relay,而 relay 上过的也只是它读不懂的密文。
|
|
37
|
+
|
|
38
|
+
如果你只在和电脑同一个 WiFi 下用手机,你并不需要这些——局域网方案更简单。
|
|
39
|
+
|
|
40
|
+
## 下载与安装
|
|
41
|
+
|
|
42
|
+
| 装在哪 | 下载 | 安装方式 |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| 电脑(插件) | — | `dsh plugin --profile web add github:zexadev/dsh-tether` |
|
|
45
|
+
| 电脑(sidecar) | [Release](../../releases/latest) 里的 `dsh-tether-host-<平台>-<版本>.tgz` | `dsh plugin --profile web add ./下载到的.tgz` |
|
|
46
|
+
| 手机 | [Release](../../releases/latest) 里的 `app-universal-release.apk` | 已签名,直接安装 |
|
|
47
|
+
|
|
48
|
+
sidecar 是插件带的一个 Rust 二进制,负责 iroh 连接。**它目前随 Release 分发,尚未发布到 npm**,所以要单独装一次;发布到 npm 之后第一条命令就够了。
|
|
49
|
+
|
|
50
|
+
也可以从源码构建 sidecar(需要 Rust):
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
git clone https://github.com/zexadev/dsh-tether && cd dsh-tether
|
|
54
|
+
cargo build --release -p tether-host
|
|
55
|
+
dsh plugin --profile web add .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 配对
|
|
59
|
+
|
|
60
|
+
电脑上照常启动 `dsh web`,在侧栏底部点**「连接手机」**,会给出一行配对串:
|
|
61
|
+
|
|
62
|
+
<p align="center"><img src="assets/pc-pairing.png" width="620" alt="电脑上出示配对串"></p>
|
|
63
|
+
|
|
64
|
+
手机上打开 DSH Tether →「添加电脑」→ 把那一整行粘进去 → 给这台电脑起个名字 → 连接。
|
|
65
|
+
|
|
66
|
+
之后每次打开 App 自动连上,不用再配对。
|
|
67
|
+
|
|
68
|
+
## 主要功能
|
|
69
|
+
|
|
70
|
+
<table>
|
|
71
|
+
<tr>
|
|
72
|
+
<td width="50%" valign="top">
|
|
73
|
+
<h3>跨网直连</h3>
|
|
74
|
+
<p>两端靠 iroh 直接打洞,打通后流量不经过任何第三方。手机在 4G/5G、电脑在家宽也能连上,不需要公网 IP、内网穿透或自建中转。打洞失败才退回 relay,而 relay 上过的只是它读不懂的密文。</p>
|
|
75
|
+
</td>
|
|
76
|
+
<td width="50%" valign="top">
|
|
77
|
+
<h3>完整的 DSH 界面</h3>
|
|
78
|
+
<p>手机上跑的就是 DSH 自己的 Web 界面,不是另做一套:会话、工具调用、审批、设置一个不少。窄屏下做了适配——侧栏改成抽屉覆盖、设置页导航横排、内容占满整宽。</p>
|
|
79
|
+
</td>
|
|
80
|
+
</tr>
|
|
81
|
+
<tr>
|
|
82
|
+
<td width="50%" valign="top">
|
|
83
|
+
<h3>多台电脑</h3>
|
|
84
|
+
<p>手机侧栏底部的「我的电脑」里,配过的电脑都留着,可以切换、改名、删除,也能添加新的。换一台不用重新填凭证。电脑侧栏的「连接手机」随时能出新的配对码。</p>
|
|
85
|
+
</td>
|
|
86
|
+
<td width="50%" valign="top">
|
|
87
|
+
<h3>审批通知</h3>
|
|
88
|
+
<p>agent 卡在等你批准时,手机会推一条系统通知。批准本身在 DSH 自己的界面里点——本插件刻意不代答审批,只把「它在等你」送到锁屏上。</p>
|
|
89
|
+
</td>
|
|
90
|
+
</tr>
|
|
91
|
+
</table>
|
|
92
|
+
|
|
93
|
+
## 安全
|
|
94
|
+
|
|
95
|
+
- 配对码是 6 位随机数(CSPRNG),10 分钟有效,每个窗口只许试 3 次。配对之后凭手机的 iroh 公钥授权,那是 TLS 验证过的,伪造不了。
|
|
96
|
+
- 未配对方够不到你的界面:代理流只在完成控制流握手的连接上提供,未配对连接首行最多 512 字节就被拒。
|
|
97
|
+
- 流量由 iroh 端到端加密(QUIC/TLS)。打洞成功时不经过任何第三方;退回 relay 时,relay 上过的也只是它读不懂的密文。
|
|
98
|
+
- 插件自己那两条 HTTP 路由套了与 dsh `/api` 同一套浏览器信任判据:Host 必须是回环权威,跨站标记一律拒,带 Origin 时必须与 Host 同源。恶意网页的跨站请求和 DNS 重绑定都会拿到 403。
|
|
99
|
+
- **已知边界**:上面那套判据防的是浏览器被当枪使,**挡不住本机进程**。本机进程的 Host 就是回环,能取到一个配对码,进而把自己配成一台「手机」。也就是说,**已经能在你开发机上执行代码的攻击者,可以借此拿到长期访问权**。若你的机器上会跑不受信任的代码,别用这个插件。
|
|
100
|
+
|
|
101
|
+
## 已知限制
|
|
102
|
+
|
|
103
|
+
- 只有 Android。iOS 暂不做。
|
|
104
|
+
- App 打开时才连接,不在后台常驻——Android 的 doze 也留不住它。
|
|
105
|
+
- 手机上的界面是 DSH 自己的,窄屏适配靠注入的最小样式完成;dsh 改版式时可能需要跟进。
|
|
106
|
+
- 已针对 dsh **`0.1.0-rc.7`** 验证。dsh 处于 developer preview,换更新的 dsh 之前先看这一行。
|
|
107
|
+
|
|
108
|
+
## 验证过什么
|
|
109
|
+
|
|
110
|
+
跨网直连是这个项目的立意,所以给证据而不是给说法。手机关掉 WiFi 只走 5G、电脑在家宽,插件侧输出:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
[tether] 手机已连接: 我的手机
|
|
114
|
+
[tether] 连接路径: P2P 直连(NAT 打洞成功) Ip(…:41341)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
选中路径是公网地址,说明两端是打洞打通的,relay 全程没用上。上面第一张截图就是手机在这条路径上渲染的 DSH 界面——状态栏没有 WiFi 图标。
|
|
118
|
+
|
|
119
|
+
配对(含错码被拒)、审批送达、多设备切换都在真机上验证过。**打洞失败退回 relay 这条路径,尚未在真实网络下触发过。**
|
|
120
|
+
|
|
121
|
+
## 与 DeepSeek Harness 的关系
|
|
122
|
+
|
|
123
|
+
<p align="center"><img src="assets/dsh-mark.svg" height="36" alt="DeepSeek Harness"></p>
|
|
124
|
+
|
|
125
|
+
DSH Tether 是基于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 与 Cordis 插件机制构建的独立社区项目。它**不修改上游源码**:固定版本的 dsh 原样运行,本项目作为一个普通 DSH 插件接入,只依赖官方公开的扩展点。
|
|
126
|
+
|
|
127
|
+
本仓库由社区独立维护,与深度求索不存在隶属、合作、授权或背书关系,也无深度求索员工或上游官方团队成员参与开发、维护或治理。README 中使用的 DeepSeek Harness 标识仅用于说明本项目服务于该上游,不代表任何授权或背书关系;手机应用图标同样取自该上游标识。
|
|
128
|
+
|
|
129
|
+
上游提供智能体能力、插件系统和 Web 界面;本项目负责:
|
|
130
|
+
|
|
131
|
+
- 电脑与手机之间的点对点连接与配对
|
|
132
|
+
- 把上游的 Web 界面带到手机上,并做窄屏适配
|
|
133
|
+
- 手机端的多设备管理与系统通知
|
|
134
|
+
|
|
135
|
+
## 从源码构建
|
|
136
|
+
|
|
137
|
+
需要 Node ^22.19 || >=24、Rust;构建 APK 另需 JDK 21 与 Android SDK/NDK(Windows 上可用 `scripts/setup-android-env.ps1` 装齐)。
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
cargo build --release -p tether-host # 电脑侧 sidecar
|
|
141
|
+
cd app && pnpm install
|
|
142
|
+
pnpm exec tauri android build --apk --target aarch64
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 许可证
|
|
146
|
+
|
|
147
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 手机客户端的组合层。
|
|
2
|
+
- insert:
|
|
3
|
+
- id: dsh-tether
|
|
4
|
+
name: dsh-plugin-tether
|
|
5
|
+
# 目录选择器换成「应用内浏览」后端(下面同时停掉默认的自动选择器)。
|
|
6
|
+
# 两半都要挂:host 半边提供 ctx.directoryPicker,client 半边填 ui-workspace
|
|
7
|
+
# 的目录流插槽;只挂 host 半边的话侧栏「添加工作区」按钮会整个消失。
|
|
8
|
+
- id: directory-picker-browse
|
|
9
|
+
name: '@deepseek-ai/dsh-host-directory-picker-browse'
|
|
10
|
+
- id: directory-picker-browse-client
|
|
11
|
+
name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'
|
|
12
|
+
|
|
13
|
+
# 默认的 -auto 在启动时一次性判定:看到 loopback 绑定 + 有显示会话就选 -native,
|
|
14
|
+
# 于是「添加工作区」在电脑上弹出系统对话框,手机端什么也看不到、也点不了。
|
|
15
|
+
# 手机经隧道出口访问,来源正是 127.0.0.1,必然命中这条误判——上游文档把这种
|
|
16
|
+
# 「经转发从本机地址进来」的情形列为已知限制,并指明直接组合 -browse 是解法。
|
|
17
|
+
# 选择器与后端同时挂会因 directoryPicker 服务重复而 fail loud,故必须停掉它。
|
|
18
|
+
# name 在 patch 里是断言不是覆盖:不匹配则整条跳过,正好用来防上游换实现时静默错配。
|
|
19
|
+
- id: directory-picker
|
|
20
|
+
name: '@deepseek-ai/dsh-host-directory-picker-auto'
|
|
21
|
+
disabled: true
|
package/index.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 手机客户端:电脑侧插件。sidecar(tether-host)内嵌 iroh endpoint,
|
|
3
|
+
* 把本机 dsh web UI 经 P2P 直连带到已配对手机上。
|
|
4
|
+
*
|
|
5
|
+
* 审批不由本插件认领:手机上跑的就是完整 web UI,`dsh-host-apiproxy` 是它的终端
|
|
6
|
+
* answerer,认领会把请求从浏览器手里抢走。这里只观察后 next(),并把待审批事件转给
|
|
7
|
+
* 手机用于系统通知——人不看手机就不知道 agent 卡住了,通知才是移动端不可替代的部分。
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
import { createInterface } from 'node:readline'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const name = 'dsh-plugin-tether'
|
|
18
|
+
const inject = ['webServer', 'settings']
|
|
19
|
+
|
|
20
|
+
/** 手机侧只读查看配置文件的路由 */
|
|
21
|
+
const CONFIG_DOCUMENT_PATH = '/dsh-tether/config-document'
|
|
22
|
+
/** 按需开一个配对窗口,拿回配对串 */
|
|
23
|
+
const PAIRING_PATH = '/dsh-tether/pairing'
|
|
24
|
+
|
|
25
|
+
/** dsh 的回环判定:localhost、IPv6 回环、以及整个 127/8 */
|
|
26
|
+
function isLoopbackHostname(hostname) {
|
|
27
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
28
|
+
const parts = hostname.split('.')
|
|
29
|
+
return parts.length === 4 && parts[0] === '127'
|
|
30
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 浏览器信任栅栏,判据照抄 dsh 自己 `/api` 那套(它的 isTrustedApiRequest 没有
|
|
35
|
+
* 从包里导出,只能同判据重写一份):Host 必须是回环权威;带 `sec-fetch-site:
|
|
36
|
+
* cross-site` 标记的一律拒;带 Origin 时其 authority 必须与 Host 相同。
|
|
37
|
+
*
|
|
38
|
+
* 挡的是浏览器打开的两条「受骗代理」路径:DNS 重绑定(Host 写攻击者域名而套接字
|
|
39
|
+
* 落到本机)和恶意页面发出的跨站请求。**挡不住本机进程** —— 本机 curl 的 Host
|
|
40
|
+
* 就是回环,照样通过,这是这套栅栏的设计边界,不是实现疏漏。
|
|
41
|
+
*
|
|
42
|
+
* 这里不接受 trustedHosts:本插件的路由只服务本机,没有远程授权的语义。
|
|
43
|
+
*/
|
|
44
|
+
function isTrustedRequest(req) {
|
|
45
|
+
const host = req.headers.host
|
|
46
|
+
if (typeof host !== 'string') return false
|
|
47
|
+
let hostUrl
|
|
48
|
+
try {
|
|
49
|
+
hostUrl = new URL(`http://${host}`)
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
54
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
55
|
+
const origin = req.headers.origin
|
|
56
|
+
if (typeof origin !== 'string') return true
|
|
57
|
+
try {
|
|
58
|
+
return new URL(origin).host === hostUrl.host
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 未通过栅栏时统一的拒绝应答,和 dsh 的 /api 一样是裸 403,不泄露路由细节 */
|
|
65
|
+
function refuse(res) {
|
|
66
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
67
|
+
res.end('forbidden')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
72
|
+
* @param {{hostBinary?: string, pair?: boolean}} [config]
|
|
73
|
+
*/
|
|
74
|
+
function apply(ctx, config = {}) {
|
|
75
|
+
const binary = config.hostBinary ?? resolveHostBinary()
|
|
76
|
+
if (binary === undefined || !existsSync(binary)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`[tether] 找不到 sidecar 二进制(平台 ${process.platform}-${process.arch})。`
|
|
79
|
+
+ '从源码用:先跑 cargo build --release -p tether-host;'
|
|
80
|
+
+ '或在插件 config.hostBinary 里指定路径。'
|
|
81
|
+
+ '若你是从 npm/GitHub 安装的,说明该平台的 dsh-tether-host-* 子包还没发布,请提 issue。',
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
// webServer.port 是真实监听端口(config 写 0 时为 OS 分配值),手机由此拿到完整界面
|
|
85
|
+
const proxyTarget = `${ctx.webServer.host}:${ctx.webServer.port}`
|
|
86
|
+
|
|
87
|
+
const args = ['host', '--proxy-target', proxyTarget]
|
|
88
|
+
if (config.pair) args.push('--pair')
|
|
89
|
+
const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'inherit'] })
|
|
90
|
+
ctx.effect(() => () => { child.kill() })
|
|
91
|
+
child.on('exit', (code) => {
|
|
92
|
+
// sidecar 死了必须可见:手机端会静默失联,而电脑侧一切照常
|
|
93
|
+
console.error(`[tether] sidecar 退出 code=${code};手机端已失联`)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const send = (msg) => { child.stdin.write(JSON.stringify(msg) + '\n') }
|
|
97
|
+
let endpointId = ''
|
|
98
|
+
/** 等着拿配对串的那个请求;sidecar 回 pairing 时兑现 */
|
|
99
|
+
let pendingPairing
|
|
100
|
+
|
|
101
|
+
createInterface({ input: child.stdout }).on('line', (line) => {
|
|
102
|
+
let msg
|
|
103
|
+
try {
|
|
104
|
+
msg = JSON.parse(line)
|
|
105
|
+
} catch {
|
|
106
|
+
console.error(`[tether] 无法解析 sidecar 消息: ${line}`)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
switch (msg.type) {
|
|
110
|
+
case 'ready':
|
|
111
|
+
endpointId = msg['endpoint_id']
|
|
112
|
+
console.log(`[tether] 就绪,本机设备 ID: ${endpointId}`)
|
|
113
|
+
console.log(`[tether] 手机将看到 http://${proxyTarget} 的完整界面`)
|
|
114
|
+
break
|
|
115
|
+
case 'pairing': {
|
|
116
|
+
// 一行可整体粘贴到手机,省去分别输 ID 和码
|
|
117
|
+
const pairingString = `${endpointId}#${msg.code}`
|
|
118
|
+
console.log(`[tether] 配对串(${Math.round(msg['expires_in_sec'] / 60)} 分钟内有效): ${pairingString}`)
|
|
119
|
+
const waiter = pendingPairing
|
|
120
|
+
pendingPairing = undefined
|
|
121
|
+
waiter?.({ pairingString, expiresInSec: msg['expires_in_sec'] })
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
case 'pairing-closed':
|
|
125
|
+
console.log(`[tether] 配对窗口已关闭: ${msg.reason}`)
|
|
126
|
+
break
|
|
127
|
+
case 'pairing-done':
|
|
128
|
+
console.log(`[tether] 配对成功: ${msg.name}(${msg.peer.slice(0, 16)}…)`)
|
|
129
|
+
break
|
|
130
|
+
case 'peer-connected':
|
|
131
|
+
console.log(`[tether] 手机已连接: ${msg.name}`)
|
|
132
|
+
break
|
|
133
|
+
case 'peer-path':
|
|
134
|
+
console.log(msg.kind === 'direct'
|
|
135
|
+
? `[tether] 连接路径: P2P 直连(NAT 打洞成功) ${msg.remote}`
|
|
136
|
+
: `[tether] 连接路径: relay 中转(打洞未成,可用但延迟略高) ${msg.remote}`)
|
|
137
|
+
break
|
|
138
|
+
case 'peer-disconnected':
|
|
139
|
+
console.log('[tether] 手机已断开')
|
|
140
|
+
break
|
|
141
|
+
default:
|
|
142
|
+
console.error(`[tether] 未知 sidecar 消息: ${line}`)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
ctx.effect(() => ctx.webServer.tapIndex(injectNarrowScreenCss))
|
|
147
|
+
|
|
148
|
+
// 「打开配置文件」在宿主机桌面开编辑器,手机上按了毫无反应。这条路由把同一份
|
|
149
|
+
// 文件的内容原样交给手机自己渲染。路径由 Host 侧的 settings.documentPath 决定,
|
|
150
|
+
// 不接受客户端传路径——否则就成了任意文件读取。
|
|
151
|
+
ctx.effect(() => ctx.webServer.register({
|
|
152
|
+
kind: 'exact',
|
|
153
|
+
path: CONFIG_DOCUMENT_PATH,
|
|
154
|
+
handler: async (req, res) => {
|
|
155
|
+
if (!isTrustedRequest(req)) return refuse(res)
|
|
156
|
+
const path = await ctx.settings.prepareDocument()
|
|
157
|
+
if (path === undefined) {
|
|
158
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
159
|
+
res.end('当前的设置存储不是本地文件,没有可查看的配置文件')
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
let text
|
|
163
|
+
try {
|
|
164
|
+
text = await readFile(path, 'utf8')
|
|
165
|
+
} catch (error) {
|
|
166
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
|
|
167
|
+
res.end(`读不到配置文件 ${path}: ${String(error)}`)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
'content-type': 'application/json; charset=utf-8',
|
|
172
|
+
'cache-control': 'no-store',
|
|
173
|
+
})
|
|
174
|
+
res.end(JSON.stringify({ path, text }))
|
|
175
|
+
},
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
// 配对窗口只在白名单为空时自动开一次;换手机、加第二台设备都得能再开一个,
|
|
179
|
+
// 否则用户只能去手工删 paired.json。这条路由就是那个开关。
|
|
180
|
+
ctx.effect(() => ctx.webServer.register({
|
|
181
|
+
kind: 'exact',
|
|
182
|
+
path: PAIRING_PATH,
|
|
183
|
+
handler: async (req, res) => {
|
|
184
|
+
if (!isTrustedRequest(req)) return refuse(res)
|
|
185
|
+
const result = await new Promise((resolve) => {
|
|
186
|
+
pendingPairing = resolve
|
|
187
|
+
send({ type: 'pairing-begin' })
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
if (pendingPairing !== resolve) return
|
|
190
|
+
pendingPairing = undefined
|
|
191
|
+
resolve(undefined)
|
|
192
|
+
}, 5000)
|
|
193
|
+
})
|
|
194
|
+
if (result === undefined) {
|
|
195
|
+
res.writeHead(504, { 'content-type': 'text/plain; charset=utf-8' })
|
|
196
|
+
res.end('sidecar 没有在 5 秒内给出配对码')
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
200
|
+
res.end(JSON.stringify(result))
|
|
201
|
+
},
|
|
202
|
+
}))
|
|
203
|
+
|
|
204
|
+
ctx.on('approval/request', async (req, next) => {
|
|
205
|
+
send({ type: 'approval', id: req.callId, tool_name: req.toolName ?? '', reason: req.reason ?? '' })
|
|
206
|
+
try {
|
|
207
|
+
return await next()
|
|
208
|
+
} finally {
|
|
209
|
+
// 决定已出(通常是手机上那个 web UI 自己答的),撤掉通知
|
|
210
|
+
send({ type: 'approval-cancel', id: req.callId })
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 窄屏修正:只在 ≤640px 生效,桌面浏览器拿到同一份 index.html 也不受影响。
|
|
217
|
+
* 只用语义选择器(role/aria),不碰 dsh 的 CSS Module 类名——那些名字带内容
|
|
218
|
+
* 哈希,dsh 一改样式就会变,针对它们写规则必碎。
|
|
219
|
+
*/
|
|
220
|
+
function injectNarrowScreenCss(html) {
|
|
221
|
+
const css = `
|
|
222
|
+
@media (max-width: 640px) {
|
|
223
|
+
/* hero 背后的装饰性发光椭圆比视口宽,会让整页能被横向拖动 */
|
|
224
|
+
html, body { overflow-x: hidden; }
|
|
225
|
+
/* 弹窗在窄屏铺满,别再留出用不上的边距 */
|
|
226
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) {
|
|
227
|
+
width: 100% !important;
|
|
228
|
+
max-width: 100% !important;
|
|
229
|
+
height: 100% !important;
|
|
230
|
+
max-height: 100% !important;
|
|
231
|
+
margin: 0 !important;
|
|
232
|
+
border-radius: 0 !important;
|
|
233
|
+
}
|
|
234
|
+
/* 设置行是「说明文字 + 控件」一行排开,说明列 min-width:0,窄屏被控件挤到
|
|
235
|
+
2px 宽,中文于是一字一行。让说明占满一行、控件换到下一行。
|
|
236
|
+
选择器咬的是 CSS Module 的本地名(row/rowText),只有哈希前缀会随构建变;
|
|
237
|
+
dsh 若重命名,规则失效退回现状,不会更糟。 */
|
|
238
|
+
[class*="_row"]:not([class*="rowText"]) { flex-wrap: wrap !important; }
|
|
239
|
+
[class*="rowText"] { flex: 1 1 100% !important; }
|
|
240
|
+
|
|
241
|
+
/* 弹窗是「左导航 + 右内容」横向排列,导航固定 188px——在 412px 的手机上
|
|
242
|
+
占掉 45%,内容只剩两百出头。改成导航横排在顶、内容占满整宽。
|
|
243
|
+
导航是 <nav> 标签、内容是弹窗下唯一的直接 div,都用结构选择器命中。 */
|
|
244
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) { display: flex !important; flex-direction: column !important; }
|
|
245
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) > nav {
|
|
246
|
+
width: 100% !important;
|
|
247
|
+
height: auto !important;
|
|
248
|
+
flex: none !important;
|
|
249
|
+
}
|
|
250
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) > nav [class*="navList"] {
|
|
251
|
+
flex-direction: row !important;
|
|
252
|
+
overflow-x: auto !important;
|
|
253
|
+
height: auto !important;
|
|
254
|
+
gap: 6px;
|
|
255
|
+
scrollbar-width: none;
|
|
256
|
+
}
|
|
257
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) > nav [class*="navList"]::-webkit-scrollbar { display: none; }
|
|
258
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) > nav [class*="navCell"] {
|
|
259
|
+
flex: none !important;
|
|
260
|
+
width: auto !important;
|
|
261
|
+
white-space: nowrap;
|
|
262
|
+
}
|
|
263
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) > div {
|
|
264
|
+
width: 100% !important;
|
|
265
|
+
flex: 1 1 auto !important;
|
|
266
|
+
min-height: 0 !important;
|
|
267
|
+
}
|
|
268
|
+
/* 侧栏展开时主框架把网格从「56px 内容」改成「280px 内容」,对话区被压到
|
|
269
|
+
140px。改成抽屉:网格保持收起时的列宽,侧栏浮到内容之上盖住一部分。
|
|
270
|
+
展开态没有专门的类名,但展开才会出现拖拽把手,用它作状态信号。
|
|
271
|
+
把手本身在手机上没用(没法拖),隐掉。 */
|
|
272
|
+
/* 网格恒定为收起时的列宽,侧栏恒定绝对定位、只让宽度随展开态变化——
|
|
273
|
+
position 不可过渡,只有始终 absolute 才能做出滑入滑出的动画。
|
|
274
|
+
侧栏脱离文档流后剩下的子元素会整体上移一列(对话区掉进 56px 那列,
|
|
275
|
+
反而更窄),所以显式钉住列号。 */
|
|
276
|
+
[class*="_frame"] {
|
|
277
|
+
grid-template-columns: 56px minmax(0, 1fr) 0px !important;
|
|
278
|
+
}
|
|
279
|
+
[class*="_frame"] > [class*="centerCol"] { grid-column: 2 !important; }
|
|
280
|
+
[class*="_frame"] > [class*="detailsCol"] { grid-column: 3 !important; }
|
|
281
|
+
[class*="_frame"] > [class*="sidebarCol"] {
|
|
282
|
+
position: absolute !important;
|
|
283
|
+
top: 0;
|
|
284
|
+
bottom: 0;
|
|
285
|
+
left: 0;
|
|
286
|
+
width: 56px !important;
|
|
287
|
+
z-index: 30 !important;
|
|
288
|
+
transition: width 220ms ease, box-shadow 220ms ease;
|
|
289
|
+
}
|
|
290
|
+
/* 展开态没有专门的类名,但展开才会出现拖拽把手,用它作状态信号 */
|
|
291
|
+
[class*="_frame"]:has(> [class*="_handle"]) > [class*="sidebarCol"] {
|
|
292
|
+
width: min(300px, 84vw) !important;
|
|
293
|
+
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.24);
|
|
294
|
+
}
|
|
295
|
+
/* 抽屉下的内容压暗。指针不拦——收起由注入的脚本接管,拦了反而会在脚本
|
|
296
|
+
未生效时把整页点死。 */
|
|
297
|
+
[class*="_frame"]::after {
|
|
298
|
+
content: '';
|
|
299
|
+
position: absolute;
|
|
300
|
+
inset: 0;
|
|
301
|
+
z-index: 25;
|
|
302
|
+
background: rgba(0, 0, 0, 0.28);
|
|
303
|
+
opacity: 0;
|
|
304
|
+
pointer-events: none;
|
|
305
|
+
transition: opacity 220ms ease;
|
|
306
|
+
}
|
|
307
|
+
[class*="_frame"]:has(> [class*="_handle"])::after { opacity: 1; }
|
|
308
|
+
[class*="_frame"] > [class*="_handle"] { display: none !important; }
|
|
309
|
+
|
|
310
|
+
/* 侧栏收起时是图标条,「主机」按钮的文字跟着藏起来,和 dsh 自己的按钮一致 */
|
|
311
|
+
[data-dsh-tether="hosts-label"] { display: none; }
|
|
312
|
+
[class*="_frame"]:has(> [class*="_handle"]) [data-dsh-tether="hosts-label"] { display: inline; }
|
|
313
|
+
@media (prefers-reduced-motion: reduce) {
|
|
314
|
+
[class*="_frame"] > [class*="sidebarCol"],
|
|
315
|
+
[class*="_frame"]::after { transition: none; }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* 触摸时安卓 WebView 会按元素的 DOM 盒子刷一块蓝色高亮,方角、盖住圆角,
|
|
319
|
+
和 dsh 自己的按下态叠在一起很脏。关掉它,按下反馈交给页面自己的样式。 */
|
|
320
|
+
* { -webkit-tap-highlight-color: transparent; }
|
|
321
|
+
|
|
322
|
+
/* 竖排之后,内容区自带的头部(关闭键)会落到标签栏下方、屏幕
|
|
323
|
+
正中,很别扭。钉到弹窗右上角,与标题同一行——手机弹窗关闭键就该在那儿。 */
|
|
324
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) { position: relative !important; }
|
|
325
|
+
[role="dialog"][aria-modal="true"]:not([data-dsh-tether]) > div > [class*="_header"] {
|
|
326
|
+
position: absolute !important;
|
|
327
|
+
top: 10px;
|
|
328
|
+
right: 12px;
|
|
329
|
+
left: auto !important;
|
|
330
|
+
width: auto !important;
|
|
331
|
+
height: auto !important;
|
|
332
|
+
z-index: 2;
|
|
333
|
+
}
|
|
334
|
+
}`
|
|
335
|
+
// 抽屉遮罩只是画上去的,点它不会收起——那需要脚本。这段只做这一件事:
|
|
336
|
+
// 窄屏下点到抽屉外面就替用户按一次侧栏开关。
|
|
337
|
+
const script = `
|
|
338
|
+
(function () {
|
|
339
|
+
var narrow = window.matchMedia('(max-width: 640px)')
|
|
340
|
+
document.addEventListener('pointerdown', function (event) {
|
|
341
|
+
if (!narrow.matches) return
|
|
342
|
+
var frame = document.querySelector('[class*="_frame"]')
|
|
343
|
+
if (!frame || !frame.querySelector(':scope > [class*="_handle"]')) return
|
|
344
|
+
var drawer = frame.querySelector(':scope > [class*="sidebarCol"]')
|
|
345
|
+
if (!drawer || drawer.contains(event.target)) return
|
|
346
|
+
var toggle = document.querySelector('button[aria-label*="侧边栏"], button[aria-label*="sidebar" i]')
|
|
347
|
+
if (!toggle) return
|
|
348
|
+
// 这一下只用来收抽屉,不该顺带点到底下的东西
|
|
349
|
+
event.preventDefault()
|
|
350
|
+
event.stopPropagation()
|
|
351
|
+
toggle.click()
|
|
352
|
+
}, true)
|
|
353
|
+
|
|
354
|
+
// 「打开配置文件」会在宿主机桌面开编辑器,手机上按了毫无反应。窄屏下改成
|
|
355
|
+
// 就地渲染文件内容——只读,想改仍然去电脑上改。
|
|
356
|
+
// 色值/圆角/字重全部取自 dsh 自身的实测计算值,别在人家界面里插一块不一样的东西。
|
|
357
|
+
// 窄屏铺满一屏,宽屏收成居中卡片——和 dsh 的弹窗一个做法。
|
|
358
|
+
function viewer(title, body, error) {
|
|
359
|
+
var dark = matchMedia('(prefers-color-scheme: dark)').matches
|
|
360
|
+
var t = dark
|
|
361
|
+
? { fg: '#f9fafb', surface: '#2c2c2e', line: 'rgba(255,255,255,.12)', muted: '#adb2b8', danger: '#f5766b' }
|
|
362
|
+
: { fg: '#0f1115', surface: '#ffffff', line: 'rgba(0,0,0,.1)', muted: '#81858c', danger: '#b42318' }
|
|
363
|
+
|
|
364
|
+
var mask = document.createElement('div')
|
|
365
|
+
mask.setAttribute('data-dsh-tether', 'viewer')
|
|
366
|
+
mask.style.cssText = 'position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;'
|
|
367
|
+
+ 'font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:' + t.fg + ';'
|
|
368
|
+
+ (narrow.matches ? 'background:' + t.surface : 'background:rgba(0,0,0,.32);padding:24px')
|
|
369
|
+
|
|
370
|
+
var card = document.createElement('div')
|
|
371
|
+
card.setAttribute('data-dsh-tether', 'card')
|
|
372
|
+
card.setAttribute('role', 'dialog')
|
|
373
|
+
card.setAttribute('aria-modal', 'true')
|
|
374
|
+
card.style.cssText = 'display:flex;flex-direction:column;background:' + t.surface + ';'
|
|
375
|
+
+ (narrow.matches
|
|
376
|
+
? 'width:100%;height:100%'
|
|
377
|
+
: 'width:min(620px,100%);max-height:min(70vh,720px);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,.24)')
|
|
378
|
+
|
|
379
|
+
var bar = document.createElement('div')
|
|
380
|
+
bar.style.cssText = 'flex:none;display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid ' + t.line
|
|
381
|
+
var name = document.createElement('div')
|
|
382
|
+
name.style.cssText = 'flex:1;min-width:0;font-size:12px;color:' + t.muted
|
|
383
|
+
name.textContent = title
|
|
384
|
+
|
|
385
|
+
// dsh 的次要按钮就是这个样子:透明底 + 1px 描边 + 14 圆角 + 12px 常规字重
|
|
386
|
+
var slim = 'flex:none;font:inherit;font-size:12px;font-weight:400;padding:5px 12px;border-radius:14px;'
|
|
387
|
+
+ 'border:1px solid ' + t.line + ';background:transparent;color:inherit;cursor:pointer'
|
|
388
|
+
var copy = document.createElement('button')
|
|
389
|
+
copy.textContent = '复制'
|
|
390
|
+
copy.style.cssText = slim
|
|
391
|
+
copy.addEventListener('click', function () {
|
|
392
|
+
navigator.clipboard.writeText(body).then(
|
|
393
|
+
function () { copy.textContent = '已复制' },
|
|
394
|
+
function () { copy.textContent = '复制失败' },
|
|
395
|
+
)
|
|
396
|
+
})
|
|
397
|
+
var close = document.createElement('button')
|
|
398
|
+
close.textContent = '关闭'
|
|
399
|
+
close.style.cssText = slim
|
|
400
|
+
close.addEventListener('click', function () { mask.remove() })
|
|
401
|
+
bar.append(name)
|
|
402
|
+
if (!error) bar.append(copy)
|
|
403
|
+
bar.append(close)
|
|
404
|
+
|
|
405
|
+
var pre = document.createElement('pre')
|
|
406
|
+
pre.style.cssText = 'flex:1;margin:0;padding:16px;overflow:auto;white-space:pre-wrap;word-break:break-all;'
|
|
407
|
+
+ 'font:13px/1.8 ui-monospace,"SF Mono",Consolas,monospace' + (error ? ';color:' + t.danger : '')
|
|
408
|
+
pre.textContent = error ? error : body
|
|
409
|
+
|
|
410
|
+
card.append(bar, pre)
|
|
411
|
+
mask.append(card)
|
|
412
|
+
// 宽屏点遮罩关闭,窄屏没有遮罩可点
|
|
413
|
+
if (!narrow.matches) mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove() })
|
|
414
|
+
document.body.append(mask)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 侧栏底部的 sidebar.footer.action 插槽:官方留给第三方放自己入口的位置。
|
|
418
|
+
// 这里放「主机」按钮,点了让外层的手机客户端打开它自己的主机页。
|
|
419
|
+
// 同一个按钮两种归宿:手机 App 里打开它自己的主机页;电脑浏览器里出示配对串,
|
|
420
|
+
// 好让用户换手机、加设备时有地方拿新配对码(否则只能去删 paired.json)。
|
|
421
|
+
var framed = window.parent !== window
|
|
422
|
+
|
|
423
|
+
function showPairing() {
|
|
424
|
+
fetch('${PAIRING_PATH}')
|
|
425
|
+
.then(function (r) { return r.ok ? r.json() : r.text().then(function (t) { throw new Error(t) }) })
|
|
426
|
+
.then(function (d) {
|
|
427
|
+
viewer(
|
|
428
|
+
'在手机上「添加电脑」里粘贴整行,' + Math.round(d.expiresInSec / 60) + ' 分钟内有效',
|
|
429
|
+
d.pairingString,
|
|
430
|
+
)
|
|
431
|
+
})
|
|
432
|
+
.catch(function (e) { viewer('配对', '', String(e && e.message ? e.message : e)) })
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function mountHostsTrigger() {
|
|
436
|
+
var slot = document.querySelector('[data-slot="sidebar.footer.action"]')
|
|
437
|
+
if (!slot || slot.querySelector('[data-dsh-tether="hosts-trigger"]')) return
|
|
438
|
+
var settings = document.querySelector('[data-slot="settings.trigger"]')
|
|
439
|
+
var reference = settings && settings.closest ? settings.closest('button') : null
|
|
440
|
+
if (!reference) return
|
|
441
|
+
var button = document.createElement('button')
|
|
442
|
+
button.type = 'button'
|
|
443
|
+
button.setAttribute('data-dsh-tether', 'hosts-trigger')
|
|
444
|
+
var label = framed ? '我的电脑' : '连接手机'
|
|
445
|
+
button.setAttribute('aria-label', label)
|
|
446
|
+
// 类名带内容哈希,照抄隔壁「设置」按钮当前的,外观自然一致
|
|
447
|
+
button.className = reference.className
|
|
448
|
+
button.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span data-dsh-tether="hosts-label">' + label + '</span>'
|
|
449
|
+
button.addEventListener('click', function () {
|
|
450
|
+
if (framed) window.parent.postMessage({ type: 'dsh-tether:open-hosts' }, '*')
|
|
451
|
+
else showPairing()
|
|
452
|
+
})
|
|
453
|
+
slot.append(button)
|
|
454
|
+
// 侧栏展开/收起时「设置」按钮会换类名(rail 与否),跟着同步
|
|
455
|
+
new MutationObserver(function () { button.className = reference.className })
|
|
456
|
+
.observe(reference, { attributes: true, attributeFilter: ['class'] })
|
|
457
|
+
}
|
|
458
|
+
mountHostsTrigger()
|
|
459
|
+
new MutationObserver(mountHostsTrigger).observe(document.documentElement, { childList: true, subtree: true })
|
|
460
|
+
|
|
461
|
+
document.addEventListener('click', function (event) {
|
|
462
|
+
if (!narrow.matches) return
|
|
463
|
+
var target = event.target
|
|
464
|
+
var button = target && target.closest ? target.closest('button') : null
|
|
465
|
+
if (!button || !/打开配置文件|Open config|open the config/i.test(button.textContent || '')) return
|
|
466
|
+
event.preventDefault()
|
|
467
|
+
event.stopPropagation()
|
|
468
|
+
fetch('${CONFIG_DOCUMENT_PATH}')
|
|
469
|
+
.then(function (r) { return r.ok ? r.json() : r.text().then(function (t) { throw new Error(t) }) })
|
|
470
|
+
.then(function (d) { viewer(d.path, d.text) })
|
|
471
|
+
.catch(function (e) { viewer('配置文件', '', String(e && e.message ? e.message : e)) })
|
|
472
|
+
}, true)
|
|
473
|
+
})()`
|
|
474
|
+
return html.replace(
|
|
475
|
+
'</head>',
|
|
476
|
+
`<style data-dsh-tether="narrow-screen">${css}\n</style><script data-dsh-tether="narrow-screen">${script}\n</script></head>`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* sidecar 二进制的解析顺序:
|
|
482
|
+
* 1. 平台子包 `dsh-tether-host-<platform>-<arch>` —— 发布路径。子包声明 os/cpu,
|
|
483
|
+
* npm 只装匹配当前平台的那一个(dsh 自己的 node-addon-landlock-run 同款结构)。
|
|
484
|
+
* 2. 仓库内 cargo 产物 —— 开发路径,release 优先于 debug。
|
|
485
|
+
* 返回 undefined 表示两处都没有,由调用方给出可操作的报错。
|
|
486
|
+
*/
|
|
487
|
+
function resolveHostBinary() {
|
|
488
|
+
const exe = process.platform === 'win32' ? 'tether-host.exe' : 'tether-host'
|
|
489
|
+
const platformPackage = `dsh-tether-host-${process.platform}-${process.arch}`
|
|
490
|
+
try {
|
|
491
|
+
return createRequire(import.meta.url).resolve(`${platformPackage}/bin/${exe}`)
|
|
492
|
+
} catch {
|
|
493
|
+
// 未安装平台子包(源码使用或该平台尚未发布),继续找本地构建
|
|
494
|
+
}
|
|
495
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
496
|
+
for (const profile of ['release', 'debug']) {
|
|
497
|
+
const candidate = join(here, 'target', profile, exe)
|
|
498
|
+
if (existsSync(candidate)) return candidate
|
|
499
|
+
}
|
|
500
|
+
return undefined
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export { name, inject, apply }
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-tether",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reach the DeepSeek Harness on your dev machine from your phone — across networks, through no server at all (iroh P2P)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"android",
|
|
10
|
+
"p2p",
|
|
11
|
+
"iroh"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"README.md",
|
|
24
|
+
"README.zh.md"
|
|
25
|
+
],
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7",
|
|
33
|
+
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.7"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@deepseek-ai/dsh-host-webserver": { "optional": true },
|
|
37
|
+
"@deepseek-ai/dsh-user-approval": { "optional": true }
|
|
38
|
+
},
|
|
39
|
+
"optionalDependencies": {
|
|
40
|
+
"dsh-tether-host-darwin-arm64": "0.1.0",
|
|
41
|
+
"dsh-tether-host-darwin-x64": "0.1.0",
|
|
42
|
+
"dsh-tether-host-linux-arm64": "0.1.0",
|
|
43
|
+
"dsh-tether-host-linux-x64": "0.1.0",
|
|
44
|
+
"dsh-tether-host-win32-x64": "0.1.0"
|
|
45
|
+
}
|
|
46
|
+
}
|