# dsh-mobile-client **Repository Path**: lulendi/dsh-mobile-client ## Basic Information - **Project Name**: dsh-mobile-client - **Description**: 只是对open-deepseek-harness-desktop(https://github.com/flaqai/open-deepseek-harness-desktop)网页的套壳 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-20 - **Last Updated**: 2026-09-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README

DSH Mobile Client

给电脑上跑着的 DeepSeek Harness 配一个手机 App:扫码即连、长期免密。

License: MIT Platform Flutter

简体中文 · English

--- ## 这是什么 **DSH Mobile Client** 是 [Open DeepSeek Harness Desktop](https://github.com/flaqai/open-deepseek-harness-desktop) (社区桌面版)与 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的**移动端客户端**。 > **先说清楚:这个 App 里没有 DSH。** > > 它**不是**把 DeepSeek Harness 打包塞进手机,也**不是**在手机上跑一份 DSH。它是一层**外壳**: > 用 WebView 打开**你电脑上已经跑着**的那个 DSH 网页,再补上浏览器做不到的几件事 > (访问密码存进系统密钥库、多设备管理、网页通知转成系统通知)。 > > 模型调用、代码执行、文件读写、会话记录**全部仍然发生在你的电脑上**——手机是遥控器,不是主机。 > 电脑上的 DSH 没在跑,App 就是一块连不上的屏幕。 > > 换句话说:DSH 本体来自上游两个项目,本项目只负责**把它套壳到手机里**,并让这层壳比浏览器好用。 桌面版自带的「手机访问」已经能把 DSH 暴露给手机浏览器,但用浏览器访问始终有几处不顺手: | 浏览器访问的不便 | 本 App 的做法 | |---|---| | 电脑上 `dsh web` 一重启,手机上就要**重新输一次 8 位访问密码** | 密码存进系统密钥库(Android Keystore / iOS Keychain),App 自动重新登录,**不用再输** | | 多台电脑 / 局域网 + Tailscale + 公网多个入口,只能靠书签分辨 | **设备列表**:扫码添加、自定义昵称、一键切换 | | 网页通知在手机上收不到 | 网页里的通知**冒泡成手机系统通知** | | 每次都要打开浏览器、找地址、输密码 | 打开 App 直接进入上次的设备 | | 想有点个性 | **桌面伙伴**:动漫角色悬浮在手机桌面上(Android)——**暂缓,见下** | ## 界面 真机截图(Android):

扫一扫连接 设备列表 连接后的 DSH 页面 设置

从左到右:扫一扫添加设备 → 设备列表(同一台设备的局域网与 Tailscale 两个地址)→ 连接后直接进入电脑上的 DSH 页面 → 设置(主题、通知转发、自动重新登录)。 ## 功能 - **扫一扫连接** — 扫描电脑上「设置 → 手机访问」页面的二维码即可添加设备。 也支持手动填写地址,以及直接扫描带 `?token=` 的分享链接(这种链接连密码都不用输)。 - **设备列表** — 支持多台 DSH;点击即切换,可重命名、改地址、删除。 - **设备昵称** — 每台设备都能取名字,昵称直接显示在底部导航栏中间;点一下回到这台设备, 长按进入配置页。 - **不再被首次引导拦住** — DSH 的「初始化」弹窗对已经配置好的服务器没有意义,App 会替你 点掉它自己的「跳过全部 → 开始体验」(设置页可关)。 - **一台设备两个地址** — 主地址之外再存一个「备用地址」(通常是 Tailscale 地址)。 连接时两个地址同时探测、用先回应的那个:在家走局域网,出门走 Tailscale,不用手动切。 - **多设备同时在线** — 连上的设备各自保活一个会话(最多 4 个),切换是秒切,不重新加载、不用重新登录。 - **纯图标导航栏** — 扫一扫 / 运行实例数 / 设备名称 / 设备列表 / 设置 / 刷新,六个图标,不占文字。当前设备那一页不再有顶部标题栏,整屏都是网页。 - **通知转发** — 把网页通知变成手机通知;可选择「仅后台时通知」,正在用 App 时不打扰。 - **自动重新登录** — 保存的密码会在会话失效时自动重放,这是本 App 相对浏览器最大的差别。 - **Tailscale 远程访问** — 出门在外也能连回家里电脑(见下文)。 - ~~**桌面伙伴**~~ — **暂缓,界面上先不提供。** 代码、Android 悬浮窗服务和设置项都还在, 只是设置里不再显示入口;想改随时可以在 `app/lib/core/pet/pet_feature.dart` 里打开。 设计见 [docs/DESKTOP-PET.md](docs/DESKTOP-PET.md)。 - **自动更新** — 应用内检查 GitHub / Gitee 上的新版本,直接下载并调起系统安装器(见 [docs/RELEASING.md](docs/RELEASING.md))。 ## 工作原理 ``` ┌──────────────────────────────┐ │ DSH Mobile Client (本 App) │ │ │ │ ① 扫码得到 http://IP:3081 │ │ ② 密码存进 Keystore │ │ ③ 连接时用 ?token=<密码> │ │ 重新登录(免手输) │ │ ④ WebView 持久化会话 cookie │ └───────────────┬──────────────┘ │ http(s)://<地址>:3081 ▼ ┌──────────────────────────────┐ │ dsh-pocket 认证代理 (:3081) │ │ · 按 Host 区分公网/局域网密码│ │ · 校验通过 → 种 30 天 cookie │ │ · Host/Origin 改写为 loopback│ │ · 注入 dsh web 启动 token │ └───────────────┬──────────────┘ ▼ ┌──────────────────────────────┐ │ dsh web (127.0.0.1:3080) │ │ 官方 Web UI + 移动端布局适配 │ └──────────────────────────────┘ ``` ### 关键点:为什么 App 能做到「不用再输密码」 DSH 的网页会话是一个 cookie,而 `dsh-pocket` 把它的有效性**绑定到电脑上 `dsh web` 进程**: 进程一重启,旧 cookie 立刻失效,浏览器只会再把登录页摆到你面前。 App 的做法是**把密码留在手机的系统密钥库里**,并且每次连接都从 `/?token=<密码>` 这个入口进——服务端接受这个参数后会立刻种一个全新的 cookie。 于是「重启后要重新登录」这件事对用户不再可见:App 自己完成了一次登录,你只是打开了 App。 > 这也是本项目选择「套壳 + 注入适配层」而不是重写 UI 的原因: > 官方 Web UI 才是功能最全、更新最快的那个界面。 ## 快速开始 ### 1. 电脑端准备 1. 安装并打开 **Open DeepSeek Harness Desktop**; 2. 确认插件 **`dsh-pocket`** 已安装(桌面版通常已内置); 3. 打开 **设置 → 手机访问**,你会看到两个二维码: - **📶 局域网**:手机和电脑在同一个 Wi-Fi(或同一个 Tailscale 网络)时使用; - **🌐 公网**:点了「开启公网访问」之后出现,人在外面也能用。 > 建议在手机访问页面里把局域网密码**自定义成固定的 8 位密码**, > 这样密码不会变,App 可以一直用它自动登录。 ### 2. 安装 App 从 [Releases](../../releases) 下载 APK 安装,或者按下面的「构建」自行编译。 发布页有**两个包**,按手机情况选: | 包 | 体积 | 适合 | | :-- | :-- | :-- | | `dsh-mobile-client-<版本>.apk` | ~70 MB | 一般手机。系统 WebView 较新,或者手机上已经装了更新的内核 | | `dsh-mobile-client-<版本>-legacy.apk` | ~110 MB | **老手机**。内置了一份 Chromium,不依赖系统 WebView,也不要求用户装任何东西 | 拿不准就先装普通版;如果连上去是白屏,说明系统 WebView 太旧,换 legacy 版。 ### 3. 扫码连接 1. 打开 App → 底部导航栏点 **扫一扫**; 2. 扫描电脑上「手机访问」页面的**局域网二维码**; 3. App 会预填地址,给设备起个昵称;这台机器如果还有别的入口(比如 Tailscale 地址), 填进**备用地址**,以后会自动挑能通的那个。保存; 4. 页面要求密码时,App 会弹出输入框,**填电脑上显示的那 8 位**; 5. 之后每次打开 App 都会直接进入 DSH —— **不再需要输入密码**。 > 添加 / 编辑设备的表单里**没有密码输入框**,这是故意的。密码在你真正连上去、 > 服务器真的开口要的那一刻才问——那也是唯一能判断密码对错的时刻。 > 在表单里先填一遍,连上去还要再填一遍,是同一件事做两次。 ### 4. 通知与桌面伙伴 - **通知**:默认开启。首次打开会请求系统通知权限;网页里的通知会变成手机通知。 - **桌面伙伴**:**暂缓**,设置里已经没有这个入口了。代码保留,见下一节。 ## Tailscale 远程访问 想在 4G 下连回家里电脑,又不想把 DSH 暴露到公网,**Tailscale 是最省事的方案**: 1. 电脑和手机都装上 Tailscale 并登录同一个账号; 2. 在电脑上「手机访问」页面的**「局域网地址」下拉框**里,选中电脑的 Tailscale 地址 (形如 `100.x.y.z`)——dsh-pocket 会把 CGNAT `100.64.0.0/10` 视为局域网, 所以这里用的仍然是**局域网密码**,而不是公网密码; 3. 手机上用 App 连接 `http://100.x.y.z:3081`,密码填局域网密码。 这样流量只走你的 tailnet,不经过任何第三方公网入口。 > **在家别绕 Tailscale。** 第 2 步把「局域网地址」改成 Tailscale 地址之后,二维码里写的 > 就是 `100.x.y.z`——在家连着同一个 Wi-Fi 时也在绕 Tailscale。不用手动切:把主地址填成 > `http://192.168.x.x:3081`,**备用地址**填 `http://100.x.y.z:3081`,连接时两个同时探测, > 谁先回应用谁。 > > **如果一直很慢**,多半是没打通直连、走了 Tailscale 的 DERP 中继(官方节点都在境外)。 > 电脑上 `tailscale ping <手机名>` 看是 `direct` 还是 `via DERP(...)`;是中继的话, > 路由器开 UPnP / NAT-PMP 能明显提高直连成功率。 > 注意:`100.64.0.0/10` 是 CGNAT 网段,手机必须真的在同一个 tailnet 里才连得上。 ## 桌面伙伴 > **暂缓。** 功能本身是做完的,但界面入口先撤了——悬浮在桌面上的角色是新用户第一眼 > 看到的东西,还没打磨到能拿出手。代码、Android 悬浮窗服务和设置项都还在, > 把 `app/lib/core/pet/pet_feature.dart` 里的 `PetFeature.available` 改成 `true` 就回来了。 > 下面这一节是设计说明,内容依然有效。 | 平台 | 能力 | |---|---| | **Android** | 角色可以悬浮在桌面 / 其他 App 之上,可拖动、点按回到 App;可换自己的角色图片。需要 `SYSTEM_ALERT_WINDOW` 权限。 | | **iOS** | **系统不允许**第三方 App 覆盖桌面,因此只有应用内的角色展示(设置页预览)。这是平台限制,不是本项目偷懒。 | 内置角色由 Flutter(`CompanionPainter`)和 Android(`CompanionView`)分别绘制同一套图形, 所以设置页的预览和桌面上的样子是一致的。 **想换成真正的动漫角色?** 现在的结构是为此留的: - 现在:内置矢量角色,或用户在设置里选一张自己的图片; - 之后:把 `CompanionView.onDraw` 换成 Live2D / 精灵图渲染即可, 服务、权限、MethodChannel 都不用动。详见 [docs/DESKTOP-PET.md](docs/DESKTOP-PET.md)。 ## 技术栈与选型 **Flutter 3.35(Dart 3.9)**,一套代码同时出 Android 和 iOS。 选它的理由: 1. **需求 7 决定了必须是「一套代码 + 原生扩展」**:桌面伙伴只能靠 Android 原生 悬浮窗(`TYPE_APPLICATION_OVERLAY` + 前台服务)实现,Flutter 通过一个 MethodChannel 调它;如果为了这一件事拆成两套原生代码,前 6 条需求就要写两遍。 2. **角色可以是一个 Flutter Widget**:桌宠未来要动画、要 Live2D、要交互, Flutter 的渲染管线比「原生 + WebView」更好扩展。 3. **生态正好覆盖全部需求**:`flutter_inappwebview`(JS 注入 / Cookie)、 `mobile_scanner`(扫码)、`flutter_local_notifications`(通知)、 `flutter_secure_storage`(Keystore / Keychain)。 > 如果你只想要一个**几十 KB 的纯 WebView 壳**,可以参考 > [dsh-mobile-app](https://github.com/hongshuxifan321/dsh-mobile-app)(Android 原生,零依赖)。 > 本项目要的是「一个真正的 App」,所以选了 Flutter。 ## 更新与发布 App 是 APK 直装、不走应用商店,所以「用户怎么知道有新版本」必须由项目自己解决: | 环节 | 做法 | |---|---| | **发布** | GitHub Releases 与 Gitee Releases 同时发布,互为镜像 | | **检查** | App 同时查询两边的公开 Release API,取版本号更高的那个 | | **安装** | 下载 APK 后交给系统安装器,需要用户授予一次「安装未知应用」 | 打一个 tag 就会自动走完整个流程: ```bash # 1. 改 app/pubspec.yaml 的 version: 1.1.0+2 # 2. 提交后打 tag,注释就是 App 里显示的「更新说明」 git tag -a v1.1.0 -m "免密登录更稳 - 修复 dsh web 重启后偶发要求重新登录" git push origin master --tags ``` CI 会校验 tag 与 `pubspec.yaml` 是否一致、跑分析和测试、构建 APK、发 GitHub Release,再把同一个包镜像到 Gitee。 完整流程(Gitee 令牌配置、签名要求、版本号规则、手动兜底)见 **[docs/RELEASING.md](docs/RELEASING.md)**。 > ⚠️ 两个前提:仓库必须**公开**(私有仓库的 Release API 需要 token,而 token 不能打进 App); > 版本号必须**和 tag 一致**,否则 CI 直接失败——发出版本号对不上的包会让所有人的更新检查失灵。 > > 如果你 fork 到自己账号下,构建时指定仓库地址: > `--dart-define=DSH_GITHUB_REPO=you/dsh-mobile-client --dart-define=DSH_GITEE_REPO=you/dsh-mobile-client` ## 遇到白屏或连不上怎么办 这个 App **没有埋点、没有崩溃上报**,所以出问题时唯一的办法就是你把诊断信息发出来——那里面有定位所需的一切。 | 情况 | 怎么办 | |---|---| | **连不上** | 页面不再白屏,而是直接说明原因(超时 / 网络不通 / 服务器拒绝 / 页面空白),并给出对应建议。点「复制诊断信息」即可 | | **平时** | `设置 → 诊断与反馈`:看日志、复制、分享,或直接跳到 GitHub Issues | 报告包含:设备型号、Android 与 **MIUI 版本**、**系统 WebView 的包名与版本**、当前设备地址、页面探测结果、本次与上一次运行的日志,以及页面里捕获到的 JS 异常和 `console.error`。 > 🔒 **访问密码和会话 Cookie 会被自动隐藏。** `?token=` 的值一律替换为 ``;密码本身还会作为「已知密钥」被全文擦除,所以即使它出现在页面标题或报错信息里也留不下来。这不是可选项——擦除发生在日志写入时,调用方无法忘记。 > > 这也是为什么白屏值得单独处理:它是唯一一种「页面加载成功了、但什么都没显示」的故障,没有错误码、没有异常,用户手上没有任何可反馈的东西。 ### 白屏的根因与兼容层 DSH 的前端由 Vite 构建。Vite 只把**语法**降级到它配置的目标,**不 polyfill 运行时 API**——于是包里同时存在两类超出旧引擎能力的东西: | 构造 | 需要 Chromium | |---|---| | 私有方法 `#name() {}` | 84 | | `??=` / `\|\|=` / `&&=` | 85 | | `static {}` 初始化块 | **94** | **真正卡住的是语法,不是 API。** 语法错误意味着整个文件**一行都不会执行**——shim 再全也救不了。 现场的诊断报告把这一点钉死了:红米 K20 Pro 的系统 WebView 是 **Chromium 83**,日志里是 ``` uncaught: Uncaught SyntaxError: Unexpected token '=' @ .../assets/index-DS_0SByp.js:2:8267 ``` 而那一位正好是 `p ??= H0(...)`。同一份报告里,shim 明明生效了(`compat: SHIMMED Object.hasOwn, ...`),页面却依然白屏——因为脚本根本没被解析。 App 因此做了两件事: 1. **兼容 shim**:在页面脚本之前补齐 bundle 用到的运行时 API(清单见下一节)。它们在旧引擎上是真的有用(那类设备语法没问题、只缺 API),在够新的引擎上完全不生效。 2. **版本闸门**:WebView 低于 Chromium 94 时直接给出说明页,而不是白屏。门槛取 94(`static {}`)而非 85(`??=`)是刻意的——只修运算符只会把解析错误往后推几 KB。 低于 94 时**唯一的解法是换一个更新的内核**。把 bundle 改写到那个程度不是 shim,是转译器——`static {}` 没法在不理解所在类的情况下做文本改写。 ### 能解析不等于能跑 第二份现场报告来自同一个用户、同一个机型,这次跑的是**内置内核**: ``` webViewKernel: upgraded kernel: bundled webview/armeabi-v7a.apk (now 113.0.5672.136) loaded http://100.111.56.77:3081/ ``` Chromium 113 解析得了、也挂载了,页面却连不上: ``` Uncaught TypeError: Promise.withResolvers is not a function @ http://100.111.56.77:3081/:47:65 [session-controller] control stream failed: TypeError: AbortSignal.any is not a function [connection] connection lost, retry #1 ``` 所以 **94 只是「能解析」的门槛,不是「能跑」的门槛**。`Promise.withResolvers` 要 119、`AbortSignal.any` 要 116,两个都在 113 之上,而第二个正好落在会话控制流的第一次读取上——于是界面出得来、连接永远建不起来。 补上这两个之后,shim 的完整清单是(每一项都是**扫 bundle 扫出来的**,不是猜的): | API | 需要 Chromium | |---|---| | `String.prototype.replaceAll` | 85 | | `Array.prototype.at` / `String.prototype.at` | 92 | | `crypto.randomUUID` | 92,**且要求安全上下文** | | `Object.hasOwn` | 93 | | `Array.prototype.findLast` / `findLastIndex` | 97 | | `AbortSignal.timeout` | 103 | | `Array.prototype.toReversed` / `toSorted` / `with` | 110 | | `String.prototype.toWellFormed` | 111 | | `ArrayBuffer.prototype.transfer` | 114 | | `AbortSignal.any` | 116 | | `Object.groupBy` / `Map.groupBy` | 117 | | `Promise.withResolvers` | 119 | | `URL.canParse` | 120 | | `Set.prototype.union` / `intersection` / `difference` | 122 | | `URL.parse` | 126 | | `Promise.try` | 128 | | `Symbol.dispose` | 134 | | `navigator.clipboard` | **要求安全上下文** | ### 自动改用手机上更新的内核 App 会尝试**复用手机上已经安装的更新内核**,而不是要求用户去动系统。实现基于 [WebViewUpgrade](https://github.com/JonaNorman/WebViewUpgrade)(MIT):它劫持 WebView provider 的 binder,让**本进程**从另一个已安装的包解析 WebView。 **不替换系统 WebView、不影响其他应用、不需要 root**,用户卸载那个内核 App 即可撤销。 两个约束决定了实现方式: - **时机**:provider 在进程内第一次创建 WebView 时就绑定,之后无法热切换。所以这段逻辑跑在 `WebViewKernelProvider`(一个 `ContentProvider`)的 `onCreate()` 里——它发生在 `bindApplication` 期间、`Application.onCreate()` **之前**,是唯一还来得及的窗口。已经创建过 WebView 再切换,需要冷启动 App 才生效。 - **只有完整单体 APK 能作为内核**:Google Play 分发的 Chrome 和 Android System WebView 是 split 安装包,**用不了**。所以从商店装的 Chrome 即使版本够新也不会被采用,需要完整版 APK。 没找到可用内核时不会白屏,而是给出说明页。Android 侧的 `MIN_CHROMIUM` 与 Dart 侧的 `CompatScript.minimumChromium` 有一个测试守着,防止两边漂移。 还有两个方向相反的坑:`crypto.randomUUID` 和 `navigator.clipboard` 都要求**安全上下文**,而本 App 是刻意用明文 HTTP 连局域网地址的——所以它们在**最新**的 WebView 上同样不存在。`crypto.getRandomValues` 和 `document.execCommand('copy')` 没有这个限制,于是也被一并补上。 每个 shim 都先检测原生实现,新设备上完全不生效。行为本身由 `tools/compat_check.mjs` 把关:它把原生实现**删掉**、换成我们的、逐项对比结果(`node tools/compat_check.mjs`,需要 Node 24+,CI 里会跑)。 有一件事**故意不做**:`structuredClone` 只在日志里报出来,不补。克隆写错会**静默**改坏状态,而方法缺失会在出错的地方当场抛异常——后者在手机上才查得动。 ### 内置内核的版本(legacy) 复用已装内核有个前提:**手机上得有一个够新的完整内核**。一台干净的老机器可能一个都没有。于是有一个把内核**直接打包进 App** 的版本。 它内置的是 **AOSP `com.android.webview` arm32**(Chromium 113,minSdk 24),启动时解出来当内核用——**不安装任何东西、不碰系统、用户无感**。 解包**由 App 自己做**,而不是用 WebViewUpgrade 的 `UpgradeAssetSource`:后者每次启动都会重新拷一遍,而且在后台线程上做,于是存在一个窗口——这次会话的第一个 WebView 可能仍按旧系统内核创建,而那个绑定一旦发生就收不回去。自己拷贝发生在 `ContentProvider.onCreate` 里,早于进程内任何可能创建 WebView 的代码。 同理,**内置内核这条路在换内核之前不向 WebView 问任何问题**(连「系统当前用的是哪个内核」都不问)。库的 `checkPreconditions` 明确要求 `WebViewFactory.sProviderInstance == null`,早一点是确定安全的,早问一句则要靠推理——不如不推理。 代价是磁盘:解出来的内核(~85 MB)加上它里面的 native 库(~64 MB),App 数据大约 **150 MB**。只在首次启动解一次,之后走缓存。 两个设计选择: - **为什么是 arm32**:arm64 的单体内核接近 200 MB,arm32 只有 85 MB。所以 legacy 版本**自己也是 32 位构建**(`--target-platform android-arm`),让进程 ABI 和内核匹配。32 位 App 在 arm64 手机上照常运行,覆盖面反而更广。 - **为什么是 AOSP 而不是 Google 版**:再分发许可上最干净,而且这个版本本来就是给老机器兜底的,差别不重要。 - **为什么按进程 ABI 找内核**:`Build.SUPPORTED_ABIS` 描述的是设备而不是进程——64 位手机上的 32 位进程会先把 `arm64-v8a` 报在前面,而 arm64 内核装不进 32 位进程。所以先按 `Process.is64Bit()` 把列表切成匹配的那一半。 - **为什么 legacy 包只打 armeabi-v7a**:Android 是按 APK 里的 native 库来定进程 ABI 的。某个插件的 AAR 只要带了 arm64 库,进程就会按 64 位起来,然后找不到 64 位的 Flutter 引擎。所以 legacy 变体用 `androidComponents.onVariants` 把 `arm64-v8a` / `x86_64` 排除掉,让这个选择没有歧义,顺带也小了一点。 内核有 85 MB,不适合进仓库,所以单独下载: ```bash ./tools/fetch_webview_kernel.sh # 下载到 app/android/app/src/legacy/assets/webview/ ``` 默认从 [WebViewPackage](https://github.com/JonaNorman/WebViewPackage) 取,直连失败会自动走 ghproxy 镜像。可以用环境变量换厂商 / ABI / 版本: ```bash DSH_WEBVIEW_KERNEL_VENDOR=google DSH_WEBVIEW_KERNEL_VERSION=119.0.6045.53_min24_arm32 \ ./tools/fetch_webview_kernel.sh ``` > 内置的是第三方编译的 Chromium 二进制,再分发前请自行确认许可。仓库里只放机制和下载脚本,不放二进制。 **两个版本的更新是分开的。** 更新器会先按文件名把两个包分开,再挑 ABI,绝不交叉——给老设备推普通包会白屏,给普通用户推 legacy 包则白白多下 110 MB。这一点有测试守着。 ## 构建 ### 本地构建 需要:Flutter 3.35+、Android SDK(compileSdk 36)、JDK 17–23(Gradle 8.12 不支持 JDK 24+)。 项目有两个 flavor,所以 **`--flavor` 是必填的**: ```bash cd app flutter pub get flutter analyze # 普通版 flutter build apk --release --flavor standard # 产物:app/build/app/outputs/flutter-apk/app-standard-release.apk # 内置内核版(先跑一次 tools/fetch_webview_kernel.sh) flutter build apk --release --flavor legacy --target-platform android-arm # 产物:app/build/app/outputs/flutter-apk/app-legacy-release.apk ``` `flutter run` 同样需要 `--flavor standard`。 iOS 需要 macOS + Xcode: ```bash cd app flutter build ios --release ``` > Release 构建的签名来自 `app/android/key.properties`,跑一次 `sh tools/setup-release-signing.sh` > 就会生成 keystore 并写好这个文件。没有它时会退回 debug 签名,而**这种包无法覆盖安装**: > Android 拒绝用不同密钥签名的包,用户只能先卸载——设备列表和存在系统 Keystore 里的访问 > 密码都会一起丢掉。所以正式分发前务必配置。keystore 放在仓库外(`.gitignore` 已排除 > `*.jks` / `*.keystore` / `key.properties`),细节见 `docs/RELEASING.md` 第六节。 ### 应用图标 图标(Android 自适应图标 + iOS AppIcon)是脚本画出来的,不提交无法编辑的二进制: ```bash python3 tools/make_icons.py --ascii # 在终端里看轮廓,不用装图片查看器 python3 tools/make_icons.py --preview p.png # 出一张对照图:三种蒙版 + 各个尺寸 python3 tools/make_icons.py # 重画全部 PNG(需要 Pillow) ``` 图案是一尾鲸尾(DeepSeek 蓝底 + 白色剪影,参考 DeepSeek 的鲸鱼标志,但不是它家的图形)。 轮廓由四条三次贝塞尔曲线定义,右半边是左半边的镜像,所以两边不会走形。 改颜色或比例就改脚本顶部的常量然后重跑;Android 的 legacy PNG、自适应前景与 iOS 全部尺寸会一起更新。 自适应前景按 108dp 画布作图、图案占 54% 宽——`--preview` 里能看到它落在安全圆内, 圆角方形和圆形蒙版都不会切到。 ### 构建注意事项 - 部分插件(如 `flutter_inappwebview_android`)会把自己的 `compileSdk` 锁在旧版本, 在只装了新平台 SDK 的机器上会报 `Failed to find Platform SDK with path: platforms;android-34`。 `app/android/build.gradle.kts` 已把所有子项目统一抬到 36,无需手动装旧 SDK。 - Gradle 8.12 不支持 JDK 24 及以上,请用 JDK 17–23。 - 首次构建需要下载 Gradle 与依赖,耗时较长;之后是增量构建。 - **R8 会吃掉 WebViewUpgrade**。Flutter 默认给 release 打开 R8,而这个库的 AAR 里 `proguard.txt` 是空的、不带 consumer rules,于是它会被混淆甚至删类——**构建照样成功、 APK 照样能装,只是这个功能在它本该帮助的旧设备上静默失效**。 `app/android/app/proguard-rules.pro` 里的 keep 规则就是为它准备的(Flutter 的 Gradle 插件本来就引用这个文件,只是默认不存在)。改动依赖后如果怀疑这个功能没生效, 可以在 `app/build/app/outputs/mapping/release/mapping.txt` 里搜 `com.norman.webviewup`: 映射应该是恒等的,出现 `R8$$REMOVED$$CLASS$$` 就说明规则没生效。 ## 项目结构 ``` app/lib/ ├── main.dart # 初始化服务、注入依赖 ├── app.dart # 主题 / 语言 / 根路由 ├── core/ │ ├── dsh/dsh_endpoint.dart # 地址解析、Host 分类、token 提取 │ ├── models/ # 设备与设置的数据模型 │ ├── storage/ # JSON 文档 + 系统密钥库 │ ├── notifications/ # 通知服务 + 注入网页的 JS 桥 │ ├── browser/compat_script.dart # 注入页面的兼容 shim(旧 WebView 白屏的根因) │ ├── diagnostics/ # 日志、脱敏、页面探测、反馈报告 │ ├── update/ # 版本比较、GitHub/Gitee 查询、APK 下载 │ ├── platform/app_platform.dart # 系统设置页 / 安装 APK 的 MethodChannel │ ├── pet/pet_platform.dart # 悬浮窗 MethodChannel │ ├── state/ # ChangeNotifier 控制器 │ └── i18n/l10n.dart # 中英文字符串表 └── features/ ├── home/home_shell.dart # 六图标底部导航 + 多会话保活 ├── scanner/ # 扫一扫 ├── devices/ # 设备列表 / 增删改 ├── browser/dsh_webview.dart # WebView 外壳 + 自动重新登录 ├── settings/ # 设置 └── pet/ # 角色绘制(与 Android 端一致) ``` ## 安全与隐私 - **访问密码只存在系统密钥库**(Android Keystore / iOS Keychain), 不写进普通配置文件;密钥库写入失败时**宁可报错也不回退明文**。 - 设备列表里**只保存「是否设置过密码」这个布尔值**,密码本身从不进内存缓存。 - App **不收集、不上传任何数据**,没有账号体系,没有统计埋点。 - 局域网明文 HTTP 是必要的(家用服务器没有证书),Android 侧已用 `network_security_config` 说明原因;公网隧道始终是 HTTPS。 - DSH 能执行你电脑上的代码。**不要把二维码、地址或密码发给别人**; 密码一旦泄露,请在电脑上「手机访问」页面点「刷新」换新。 ## 致谢 本项目的每一块难啃的骨头都属于下面这些项目。**没有它们就没有这个 App。** - **[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)**(deepseek-ai)—— 智能体运行时与 Web 工作区。本 App 显示的就是它的官方 Web UI。 - **[Open DeepSeek Harness Desktop](https://github.com/flaqai/open-deepseek-harness-desktop)**(flaqai)—— 开箱即用的社区桌面发行版。它提供的「手机访问」能力正是本 App 对接的对象, 本项目也是因它而起。 - **[dsh-pocket](https://github.com/shaobeichen/dsh-pocket)**(shaobeichen)—— 手机访问代理:二维码、访问密码、局域网开关、cloudflared 隧道、移动端布局适配。 本 App 的自动重新登录正是建立在它的 `?token=` 语义之上。 - **[dsh-mobile-app / DSH Remote](https://github.com/hongshuxifan321/dsh-mobile-app)**(hongshuxifan321)—— 把「手机壳 + 扫码连接 + 凭证加密存储」这条路走通的先行项目, 本项目的许多设计取舍(尤其是凭证加密「失败不回退明文」)直接受益于它的总结, 该总结收录于 [docs/REFERENCE-dsh-remote.md](docs/REFERENCE-dsh-remote.md)。 - **[dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile)**(mexiaosqwq,MIT)—— 移动端布局适配,经 dsh-pocket 移植。 - **[cloudflared](https://github.com/cloudflare/cloudflared)** —— 公网隧道。 同时感谢 Flutter、以及 `flutter_inappwebview`、`mobile_scanner`、 `flutter_local_notifications`、`flutter_secure_storage`、`provider` 等开源项目的作者。 ## 开源声明 - 本项目以 **MIT 许可证**开源,见 [LICENSE](LICENSE)。你可以自由使用、修改、分发。 - 本项目**不包含、不修改、不重新分发** DeepSeek Harness、Open DeepSeek Harness Desktop 或 dsh-pocket 的代码;它只在运行时通过 HTTP/WebSocket 访问它们。 - 这些上游项目各自遵循其原有许可证:DeepSeek Harness 与 dsh-pocket(**GPL-2.0**)等, 请以各自仓库为准。 - 本项目是**社区项目,与 DeepSeek 官方无关**。 ## 路线图 - [x] 扫码 / 手动添加设备、设备列表与昵称 - [x] 凭证加密存储 + 自动重新登录 - [x] 网页通知 → 手机通知 - [~] Android 悬浮桌宠(内置角色 / 自定义图片)——已实现,但界面上先屏蔽 - [ ] 桌宠:Live2D / 精灵图角色、点击互动、待机动作 - [ ] 连接状态与流式输出进度的常驻通知 - [x] 应用内检查更新 + 一键下载安装(GitHub / Gitee 双源) - [ ] 多设备会话快速切换(同时保持后台连接) - [ ] 桌面版「手机访问」页直接生成 App 深链二维码(省掉手输密码)