用手机 SSH 连多台机器,标签页切换各自的 Harness 界面
30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/3 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add OahzOb/dsh-tabs-android该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:需留意
是什么
dsh 原生插件 · ui
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 32 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
⚠︎ 实装验证未通过(unknown · 2026/11/4) ——可能是验证环境差异,装前建议到 GitHub 仓库确认最近更新与 issue。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0⚪ 扫描:无法判定尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0已尝试扫描但取不到可分析的源码:仓库或包不存在、已删除,或为非 JS/TS 实现、未随包发布源码。无法判定,不代表安全。
- · 包内无可扫 JS
- · TS 源码(可能为非 JS 实现或未发布源码),无法判定
扫描于 2026/11/4(0 天前) · 范围 全包扫描(包内未声明入口) · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- SSH连接远程机器
- 标签页切换各机界面
Android 客户端,通过 SSH 连接远程 DeepSeek Harness(dsh)网页界面:单进程内一个会话,每台机器一个 WebView,设备上不装 Harness
README
由 DeepSeek 最新模型翻译生成dsh-tabs for Android
一个手机客户端,用来连接运行在其他机器上的 DeepSeek Harness (DSH) 实例,通过 SSH 访问。每台机器一个进程内 SSH 会话,每台机器一个 WebView,还有一个标签栏,可以同时打开好几个。
它本身不运行 Harness。 没有本地标签页,没有内嵌 Node,设备上也没有 dsh。每个标签页都是别处的某台机器——这也是为什么这个客户端比桌面版更简单,而不是桌面版的移植。
快速开始
1. 在远端机器上: 运行 SSH 服务器,并且用 Node 安装好 dsh,放在 PATH 里。打包的 Desktop 应用不算:它把 Harness 塞在 app.asar 里,不安装 dsh 命令。
2. 在手机上: 安装应用,点 +,填好主机、用户,以及——在应用的密钥放到那台机器上之前——密码。
3. 点标签页连接。Harness 的 Web 界面会在标签页里打开。
每台机器做一次,标签栏就会记住它们。
它能做什么
每台机器,通过一条 SSH 连接:
1. 在远端运行 dsh web --no-open --port 0,让远端操作系统挑一个空闲端口,这样连接永远不会和谁手动启动的服务器撞上。
2. 从它打印的就绪行里读回端口和会话 token。
展开完整文档(README 剩余 1 节)
dsh-tabs for Android
一个手机客户端,用来连接运行在其他机器上的 DeepSeek Harness (DSH) 实例,通过 SSH 访问。每台机器一个进程内 SSH 会话,每台机器一个 WebView,还有一个标签栏,可以同时打开好几个。
它本身不运行 Harness。 没有本地标签页,没有内嵌 Node,设备上也没有 dsh。每个标签页都是别处的某台机器——这也是为什么这个客户端比桌面版更简单,而不是桌面版的移植。
快速开始
1. 在远端机器上: 运行 SSH 服务器,并且用 Node 安装好 dsh,放在 PATH 里。打包的 Desktop 应用不算:它把 Harness 塞在 app.asar 里,不安装 dsh 命令。
2. 在手机上: 安装应用,点 +,填好主机、用户,以及——在应用的密钥放到那台机器上之前——密码。
3. 点标签页连接。Harness 的 Web 界面会在标签页里打开。
每台机器做一次,标签栏就会记住它们。
它能做什么
每台机器,通过一条 SSH 连接:
1. 在远端运行 dsh web --no-open --port 0,让远端操作系统挑一个空闲端口,这样连接永远不会和谁手动启动的服务器撞上。
2. 从它打印的就绪行里读回端口和会话 token。
3. 把手机上的一个回环端口转发到那个远端端口,让 WebView 指向它。
token 是一次性入场券:服务器回一个签名 cookie 和一个重定向,所以之后每次重新加载都用 cookie,而不是 URL。
功能
| 同时连多台机器 | 每台机器一个标签页,各自有独立的 SSH 会话、WebView 和对话。切换标签页只改变可见性,别的什么都不动——页面从不重新加载,所以你回来时对话还在。 |
| 手机上不装 Harness | 目标机器上不装任何东西,本地也不跑任何东西。应用启动远端自己的 dsh web,并转发它的端口。 |
| SSH 密钥,或密码 | 生成一次应用的密钥,把一行粘进 authorized_keys;或者用密码做一次性连接。密码从不保存。 |
| 主机密钥固定 | 首次使用时信任,之后每次连接都校验。主机密钥变了会被拒绝,并告诉你,而不是默默接受。 |
| 会清理的关闭 | 关闭标签页会停掉远端的 dsh web,而不是让它继续占着机器的资源。 |
| 离开应用也不断 | 前台服务在你切到别的应用时保持连接。 |
| 自动重连 | 如果远端服务器没了——比如改插件导致它重启——标签页会说明情况,并自己重连,重试次数有上限。 |
| 看得懂的失败 | 连接失败时显示远端自己的原话,而不只是「连接失败」。 |
要求
你要连接的机器: 运行 SSH 服务器,并且用 Node 安装好 dsh,放在 PATH 里。
Node。就这些——应用不在那里安装任何东西。
手机端: Android 14(API 34)或更高版本。门槛是密码学,而不是潮流:Ed25519 主机密钥和 XDH 密钥交换是现代默认值,而平台直到 API 33 才同时具备这两者。抬高门槛,才能让应用不携带自己的加密库。
安装
从发布版安装。 从 Releases 下载 dsh-tabs-android-<version>.apk 并安装。发布说明里带有所发布文件的 SHA-256。
它用维护者自己的密钥签名,所以无法覆盖任何其他人的构建——Android 通过签名密钥识别应用,密钥不匹配就是 INSTALL_FAILED_UPDATE_INCOMPATIBLE。先卸载旧版本,这会一并带走设备簿和应用自己的 SSH 密钥。
如果在小米设备上 adb install 报 INSTALL_FAILED_USER_RESTRICTED,那只是 MIUI 拦截了流式安装路径;把 APK 推上去,再从设备上安装即可。
从源码构建。 你需要 JDK 17、带 platform 36 的 Android SDK,以及 adb。Gradle 随 wrapper 一起提供:
.\gradlew.bat assembleRelease
adb install -r app\build\outputs\apk\release\app-release.apk
自己构建意味着用自己的密钥签名,如果你打算长期保留它,这才是正确的做法。参见下面的「构建」和「签名」。
使用
- + 添加一台机器。它需要主机、SSH 用户,以及——在应用的密钥放到那台机器上之前——密码。密码只用于那次连接,绝不存储;输错了会重新询问,而不是重试。
- 同一对话框里的 SSH key… 会生成应用自己的密钥,并显示要粘贴到那台机器 authorized_keys 里的那一行。一旦某台机器有了它,连接就再也不会询问任何东西。
- 点按标签页即可连接,或者如果它已经在线,就把它切回前台。
- × 断开连接。标签页会保留,因为标签页是一台配置好的机器,而不是临时视图。
- 长按标签页可编辑或移除该机器。
文档
本文件其余部分是工程记录:架构为何是现在这样、测量了什么以及如何测量、以及塑造了它的那些失败。它故意写得很长——每一节都是一个付出过代价才学到的决定。
| 架构为何是现在这样 | 不内嵌 Node,一条连接而非两条,拆除契约 |
| 布局 | 每个文件及其归属 |
| 构建 | 工具链、wrapper、辅助脚本、预览和模拟器 |
| 签名 | 为什么 debug 安装是安全漏洞,而不是便利 |
| 应用自己的 SSH 密钥 | 它在哪里,以及什么在保护它 |
| 诊断连接 | 连不上时该看什么 |
| 验证了什么,怎么验证的 | 验证表 |
| 已知缺口 | 目前还做不到的事 |
dsh-tabs 安卓端(中文)
在手机上连接其它机器上运行的 DeepSeek Harness (DSH),走 SSH。每台机器一条进程内 SSH 会话、一个 WebView,标签栏可以同时开着好几台。
它本身不跑 Harness。 没有本地标签页,不内嵌 Node,设备上也没有 dsh。每个标签页都是别处的机器 —— 这也是它比桌面端简单、而不是桌面端的移植版的原因。
快速开始
1. 目标机器:跑着 SSH 服务,并且 PATH 上有用 Node 安装的 dsh。打包的桌面应用不算 —— 它的 Harness 装在 app.asar 里,不提供 dsh 命令。
2. 手机:装上应用,点 +,填主机、SSH 用户,以及密码(在把那台机器加上本应用的公钥之前需要)。
3. 点标签页连接,Harness 的网页界面就会在标签里打开。
每台机器做一次,之后标签栏会记住它们。
它能做什么
针对每台机器,只走一条 SSH 连接:
1. 在对端执行 dsh web --no-open --port 0 —— 让远端系统自己挑一个空闲端口,所以绝不会和谁手动起的服务撞端口。
2. 从它打印的就绪行里读回端口和会话 token。
3. 把手机上的一个回环端口转发到那个远端端口,让 WebView 打开它。
token 是一次性的入场券:服务端回一个签名 cookie 并重定向,之后每次刷新都走 cookie 而不是 URL。
功能
| 多台机器同时开 | 每台机器一个标签页,各自独立的 SSH 会话、WebView 和对话。切标签只改可见性、不重新加载页面,所以切回来对话还在。 |
| 手机上不装任何东西 | 目标机器上不装东西,本地也不跑东西。应用只是启动对端自己的 dsh web 并转发端口。 |
| 密钥或密码 | 生成一次应用自己的密钥,把一行粘进 authorized_keys;或者用密码做一次性连接。密码从不保存。 |
| 主机密钥固定 | 首次信任,之后每次连接都校验。主机密钥变了会被拒绝并告知,而不是默默接受。 |
| 关得干净 | 关标签页会停掉远端的 dsh web,不会留下进程占着那台机器的资源。 |
| 切出去也不断 | 前台服务让连接在你用别的应用时保持存活。 |
| 自动重连 | 远端服务消失时(比如改插件导致它重启),标签页会说原因并自行重连,重试次数有上限。 |
| 失败能读懂 | 连接失败会显示对端自己的原话,而不是只给一句"连接失败"。 |
环境要求
被连的机器:一个 SSH 服务,以及 PATH 上用 Node 安装的 dsh。就这些 —— 应用不在那边装任何东西。
手机:Android 14(API 34)或更新。这个下限是密码学决定的,不是赶时髦:Ed25519 主机密钥和 XDH 密钥交换是现在的默认值,而平台到 API 33 才同时具备两者。抬高下限正是这个应用能不带自己的加密库的原因。
安装
用 Release。 从 Releases 下载 APK 安装。
它用维护者自己的密钥签名,所以无法覆盖安装别人的构建 —— Android 用签名密钥识别应用,不匹配就会报 INSTALL_FAILED_UPDATE_INCOMPATIBLE。得先卸载旧版,而卸载会一并带走设备簿和应用自己的 SSH 密钥。
小米设备上如果 adb install 报 INSTALL_FAILED_USER_RESTRICTED,那只是 MIUI 挡了流式安装这条路;先 push 再在设备上安装即可。
从源码构建。 需要 JDK 17、带 platform 36 的 Android SDK、以及 adb。Gradle 由 wrapper 自带:
.\gradlew.bat assembleRelease
adb install -r app\build\outputs\apk\release\app-release.apk
自己构建就是用你自己的密钥签名 —— 如果你打算长期用,这是正确的做法。细节见下面的 "Building" 与 "Signing"。
用法
- + 添加机器。需要主机、SSH 用户,以及密码(在那台机器上有本应用公钥之前)。密码只用于这次连接、从不保存;密码错了会重新询问而不是反复重试。
- 同一个对话框里的 SSH key… 生成应用自己的密钥,并给出要粘进那台机器 authorized_keys 的那一行。机器有了它之后,连接就不再问任何东西。
- 点标签页连接,或把它(已经连着的)调到前面。
- × 断开。标签页保留 —— 标签页是一台配置好的机器,不是临时视图。
- 长按标签页编辑或删除这台机器。
文档
本文件其余部分是工程记录:架构为什么是这样、实测了什么、怎么测的,以及塑造了它的那些失败。
它很长是有意的 —— 每一节都是一个花了代价才换来的决定。
| Why the architecture is what it is | 不内嵌 Node、一条连接而非两条、teardown 契约 |
| Layout | 每个文件及其归属 |
| Building | 工具链、wrapper、helper 脚本、预览与模拟器 |
| Signing | 为什么 debug 安装是安全漏洞而不是便利 |
| The app's own SSH key | 密钥存在哪、靠什么保护 |
| Diagnosing a connection | 连不上时该看什么 |
| What was verified, and how | 验证记录表 |
| Known gaps | 目前还做不到什么 |
技术细节一律以英文原文为准,中文部分不重复翻译,避免两份说明逐渐说不到一起去。
工程记录
以下章节只有英文。它们记录了测到了什么、每个决定为什么是现在这样;翻译过来会变成两份描述,慢慢就对不上,而代码和测试只会引用其中一份。
架构为什么是现在这样
不内嵌 Node
想复用桌面应用的逻辑,最直接的办法是在手机上跑 Node。这条路走不通,而且是彻底走不通,不是部分走不通:
"The child_process and cluster modules are not available."
— nodejs-mobile 自己的文档
桌面端的连接层就是 child_process.spawn——spawn 出 ssh,解析它的 stdout,再 spawn 一个 ssh -N -L。所以内嵌 Node 一点都复用不上,反而要加一层 Kotlin↔Node 桥、每个 ABI 约 11 MB 的 libnode.so,还有一个仍停留在 Node 18 的运行时(2025 年 4 月已停止维护)。
第二条路——打包一个 ssh 二进制再 spawn 它——合法,但更糟:从 Android 10 起,可写的应用目录根本不能执行,所以二进制必须放进 APK 的原生库目录,并且让整个应用强制走旧版打包方式;而且它到手时没有 /etc/passwd、没有 ~/.ssh、没有 known_hosts、也没有 ssh_config。
于是那约 400 行被移植到了 Kotlin。JVM SSH 库用来替代 ssh 二进制的,全都在一个进程内会话里——桌面应用每台设备开两条连接,到这里就合并成一条。
一条连接,不是两条
桌面应用每台设备开两条 SSH 连接:一条跑服务端,一条裸的 ssh -N -L 做转发。两条之所以存在,只是因为 ssh 是个每次调用只干一件事的程序。RemoteSession 向同一个 JSch Session 要一个 exec 通道和一个本地转发,所以只有一条连接,也只有一样东西要拆。
拆除约定没变,而它才是承重的那部分
通过 SSH 启动的远程命令,在连接断开时不会可靠地死掉。在桌面项目上实测:一个普通的 exec dsh web,甚至套在 ssh -tt 下的,都在客户端被直接杀掉后活了下来,留下一个孤儿服务端占着远程端口。
解决办法是让远程程序阻塞在 stdin 上。sshd 会给它一个管道,连接一结束管道就到达 EOF,不管是怎么结束的,所以后台服务端总会被回收:
- POSIX:dsh web … & child=$!; cat > /dev/null; kill $child; wait $child
- Windows:在 try/finally 里 [Console]::In.ReadToEnd(),再树杀 shim
这就是为什么 RemoteSession.stop() 先关 stdin,然后才放下转发、通道和会话。先关连接的话,对面会一直占着端口,没人告诉它该让开。
转发是库的,不是我们的
Session.setPortForwardingL(0, "127.0.0.1", remotePort) 和 ssh -L 做的事一样,端口 0 表示向操作系统要一个空闲端口并返回——桌面应用只能自己分配端口,然后祈祷这段时间里没有别的程序把它抢走。
自己手写这套东西是个错误。监听器要接受 WebView 的连接,就必须正确实现 HTTP/1.1:默认长连接、分块传输帧、为 Harness UI 使用的 WebSocket 处理 Upgrade、半关闭、不能重复分块。JSch 已经把回环连接桥接到 direct-tcpip 通道,这是同一件事,而且实现经过充分测试。
主机密钥:首次使用即信任,之后固定
JSch 的 StrictHostKeyChecking=no 比 OpenSSH 的 accept-new 更弱,这是个迟早要爆的安全漏洞。 在 Session.doCheckHostKey 里,只有设置为 ask 或 yes 时才会走到密钥变更分支——所以设为 no 时,变更过的主机密钥会被静默接受。一台重装过的机器,或者中间人,都会被毫无提示地信任。
TrustOnFirstUse 实现了真正的行为:接受从未见过的密钥并上报,以便固定;拒绝与已固定密钥不同的密钥,并给出说明。固定信息存在设备记录里。
为什么是 minSdk 34
现代 OpenSSH 优先提供 ssh-ed25519 主机密钥和 curve25519-sha256 密钥交换。Android 平台直到 API 33 才支持 Ed25519 签名和 XDH。目标设备运行 API 36,所以下限设在平台已经完成部分工作的地方。
BouncyCastle 不是可选项,这是实测结论
JSch 把 Ed25519 实现放在 multi-release JAR 的 META-INF/versions/15/ 下,而 Android 运行时并不实现这些:它链接的是基类,其构造函数是
throw new UnsupportedOperationException("SignatureEd25519 requires Java15+");
在设备上实测,对着只提供 ssh-ed25519 的真实 OpenSSH 9.5 服务器,结果如下:
Algorithm negotiation fail: algorithmName="server_host_key"
jschProposal="ecdsa-sha2-…,rsa-sha2-…" serverProposal="ssh-ed25519"
尴尬的地方在于,平台确实有 Ed25519——在这台设备上探测报告 ED25519_SIGNATURE=true——但 JSch 够不到它,因为会调用它的那个类正是 Android 拒绝加载的。所以 bcprov-jdk18on 在这里不是平台加密的重复品,而是 JSch 唯一的通路。它还必须在启动时注册为服务商,否则同样的握手会失败,库却闲置在 classpath 上——这个 bug 更糟,因为看起来像是修复没起作用。
明文只到回环,且仅限回环
从 API 28 起,平台默认拒绝明文,WebView 按主机强制执行,所以被拦截的导航会失败,且没有任何有用的说明。这里涉及两套独立机制,很容易混为一谈:
- Android 的明文策略——res/xml/network_security_config.xml 保留
cleartextTrafficPermitted="false" 作为基线,只重新放开 127.0.0.1、localhost 和 ::1。应用里其他任何地方都不许走明文。
- Web 平台的「潜在可信来源」 —— http://127.0.0.1 按定义就是安全上下文,所以页面发起的 ws:// 不算混合内容。这不能替代上面的策略:回环页面可以是安全上下文,却依然被拒绝。
目录结构
app/src/main/java/dev/dshtabs/
Remote.kt 远端要跑什么 —— 桌面端 remote.js 的移植 RemoteSession.kt 一个 SSH 会话:启动服务端、转发端口、拆除 HostKeys.kt 首次使用即信任,之后固定 Device.kt 机器记录及其账本 MainActivity.kt 标签栏和唯一的 WebView SessionService.kt 息屏时保持会话存活 Notifications.kt 这个应用发出的唯一一条通知 App.kt 启动:账本放在哪,以及通知渠道
Remote.kt 是移植,注释也一并移植了过来,因为每一条都记录着一次已经付过代价的失败。
一致性测试,它直到发现两处差异才存在
这份 README 以前说,../dsh-tabs/tools/parity.mjs 会把两个客户端跑在同一批输入上,有任何差异就失败。那个文件从来没写过。 同一套协议的两份实现,靠什么都没有保持同步,于是 app/src/test/java/dev/dshtabs/RemoteParityTest.kt 被写出来,充当当初承诺的那道检查 —— 它立刻发现了两处差异:
1. 就绪模式等价,但并非逐字节相同。 JavaScript 的 RegExp.source 会保留一个并不需要的转义,所以桌面端写的是 http:\/\/,而 Kotlin 写的是 http:[/]/。同一个模式,同样的匹配结果。测试在比较时剥掉多余的转义,这才是当初该有的说法。
2. Windows 程序确实已经跑偏,而这个客户端走在它前面。 它会解析 node,而不是盲信 .cmd 垫片;会报告解析到了什么;还会盯着子进程 60 秒,因为桌面端那版在 dsh 退出却没公布 URL 时会永远挂住。
第二处差异已经关闭,而这段话是在纠正一个一直摆在这里、直到桌面端追上来才作废的说法。 桌面端把这些全盘采纳了 —— node 解析、解析结果的报告、60 秒看门狗 —— 所以两个程序现在逐字节相同,这也是今天一致性测试所断言的:
ok the Windows program is byte-identical to the desktop client's
这样一来,这个客户端既不落后什么,也不领先什么;它就是同一个程序写了两遍。在这里读到「这个客户端领先」,会让你以为桌面端的 Windows 分支缺少一个看门狗,而它自采纳那次提交起就已经有了,诚实的核对方式是跑测试,而不是读这段文字。这段历史保留它,是因为看门狗存在的理由就在这里:故障最早是在这儿发现的。
它的基线是桌面端已发布的契约,即 ../dsh-tabs/docs/contract.json——由那个仓库生成,并由它自己的测试套件锁定——而不是桌面端代码最后一次打印出来的副本。测试从同级仓库里读取那个文件,所以什么都不用复制,也不会过期;两个仓库放在同一个父目录(dsh-workspace/)下,部分原因就是为了让这件事可行。改动桌面端契约,下一次运行就会让这些测试失败,这就是信号,提醒你判断这个客户端是否也需要同样的改动。
单独检出这个仓库仍然能用:测试会退回到自己测试资源里的 contract.json,两个文件都不存在时它会失败,并说明自己找过哪条路径,而不是在无可比对的情况下直接通过。
gradle testDebugUnitTest # 12 checks, on the build machine, no device needed
POSIX 和 Windows 都按精确字节比对。Windows 以前用的是更宽松的标准——两个客户端共有的片段必须在两边都出现,这个客户端新增的每一处也必须还在——因为这两个程序当时确实不一样。现在它们不再有差异,而相等性检查严格强于片段清单:清单只能证明有人想到要命名的那些片段还在,两个客户端却可能持有同一份坏掉的程序。
那个一直是灰色的圆点,以及它背后的那类 bug
一台已连接的机器一直显示灰色圆点。连接没问题,状态机也没问题:renderTabs 根据标签页状态在圆点上设置 isSelected、isEnabled 和 isActivated,而 dot.xml 恰好就是针对这三个状态的 selector。tab.xml 直接写了 @drawable/dot_idle,而不是 @drawable/dot,所以背景成了一个普通灰色椭圆,这三个状态一个都不读。dot_running、dot_starting 和 dot_failed 作为文件确实存在,也被 selector 引用,却永远到不了。
它为什么能躲过这个项目所有的检查,值得说清楚,因为这一点可以推广:状态列表没有失败模式。 它能编译,能打包,能渲染,什么也不抛。selector 对应的视图如果永远收不到它用来选择的状态,就只会一直显示默认颜色——而且和这个应用的其他 bug 不同,构建机上的任何测试都看不到它,因为 JVM 测试源集里没有任何东西能加载布局。
TabDotResourcesTest 补上了这个洞,而且它刻意检查的是类别,不是单个实例。它把布局、selector 和 MainActivity 当作文本来读,断言代码设置的状态集合就是 selector 读取的状态集合。只查找那一个错误字符串的测试能抓住这个 bug,却抓不住下一个;比较两个集合,则能在任意一侧新增状态时单侧为空,正是错误的形态。两道防线保证比较可靠:没有无条件默认项的选择器,在没有任何状态匹配时什么也画不出来,而两个空集相等,所以任何一侧都不能为空。
构建
你需要什么: JDK 17、带 platform 36 和 build-tools 36.0.0 的 Android SDK,以及 PATH 上的 adb。Gradle 本身不需要——gradlew 自带一份。
构建会读取三个变量:ANDROID_HOME(或 ANDROID_SDK_ROOT)和 JAVA_HOME。装好 Android Studio 后,前两个会自动设好。具备这些变量的 shell 可以直接调用 adb、sdkmanager 和 java。
tools\android-env.ps1 是同样内容的可点源形式,另外还有 Test-AndroidEnv,用来报告每个工具的输出,以及 Get-AndroidDevice,用来返回已连接设备的序列号。它从环境读取默认值,所以在正常安装下无需任何配置:
. .\tools\android-env.ps1
Test-AndroidEnv
某台机器的布局,仅作示例,不是硬性要求。 开发这套代码的机器把工具链解包在单独的目录里,并在 tools\android-env.local.ps1 中写明路径,该文件被 git 忽略:
C:\android-toolchain\jdk17 Temurin JDK 17
C:\android-toolchain\gradle-8.9 Gradle 8.9 (AGP 8.7.3 requires 8.9+)
C:\android-toolchain\android-studio Android Studio 2026.2.1 (Rabbit 1)
C:\android-toolchain\scrcpy scrcpy 4.1, live device mirroring
C:\android-sdk command-line tools, platform 36, build-tools 36.0.0
这些路径也在那台机器的用户 PATH 上。构建不依赖它们:本地文件的存在,是为了不让任何被跟踪的文件带上某台机器的目录。
wrapper 与发行版
gradlew、gradlew.bat 和 gradle-wrapper.jar 都已提交,gradle-wrapper.properties 指向官方发行版。克隆下来需要的就这些,入口是 .\gradlew.bat assembleDebug——优先用它,而不是全局安装的 gradle.bat,因为 wrapper 把版本钉在项目上。首次使用时,发行版会解压到 ~\.gradle\wrapper\dists,所以第一次构建需要网络,之后就不需要了。
在开发这套代码的机器上,官方 URL 用不了。 那里通过本地代理访问 services.gradle.org 的 TLS 会失败,而 dl.google.com、repo1.maven.org 和 github.com 走同一个代理都正常,并且 gradle wrapper 根本拒绝写入该 URL:Test of distribution url … failed。这是那台机器的特性,不是项目的特性,所以覆盖项放在 tools\android-env.local.ps1(被 git 忽略)里,而不是放在被跟踪的 properties 文件中:
$env:GRADLE_DISTRIBUTION_URL = 'file:/C:/android-toolchain/gradle-8.9-bin.zip'
Gradle 的 wrapper 会优先采用这个变量,而不是 distributionUrl。如果你也采取同样的路由,有一个打包细节很坑:要用 bsdtar 构建 zip,不要用 Compress-Archive。后者会漏掉目录条目,包装脚本解压时就会在第一个文件上失败,报 Could not unzip … (The system cannot find the path specified)——从父目录执行 tar -a -c -f gradle-8.9-bin.zip gradle-8.9,生成的归档 Java 才能读。
wrapper jar 就是 Gradle 自己内嵌的那个,作为条目 gradle-wrapper.jar 位于 gradle-<ver>\lib\plugins\gradle-wrapper-main-<ver>.jar 内。手动把 gradle-wrapper-main 和 gradle-wrapper-shared 合并行不通:结果什么都编译不了,还会死在 NoClassDefFoundError: org/gradle/cli/CommandLineParser。
.\gradlew.bat assembleDebug
.\gradlew.bat assembleDebug --offline
adb install -r app\build\outputs\apk\debug\app-debug.apk
local.properties 指向 SDK,因机器而异,就像 tools\android-env.local.ps1。
辅助脚本
tools\ 把整个循环包了起来,一条命令就能编译、安装并看一眼改动:
.\tools\build.ps1 # compile only
.\tools\build.ps1 -Offline # from the Gradle cache, no network
.\tools\build.ps1 -Install # compile and push to the device
.\tools\build.ps1 -Install -Shot -Label 04-tabs # …and capture a screenshot .\tools\shot.ps1 -Label 05-empty # capture the current screen .\tools\shot.ps1 -NoLaunch # capture without launching the app
shot.ps1 写入 .shots\,旁边还有一个 .small.png,缩小后方便分享。它会先唤醒屏幕,并报告平均亮度,这样黑屏就不会悄悄蒙混过关——screencap 在设备休眠或锁屏时也会成功,返回一张完全有效的全黑 PNG,而这正是浪费一下午的坑。
有一个限制值得提前知道:锁屏的设备拍不了照。 只要 keyguard 还在,Android 就不会合成应用窗口,所以无论屏幕多亮,帧缓冲都是黑的。wm dismiss-keyguard 只在没有安全锁时有效。先解锁设备,并在整个截图过程中保持解锁。
不用设备的预览
Android Studio 自带 layoutlib.jar(61 MB,位于 plugins\design-tools\lib\),能在宿主机上渲染布局和 Composable,所以预览不需要设备、模拟器,也不需要解锁。这样启动 IDE:
C:\android-toolchain\android-studio\bin\studio64.exe
模拟器
已经有一个 AVD,叫 dsh_tablet——Pixel Tablet,Android 15(API 35),google_apis,x86_64。它注册在 ~\.android\avd\ 里,Android Studio 会直接把它识别为运行目标,无需额外设置。
emulator -avd dsh_tablet
adb install -r app\build\outputs\apk\debug\app-debug.apk
镜像是 x86_64,不是 arm64-v8a:宿主机是 Intel,arm64 镜像需要完整的指令模拟,慢到根本没法用看 WebView 绘制。
config.ini 里有四项设置改动了配置档默认值,因为默认值在这台机器上、对这个应用都不对:
| 键 | 默认值 | 设为 | 原因 |
| hw.gpu.enabled | no | yes | no 会强制软件渲染,WebView 就没法看了 |
| hw.ramSize | 2G | 4G | 宿主机有 31.8 GB;2G 会让 Android 杀掉会话 |
| vm.heapSize | 192M | 512M | 应用自己的堆,与 hw.ramSize 是两回事 |
| hw.keyboard | no | yes | 在 PC 上打字,不用软键盘 |
运行它需要 WHPX,而 WHPX 现在已经启用。 HypervisorPlatform 和 VirtualMachinePlatform 用 dism 启用,重启后提交生效。启用之前,emulator -accel-check 退出码是 6;启用之后:
accel:
0
WHPX(10.0.26300) is installed and usable.
accel
端到端验证过:AVD 启动了 Android 15,adb install 成功,应用启动,截图返回 2560x1600,UI 已绘制。图形跑在宿主机 GPU 上——GLES: Google (Intel), Android Emulator OpenGL ES Translator (Intel(R) Arc(TM) B580 Graphics)。
有两点会因此产生误导性消息,信了都要浪费时间:
- emulator -accel-check 报 "Android Emulator hypervisor driver is not installed",指的是 legacy AEHD 驱动,这套配置不用它,也不需要它——启用 HypervisorPlatform 后,模拟器用的是 WHPX。这条消息出现在功能安装之后、重启之前,当时它成立,但理由是错的。
- 功能包的状态不能证明 hypervisor 已启动。 安装功能会立刻报成功,但包会停在 Install Pending(CBS 0x60),而不是 Installed(0x70),直到重启提交。唯一可靠的检查是 (Get-CimInstance Win32_ComputerSystem).HypervisorPresent,它必须读到 True。
dism 和 bcdedit 命令需要提权 shell。助手会话里禁用了审批提示,所以提权只能手动做:C:\android-toolchain\enable-hyperv.cmd 和 C:\android-toolchain\set-hypervisor-launch.cmd 各自通过 Start-Process -Verb RunAs 重新启动自己,需要点一次 UAC。计划任务这条路(schtasks /create /rl HIGHEST,或 Register-ScheduledTask 配 RunLevel Highest)在这里行不通——两者都报 "Access is denied",因为创建最高运行级别的任务本身就是管理员操作。bcdedit 在这台机器的本地化 Windows 上也不认 {current} 别名;改用裸 bcdedit /enum 枚举。最后 hypervisorlaunchtype 不需要改——启用功能时就设好了。
签名,以及为什么 debug 安装还不够
assembleDebug 产出的 APK 设置了 android:debuggable,这不是表面功夫:正是它让 CryptoProbe 可达。启动器 activity 是已导出,所以设备上任何应用都能用自己的 extras 启动它,而在 debug 构建上探针会响应。这些 extras 能触及什么,见「探针仅限 debug」——本应用真正的私钥就是其中之一。
.\gradlew.bat assembleRelease
adb install -r app\build\outputs\apk\release\app-release.apk
发布签名密钥存放在本仓库之外,位于
C:\Users\74831\.android-keys\dsh-tabs-release.jks, and keystore.properties — path, alias and passwords — is ignored by git. Both decisions are the same one: a signing key that reaches a repository has to be treated as compromised, and the password beside it makes that worse rather than better. The build works without the file; a missing keystore.properties produces an unsigned release APK, exactly as before, instead of failing.
Two details about keystore.properties that cost a build each:
- storeFile is absolute. AGP resolves a relative value against the Gradle
daemon's working directory rather than the project root, so ../android-toolchain/… resolves to ~/.gradle/daemon/8.9/android-toolchain/… and the build fails with "keystore file not found" naming a path nobody wrote.
- A different key is a different app. Android identifies an app by its signing
key, so a release build cannot update a debug install, or another machine's release
install. It has to be uninstalled first — which takes the device book and the app's own SSH key with it. Back the book up first if the machines in it are more than a few taps to retype:
adb shell run-as dev.dshtabs cat files/dsh-tabs.json > dsh-tabs.json
That only works while a debuggable build is installed, which is the one moment the
book is easy to read — worth doing before the switch, not after.
The app's own SSH key
One key, generated on the phone, pasted into as many authorized_keys files as the
operator likes. Ed25519, because that is what modern OpenSSH offers first and
because it is short enough to read off a screen.
It is generated by JSch, which works only because BouncyCastle is a dependency — the same finding that made the handshake work at all. KeyPair.genKeyPair(jsch, ED25519) goes through exactly the class Android's runtime cannot load by itself, so this feature and that dependency are one decision rather than two.
The private half is written with a passphrase, and the passphrase is a random value
kept in EncryptedSharedPreferences, whose master key lives in the Android
Keystore. So the key on disk is ciphertext and the thing that decrypts it is
hardware-backed where the device supports it. That is not a defence against someone
holding an unlocked, rooted phone — it is a defence against a key that outlives the app, through a backup, a file manager, or a copy made while debugging.
Two overload bugs were paid for here, and they are worth naming because both produce
a failure that points at the wrong thing:
- KeyPair.load(jsch, prvfile, pubfile) — the second string is a .pub file path, not a passphrase. Passing a passphrase there makes JSch try to open a file named after the passphrase and fail with FileNotFoundException naming it. The fix is to read the bytes and use load(jsch, prvkey, pubkey), then decrypt().
- JSch.addIdentity(name, prvfile, passphrase) does not exist. The three-argument
form is (name, prvkey, pubkey), so a passphrase lands in the pubkey slot. The
fix is the four-argument (name, prvkey, pubkey, passphrase).
Both are the same mistake in different clothes: JSch overloads its arguments by
position with no type distinction, so a wrong call compiles and fails later in a way
that describes the wrong file.
Replacing the key is deliberate. The button says what it will cost — every machine that already has the current public key will refuse this app until the new one is pasted there — because an app cannot reach into those files to clean up after itself, and pretending otherwise would be worse than saying so.
Threading: the bug that killed every first connect
RemoteSession.open runs on Dispatchers.IO, and it reports progress through
callbacks. A callback that touches a view therefore runs on a worker thread, and
the process dies:
CalledFromWrongThreadException: Only the original thread that created a view
hierarchy can touch its views. Expected: main Calling: DefaultDispatcher-worker-2
at MainActivity.showTranscript(MainActivity.kt:431)
at MainActivity$startSession$1… (MainActivity.kt:467) at RemoteSession$Companion$open$3… (RemoteSession.kt:123)
Kotlin's dispatchers do not save you here, and that is the part worth
remembering. They only switch threads when the dispatcher changes, so a plain
lambda invoked from inside withContext(Dispatchers.IO) stays on the worker no
matter what dispatcher the enclosing coroutine declared. Declaring a coroutine
scope is not a thread guarantee for the callbacks you hand to a library.
The rule that fixes it is in the code: every callback that reaches a view goes
through onMain, and render/renderTabs are annotated @MainThread so the
constraint is visible rather than inferred.
An uncaught exception is now written to files/last-crash.txt as well as logcat,
because a crash during a connection is the worst place to lose information — the transcript panel that would have explained it never rendered, and logcat rotates.
A remote that cannot start must not hang
The Windows branch launches the far side with Start-Process. When that produced
no process at all — measured against this project's own machine, where dsh exits
on a profile it cannot resolve — the program reached [Console]::In.ReadToEnd()
and waited, and the client waited for a readiness line that would never come. The
operator saw a connection that neither finished nor failed.
The program now watches the child for 60 seconds and writes to stderr if it dies
first, so the failure arrives as a transcript line:
! dsh exited with code before announcing a URL
The gap after code is not a typo in this document. It is what the program
prints, and it is a PowerShell limitation rather than a decision: Start-Process
-PassThru leaves $proc.ExitCode unset unless it is also given -Wait, and -Wait
would block and defeat the very loop that is watching. WaitForExit() and Refresh()
do not fill it in either (verified on PowerShell 5.1). The blank is kept rather than
papered over, because a made-up code would be worse than none.
combined with the child's own stderr, which for the case above is the full Node
stack trace naming the path it could not resolve.
Auth fail for methods 'publickey,password' is almost never about the key
This cost an hour on a working setup, and the mistake was reading the message as
being about the key. It is not a statement about the key at all — JSch produces it whenever authentication ends without success, and it names the methods the server offered, not the ones the client tried.
The decisive evidence is on the far side, and it is an absence. sshd logs
Failed publickey for <user> … SHA256:<fp> whenever a client offers a key that is refused. So:
| The server's log shows | What it means |
| Failed publickey … SHA256:<fp> | The client offered that key and it was refused — now the key, the file or its permissions are worth looking at |
| No publickey line at all, only the disconnect | The client never offered a key. If the record's user does not exist on that machine, sshd will not read any authorized_keys, so no key can ever appear |
| Disconnected from invalid user <name> | That is the answer: the username in the device record is wrong for that machine |
Measured, on a machine where the key was in authorized_keys byte for byte, the
file was 600 in a 700 directory owned by the account, and the same key connected
from another host:
Received disconnect from <phone> … Auth fail for methods 'publickey,password' [preauth] Disconnected from invalid user deploy <phone> [preauth] ← the whole problem
The far side named a user that does not exist there at all; the account was a
different one entirely. Nothing about the key was wrong,
and no amount of checking the key would have found it. Look for the invalid user
line before touching the key, and remember that the failure message cannot tell
the two cases apart:
com.jcraft.jsch.JSchException: Auth fail for methods 'publickey,password' ← refused, or never offered
com.jcraft.jsch.JSchException: USERAUTH fail ← a key was offered and refused
USERAUTH fail with no for methods suffix is the narrower message: it means a key
was actually presented. The longer one is the ambiguous case, and the server log is
the only thing that separates them.
Diagnosing a connection
CryptoProbe drives the real RemoteSession from an adb shell and reports to
logcat. It is inert without its extras, and it is not a separate component — it hangs off the launcher activity, because that is the only thing a shell may start without the app exporting a way in:
adb shell am start -n dev.dshtabs/.MainActivity \
--es probeHost 192.168.1.10 --es probeUser me --es probeKeyFile /data/local/tmp/id_key
adb logcat -s DshCryptoProbe:I
Add --ez probeWeb true to also load the result into a real WebView and report
the title, the URL after the token redirect, and the page text. That is how a blank
screen becomes a sentence, and it is what proved the last link in the chain.
The probe is debug-only, and that is a security boundary
It used to be reachable in a release build, and that was a real hole. The
launcher activity has to be exported="true", so any installed app — not just adb, and without root — can start it with extras of its own choosing. With the probe ungated, those extras were reachable too:
| Extra | What a hostile app got |
| probeUseAppKey | This app authenticates with its real private key, to whichever host the caller names |
| probeCommand | An arbitrary command run on that host, under an authentication the far side believes is this app |
| probeKeyFile | A private key read from any path the caller names |
CryptoProbe.requested() now starts with BuildConfig.DEBUG, so a release build
ignores the extras however they arrived. The code is still present in a release
APK — minifyEnabled is false — and that is fine: what matters is that nothing reaches it.
A debug build is still a debug build. The gate removes the hole from anything
published; it cannot remove it from a debug APK installed for development, because
the probe is the thing that makes that build useful. Treat a debug install as a
development device's build, not as one to carry.
What was verified, and how
Every one of these was measured on the Redmi itself, not inferred:
| Claim | Evidence from the device |
| SSH handshake with a real OpenSSH 9.5 server | connect … → remote platform: windows — Ed25519 host key, curve25519 key exchange, public-key auth |
| Host key pinning works | HOSTKEY AAAAC3NzaC1lZDI1NTE5AAAAI<redacted>… captured on first use |
| The remote Harness starts | dsh web: http://127.0.0.1:60136/?token=… streamed back over the exec channel |
| The port forward binds and carries bytes | tunnel 127.0.0.1:39231 -> 127.0.0.1:60136, then FORWARD_FIRST_BYTES <<HTTP/1.1 401 Un>> — a real response from the far side, refused for want of the token, which is the fence working |
| The token leaves the address bar | HTTP_TOKEN status=303 location=./ then HTTP_ROOT status=200 — the client's own half of the protocol, with a real cookie |
| The document is really the Harness | HTTP_BODY bytes=35182 looksLikeHarness=true |
| WebView loads the remote Harness | WEB_TITLE DeepSeek Harness, WEB_STATE ready=complete boot=true url=http://127.0.0.1:37177/, WEB_TEXT <<探索未至之境 … DeepSeek-V41-Flash>> |
| The realtime channel connects | connection lost, retry console lines appear the instant the session is stopped |
| Teardown reaps the far side | STOPPED, and the far side's dsh web is gone — checked with pgrep over a separate connection |
| The app's own key authenticates | KEYGEN public=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5<redacted>, pasted into a real sshd's authorized_keys with PasswordAuthentication no, then credential=PrivateKey and KEY-AUTH-OK from the remote |
| Multi-device | three tabs render from the book by name (box-a, box-b, box-a (bridge)) in a screenshot |
| The whole thing, in use | This README's verification round was driven from the tablet: an agent running on the PC was reached through the app's tunnel, and the conversation it produced is the record. sshd logged the tablet's own key — SHA256:<redacted> — from its Tailscale address, and the dsh web serving that GUI is a child of the PowerShell that the tablet's session started |
The protocol's own contract was verified separately against two real machines with
the desktop project's live suite: the readiness line, the token→cookie exchange, the
document load, the 401 fence, a unary RPC returning 200, and teardown leaving no
orphan.
When the remote server ends by itself
The whole client rests on one assumption: while the SSH session is up, so is the
Harness behind it. That assumption is false, and this is the failure it hides. The
far side's profile is configured patchReload: live, so an agent that edits a plugin — an ordinary thing to ask it to do — makes the Harness restart itself mid-turn. Measured:
23:29:15 profiles/web/cordis.patch.yml written ← the agent toggling a plugin
the Harness exits; sshd logs no disconnect at all
the interface sits in "connection lost, retry #50"
Nothing in the connection reported a problem. The tab went on claiming to be running,
the forward pointed at a dead port, and a turn that could never finish looked like a
turn that was still going.
Three things fix that, and the third is what makes the first two worth having:
1. The remote program reports what it resolved — which node, which script, whether it exists, and the working directory — to stderr, before it starts anything. Two launches that are equivalent at a prompt behave differently once PowerShell is spawned by sshd with redirected pipes, and when the server then fails the transcript otherwise says only that it failed:
launch node=C:\Program Files\nodejs\node.exe
launch script=…\@deepseek-ai\dsh\lib\bin.js exists=True
2. A dead server is noticed. watchUntilEnded polls the exec channel, which closes
when the remote program does. JSch offers no callback for it, and the alternative is
to wait for output that will never arrive.
3. Then it reconnects by itself, within a bounded budget, and says so:
The server on build-host stopped (exit code 1).
Starting a new one — attempt 1 of 3…
Silent recovery would be worse than the failure here. The conversation the operator was
watching has ended and they need to know that, rather than wonder why the answer never
came. Three attempts, cleared by any successful connect, so a machine that restarts
once an hour is not treated as one that keeps dying — and a server that dies on every boot is not restarted in a loop on someone else's machine, from a phone in a pocket.
Pressing × cancels the watcher, so a tab you disconnected stays disconnected.
Known gaps
- Doze will eventually stall a session. A foreground service keeps the process
out of the cached state — which is what otherwise closes the TCP sockets — but Doze suspends the whole device's network and the exemption that lifts it is a Play-policy matter an SSH tunnel is not on the list for.
- specialUse foreground service type. It is the only type with no runtime
prerequisite, and it is reviewed when an app is submitted to Play. For a
sideloaded personal build that is irrelevant; for a Play release it is a question
to answer first.
- The port lives on 127.0.0.1 only. Loopback is not app-isolated, so another
app on the phone can reach the forwarded port. It is the library's forward, so
nothing here can change it beyond what the Harness itself does — and it is the reason the URL carries a token at all.
- A Windows remote launched through Start-Process can fail on a profile
junction. Seen once against the build machine itself: dsh could not realpath
a node_modules junction and exited, while the identical command typed at a
prompt booted the same profile fine. The desktop app's Windows branch runs the
same program and is documented as working against a real Windows host, so this
looks specific to a profile with linked plugins rather than to the branch — but it is written down because it cost an hour to find and would cost another.
- The desktop client's Windows branch has converged, and the parity test is what
found the gap. This client resolves node itself, reports what it resolved, and
watches the child for 60 seconds so a dsh that exits without announcing a URL
arrives as a transcript line instead of a wait that never ends. The desktop client
once did none of that — it handed the .cmd shim straight to Start-Process and had no watchdog, so the same failure there was the hang this file's own section documents. It has since adopted all of it, and the two Windows programs are now byte-identical; the parity test asserts equality rather than the fragment list it used for as long as they differed.
What neither client could do is verify it against a real Windows remote: the
machine that would have been the subject (box-b) was unreachable throughout, and
a Windows host is the only place this branch can be exercised. The desktop side is
therefore a faithful copy of a program this client has run for real, not an
independent implementation — which is the most confidence available without the host, and the reason it was copied rather than rewritten smaller.预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
OahzOb/dsh-tabs-android
用手机 SSH 连多台机器,标签页切换各自的 Harness 界面
相关插件 · UI / 主题
破甲增强版JeffKeeping/dsh-purge-plus
dsh-purge 的非官方分支fork:适配 DeepSeek Harness 0.2.1-rc.2、破甲内核…
★ 387已汉化DeepSeek 客户端
71.9
无代码业务系统平台nocobase/nocobase
NocoBase 是什么
★ 24,804已汉化DSH
71.8
1Panel 服务器面板1Panel-dev/1Panel
全球 250 万+ 自托管用户共同喜爱。
★ 37,350已汉化DSH
71.5
工作状态行动态ccch1mneyyy/working-activity
作者:chimney@ccch1mneyyy。社区项目,非 DeepSeek 官方项目。
★ 664已汉化DSH
71.2
多模型配置管家farion1231/cc-switch
跨平台桌面一体化助手,支持 Claude Code、Codex、OpenCode、DeepSeek Harness 等工具,唯一官网 ccswitch.io
★ 151,506已汉化DSH
71.1
AI 矢量设计工具ZSeven-W/openpencil
全球首个 AI 原生开源矢量设计工具。
★ 7,027已汉化DSH
71.1
这是你的仓库?认领条目