dsh-mini-remote 0.2.1 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  本文件记录每个版本改了什么。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.2.2] - 2026-10-18
6
+
7
+ 发布后一天的收尾,只有一件事:**把「跨机窗口白屏」这个问题在源头拦住,并防它将来以别的方式再出现**。判断来自把它查到底的一次调研——那枚窗口会话 cookie 属于"第三方 cookie",而浏览器正在收紧这一类。
8
+
9
+ ### 改了的
10
+
11
+ - **配对时填 `http://` 会被当场拒绝**(本机回环地址除外)。原来它能配对、也能取票,用户一路顺利,直到点开那一行才看见白屏——因为进门要种的那枚 cookie 是 `SameSite=None; Secure`,浏览器在非 https 的地址上直接把它丢掉。现在在"填地址"这一步就说清,并说明原因。本机 `127.0.0.1` / `localhost` 仍可用 `http://`(浏览器把回环当可信来源),本机自测那条路不受影响。
12
+
13
+ ### 修了的
14
+
15
+ - **手机端导航适配"整份失效"的真根因找到了:我们注入的样式被 HMR 清掉了。** 上一版把选择器改成 `[class*="_sidebarCol"]` 之后仍然不生效,于是换成**去浏览器里查**(无头 Edge + 390×844,加载前先挂 `MutationObserver`):页面加载后约 **270 毫秒**,`@deepseek-ai/dsh-client-hmr` 会重建 `<head>`,把**不认识的 `<style>` 一律当旧的移除**——官方那几份随后会被它重新插回来,**我们这份不会**。所以服务端返回的 HTML 里明明有 `#mini-mirror-adapt`(位置、长度都对),浏览器里 `getElementById` 却是 null,整份适配一条规则都不生效。现在**别往 DOM 里放**了——用 `adoptedStyleSheets`:样式以 `CSSStyleSheet` 对象挂在 `document` 上,**根本不在 DOM 里**,HMR 重建 `<head>` 时删不到它。实测插入后侧栏 `overflow-y` 立刻变 `auto`,6 秒后再看(HMR 又重建过一轮、`<style>` 标签确实已被删)依然是 `auto`。老浏览器没有这个 API 时,退回"插 `<style>` + 有上限的轮询"。
16
+
17
+ **这一天在这条路上踩了三次**,前后记下来:① `MutationObserver` 盯 `<head>`、回调里又插节点 → 和 HMR 互相触发**死循环、页面卡死**(用户报「进不去」);② 改成节流轮询 → 手机布局下 HMR 比桌面活跃,补一次删一次,25 次耗尽后照样失效;③ `adoptedStyleSheets` → 绕开 DOM,一次成功。**教训:跟一个会重建 DOM 的东西抢 DOM,怎么抢都是输;换个不参与竞争的位置才对。**
18
+
19
+ - **手机端侧栏滚不动、够不着「设置」、会话列表被挤没——修回来了。** 官方把侧栏那一列的标记 `data-pane="sidebar"` **整套去掉**了(8.9MB 的客户端包里已经一次都不出现),而"放开侧栏纵向滚动"那条规则还在、**但匹配不到任何元素**:规则没删、也不报错,静默失效。现在改按类名匹配 `[class*="_sidebarCol"]`(前缀是每次构建的随机哈希,只匹配稳定那半截)。症状和当初修它之前一模一样,原因却完全不同。
20
+
21
+ - **窗口会话 cookie 加 `Partitioned` 的事:试过,撤回了。** 本来是想防"浏览器收紧第三方 cookie"(CHIPS)。但 DSH 桌面端那一层的顶层页面跑在自定义协议 `dsh-app://app/` 上——那**不是有效的分区上下文**,浏览器会把整枚 cookie 拒掉。真机症状:框里停在「Loading plugins…」(说明带票那次请求成功了、HTML 到了),之后子资源全过不去,20 秒后报「那台电脑的界面一直没打开」。撤掉后恢复正常。将来要再碰这一块,得先解决"分区键从哪来"。
22
+
23
+ - **窗口凭据被浏览器拒收时,不再静默白屏。** 撤掉 `Partitioned` 之后,"阻止第三方 cookie"这个风险还在:那枚窗口会话 cookie 属于"第三方 cookie",被拦掉时的表现是框里停在「Loading plugins…」——用户只会去怀疑网络或对面没开,而修法完全不同。现在两头都加了说明:服务端能认出"刚种下却没被带回来"(连两次才算数,排除竞态),在自检的「最近出错」里报一条并说清原因与修法;那一屏的兜底提示也把"浏览器拦了跨站凭据"列进可能原因,并提示去顶部横条点「再试一次」。
24
+
25
+ ### 文档
26
+
27
+ - **开篇那段重写了。** 原来只讲移动端("只保留你的输入和结论"),现在把 PC 端一起说清:移动端三种模式(单帧 / 聊天 / 完整)自己选屏蔽还是展开,PC 端则在多台设备上、用桌面端原生的界面操作核心设备上的 DSH。
28
+ - **导航里加了「移动端使用」**,位置在「效果预览」和「远程窗口」之间——三个"看和用"的入口连在一起:先看效果、再看手机怎么用、然后是桌面端远程窗口,之后才是"装"。
29
+ - 「移动端页面操作指引」补上**怎么拿到这个页面的地址**:那一页会列出这台电脑能用的每一条地址(本机 / 内网 / Tailscale / Tailscale(加密)/ 公网访问),每条左边是二维码、右边是完整网址,点一下网址框会自动全选。同时提醒:**这条网址里带着访问口令**,别外发。
30
+ - 「必须 https」那句改准了:被浏览器丢掉的是**票换来的那枚 cookie**,不是票本身——票在 http 下照常能用,所以配对和取票都成。
31
+ - 「远程窗口」与「移动端会话治理」两条特性描述改得更凝练;中英两版逐节拉平(28 对 28 节、每节行数比 1.00),顺手统一了几处中英混排的空格和 `DeepSeek Harness` 的写法。
32
+
5
33
  ## [0.2.1] - 2026-10-17
6
34
 
7
35
  这一版跨了十天、46 笔改动,主体是**「远程窗口」真的能跨电脑了** —— 在另一台电脑的 DSH 桌面端里,打开这台电脑的界面。它前面压着一件必须先做的事:**多设备身份**(原来一个口令管所有设备,撤销一台等于全撤)。后面跟着一串"让它用起来不别扭"的修补,以及一件拖了很久的工程账:**这个仓库终于有 CI 了**。
package/README.en.md CHANGED
@@ -21,6 +21,7 @@
21
21
  <a href="#background--pain-points">Background & Pain Points</a> ·
22
22
  <a href="#core-features">Core Features</a> ·
23
23
  <a href="#ui-preview">UI Preview</a> ·
24
+ <a href="#mobile-interface-guide">Mobile Usage</a> ·
24
25
  <a href="#desktop-remote-window">Remote Window</a> ·
25
26
  <a href="#quick-start">Quick Start</a> ·
26
27
  <a href="#usage--configuration">Usage & Configuration</a> ·
@@ -37,7 +38,7 @@ When running complex engineering tasks with desktop or web AI agents, a single t
37
38
  - **Attention Overload**: During commutes, meals, or brief breaks away from the keyboard, the primary goal is simply checking whether a round finished and dispatching the next instruction, without dissecting every intermediate step.
38
39
  - **Inflexible Connectivity**: Pure LAN routes disconnect as soon as you step outside, while centralised cloud relays introduce external credential custody risks.
39
40
 
40
- **Minimal Remote** was built to solve this: by default, the mobile view presents only your input and the AI's final answer, filtering out execution noise while allowing you to drill down into the full trace on demand.
41
+ **Minimal Remote** is built around exactly that: it fits several ways of working and hands attention control back to you. On mobile, three modes — "Single frame", "Chat" and "Full" — let you hide execution noise entirely, or expand the full trace whenever you want. On PC, the "Remote Window" feature lets you drive the DeepSeek Harness on your main machine from other devices, inside its own native desktop UI.
41
42
 
42
43
  ---
43
44
 
@@ -48,8 +49,8 @@ When running complex engineering tasks with desktop or web AI agents, a single t
48
49
  - 🌐 **Multi-Route Secure Connectivity**: Connect via LAN, Tailscale private network (supporting MagicDNS and HTTPS certificates), or Cloudflare Tunnel (for environments without public IPs). All routes coexist with dedicated QR codes and tokens.
49
50
  - 🐬 **Lightweight Anthropomorphic Feedback**: Inter-step narrations are spoken aloud by the whale girl sprite in speech bubbles; execution progress and queued instructions are visualized on an exact scale, filtering out raw reasoning blocks.
50
51
  - 💬 **Feishu Long-Connection Remote**: Direct bidirectional communication through the Feishu Open Platform WebSocket long connection without public ports, supporting text dialogue, multiple-choice responses, and the `/会话` switch command.
51
- - 🗂️ **In-Place Mobile Session Management**: Rename, pin, archive, or fork sessions from the most recent completed turn directly on your phone, with clear indicators for pending approvals and questions.
52
- - 💻 **Desktop Embedded "Remote Window"**: Adds a "Remote Window" shortcut to the left sidebar of the DSH desktop app: it lists the other computers you have paired, and clicking a row embeds that computer's DSH desktop interface in the main view; click the row again to exit and return to where you were. Cross-machine access is supported; see "Desktop \"Remote Window\"" below for the pairing steps.
52
+ - 🗂️ **In-Place Mobile Session Management**: Open it as a web page in your phone's browser and rename, pin, archive, or fork sessions right there, with clear indicators for pending approvals and questions.
53
+ - 💻 **Desktop Embedded "Remote Window"**: A "Remote Window" shortcut in the DSH desktop left sidebar lets you operate another computer's DSH desktop remotely, with native-feeling window transitions and no page switch; see "Desktop \"Remote Window\"" below for the pairing steps.
53
54
  - 🔒 **Rigorous Security Boundaries**: All API routes enforce constant-time token comparison to prevent timing side-channel attacks; built-in rate limiting locks out sources after 5 failed attempts in 1 minute; pairing data is accessible solely from the host loopback interface.
54
55
 
55
56
  ---
@@ -168,6 +169,20 @@ Select the route that matches your current networking environment:
168
169
 
169
170
  ### Mobile Interface Guide
170
171
 
172
+ **First, get the address of this page.** Open "Settings → Phone Remote" in the DSH desktop app; it lists **every address this computer can be reached at** — "This machine", "LAN", "Tailscale", "Tailscale (encrypted)", "Public access". Which ones appear depends on what this computer has installed and switched on.
173
+
174
+ Each one is a block: **QR code on the left, the full URL on the right**. Scanning the QR code with the phone is the easiest way; to type it on another device, click "Copy" on the right, or click into the URL field (it selects the whole address for you).
175
+
176
+ An address looks like this:
177
+
178
+ ```text
179
+ http://192.168.1.2:3090/mini?token=xxxxxxxxxxxx
180
+ ```
181
+
182
+ > ⚠️ **That URL carries the access pass** — whoever gets it can operate this computer. Do not screenshot it to others, and do not paste it anywhere public. To change the pass, see the "Password" section in the settings page.
183
+
184
+ Once you are in, this page lets you:
185
+
171
186
  - **Sending & Queuing**: Enter instructions in the bottom input box. When an agent is busy, subsequent messages queue up sequentially and appear on the scale.
172
187
  - **Slash Commands**: Type `/` to bring up available session commands with parameter guidance and search filtering.
173
188
  - **Interactive Questions**: When the agent requests clarification or user choice, an interactive card pops up for one-tap answers or custom replies.
@@ -242,7 +257,7 @@ Each row shows the name, the address, `last ok <time>` (or "no record yet"), and
242
257
  **Which address to enter**
243
258
 
244
259
  - Enter the address shown on A's "Phone Remote" page (no trailing `/mini` needed) — **not** the one you use to open the DSH interface on A itself.
245
- - **It must be `https://`.** A plain `http://` address can pair and can obtain the pass, but the frame stays blank — the browser drops that pass on a non-HTTPS address.
260
+ - **It must be `https://`.** A plain `http://` address can pair and can fetch a ticket, but the frame stays blank: on the way in, the browser has to store a cookie for this screen (every request inside the frame is then identified by it), and it will only store such a cookie over an encrypted connection. On an http address that cookie is dropped outright, so every later request is refused. Local `127.0.0.1` is the one exception — browsers treat loopback as a trusted origin — which is why this restriction only bites cross-machine setups.
246
261
  - Tailscale is recommended: switch on "HTTPS address (Tailscale)" on A, then enter `https://<machine>.<your-tailnet>.ts.net` on B — **no port needed**. The address is stable, so you set it once.
247
262
  - The "Public access" tunnel address changes on every restart, so it is not suitable for cross-machine use.
248
263
 
package/README.md CHANGED
@@ -21,6 +21,7 @@
21
21
  <a href="#背景与痛点">背景与痛点</a> ·
22
22
  <a href="#核心特性">核心特性</a> ·
23
23
  <a href="#效果预览">效果预览</a> ·
24
+ <a href="#移动端页面操作指引">移动端使用</a> ·
24
25
  <a href="#桌面端远程窗口">远程窗口</a> ·
25
26
  <a href="#快速上手">快速上手</a> ·
26
27
  <a href="#使用示例与配置">使用示例与配置</a> ·
@@ -37,7 +38,7 @@
37
38
  - **注意力过度消耗**:外出通勤、就餐或离机休憩时,用户核心诉求仅为确认当前轮次是否结束,并下达下一阶段指令,无暇逐行审查中间步骤。
38
39
  - **网络与权限适配单一**:纯内网方案出差无法访问,中心化云端转发则引入额外的凭据托管风险。
39
40
 
40
- **极简遥控器**由此设计:手机界面默认仅保留「你的输入」与「AI 最终结论」,彻底屏蔽执行噪声;同时支持随时展开完整轨迹,将注意力支配权交还给用户。
41
+ **极简遥控器**由此设计:本插件适应多种使用场景,将注意力支配权交还给用户。移动端提供「单帧」、「聊天」和「完整」三种模式,用户可以选择彻底屏蔽执行噪声,也可随时展开完整轨迹;PC 端提供远程窗口功能,可在多台设备上以桌面端原生 UI 界面操作核心设备上的 DeepSeek Harness。
41
42
 
42
43
  ---
43
44
 
@@ -48,8 +49,8 @@
48
49
  - 🌐 **多通路安全连接**:内置本地局域网(LAN)、Tailscale 私网(支持 MagicDNS 与 HTTPS 证书)以及 Cloudflare 公网隧道(无公网 IP 场景)三种模式,各通道并存且独立提供二维码与访问口令。
49
50
  - 🐬 **拟人化轻量状态流**:提取步骤之间的关键旁白由鲸鱼娘气泡念出,实时呈现队列刻度尺与计时进度,彻底过滤底层原始推理块。
50
51
  - 💬 **飞书长连接双向遥控**:基于飞书开放平台 WebSocket 长连接直连桌面端,无需公网 IP 即可实现纯文本交互、选择题回复以及 `/会话` 切换。
51
- - 🗂️ **移动端就地会话治理**:在移动端支持会话重命名、置顶排序、安全归档以及从指定轮次派生新会话(Fork),并明确提示待审批与待回答事件。
52
- - 💻 **桌面端嵌入式「远程窗口」**:在 DSH 桌面端左侧栏提供「远程窗口」快捷入口,点击即在主面板中列出已配对的其他电脑,点一行就能把那一台的 DSH 电脑端界面嵌在面板里;再次点击同一行即可退出并返回原面板。跨机可用,配对步骤见下文「桌面端『远程窗口』」。
52
+ - 🗂️ **移动端就地会话治理**:在移动端用浏览器网页打开,支持会话重命名、置顶排序、安全归档以及从指定轮次派生新会话(Fork),并明确提示待审批与待回答事件。
53
+ - 💻 **桌面端嵌入式「远程窗口」**:在 DSH 桌面端左侧栏提供「远程窗口」快捷入口,可远程跨机操作另一台电脑上的 DSH 桌面端,原生窗口无感过渡,配对步骤见下文「桌面端『远程窗口』」。
53
54
  - 🔒 **严格的安全与防御边界**:所有接口均实施定长字符串口令校验,防范时序侧信道攻击;配置防暴力破解阈值(1 分钟内试错 5 次锁定 1 分钟);严禁移动端跨目录读取任意文件;配对信息仅限电脑本机回路读取。
54
55
 
55
56
  ---
@@ -168,6 +169,20 @@ dsh plugin --profile web add C:\path\to\dsh-mini-remote
168
169
 
169
170
  ### 移动端页面操作指引
170
171
 
172
+ **先拿到这个页面的地址。** 打开 DSH 桌面端「设置 → 手机遥控」,页面会列出这台电脑**能用的每一条地址**——「本机」「内网」「Tailscale」「Tailscale(加密)」「公网访问」,具体有哪几条,取决于你这台电脑装了哪些、开了哪些。
173
+
174
+ 每一条是一块:**左边二维码,右边就是完整网址**。手机扫二维码最省事;要在别的设备上手动输入,点右边那个「复制」,或者点一下网址框(它会自动全选)。
175
+
176
+ 地址长这样:
177
+
178
+ ```text
179
+ http://192.168.1.2:3090/mini?token=xxxxxxxxxxxx
180
+ ```
181
+
182
+ > ⚠️ **这条网址里带着访问口令**——谁拿到它,谁就能操作这台电脑。别截图发出去,也别粘到公开的地方。要换口令,见设置页里「密码」那一块。
183
+
184
+ 进去之后,这一页能做这些事:
185
+
171
186
  - **指令发送与排队**:在底部输入框输入文字并发送。若 Agent 正处于执行中,后续消息自动进入有序队列,并在刻度尺中高亮展示。
172
187
  - **斜杠指令交互**:输入 `/` 呼出当前会话绑定的系统级指令列表,支持按键过滤与参数提示。
173
188
  - **多选提问与确认**:当 Agent 发起选项询问时,页面弹出答题卡,支持点选、改选或自定义输入补充文本。
@@ -242,7 +257,7 @@ dsh plugin --profile web add C:\path\to\dsh-mini-remote
242
257
  **地址填哪一条**
243
258
 
244
259
  - 填 A 的「手机遥控」页里那条地址(末尾不用带 `/mini`);**不要**填你在 A 上打开 DSH 界面时用的那条。
245
- - **必须 `https://`。** 明文 `http://` 能配对、也能取到通行凭据,但框里是白的——那枚通行凭据在非 https 的地址上会被浏览器丢掉。
260
+ - **必须 `https://`。** 明文 `http://` 能配对、也能取票,但框里是白的:进门那一下,浏览器要替这一屏收下一枚 cookie(之后框里每个请求都靠它认人),而这枚 cookie 必须走加密连接浏览器才肯收。http 地址上它会被当场丢掉,于是之后每次请求都被挡在外面。本机 `127.0.0.1` 是个例外(浏览器把回环当可信来源)——所以这条限制实际只卡跨机。
246
261
  - 推荐走 Tailscale:在 A 上打开「HTTPS 地址(Tailscale)」开关,B 填 `https://<机器名>.<你的 tailnet>.ts.net`,**不用写端口**。地址固定,设一次就完事。
247
262
  - 「公网访问」隧道那条地址每次重启都会变,不适合跨机。
248
263
 
package/client/client.js CHANGED
@@ -816,13 +816,59 @@ window.__ModuleLoader__.load({
816
816
  * 前端这两道拦的只是"太明显了"的空格(当场说清,不用等一趟网络);真正的门在宿主
817
817
  * 那一边,这里改了那边不改照样进不去——和改密码那一块同一条口径。
818
818
  */
819
+ /**
820
+ * 这个地址能不能用来跨机打开(2026-10-18,依据见 `docs/` 那份调研报告)。
821
+ *
822
+ * ## 为什么在填地址这一步就拦
823
+ *
824
+ * `http://` 的地址**能配对、也能取票**,但点开那一屏是**白的**:进门那一刻要种一枚
825
+ * 窗口会话 cookie,它是 `SameSite=None; Secure`(跨站子框架里非这样不可),而浏览器
826
+ * 在非 https 的地址上会把这枚 cookie **直接丢掉**,之后每个请求都过不去。
827
+ *
828
+ * 这个失败**不在配对的时刻发生**,而是延后到"点开那一行"——用户会以为是界面坏了。
829
+ * 所以宁可当场拒绝,把原因说在源头。
830
+ *
831
+ * ## 回环地址例外
832
+ *
833
+ * 浏览器把 `127.0.0.1` / `localhost` / `[::1]` 当作可信来源,`Secure` 那一条对它们
834
+ * 不适用。留这个口子是为了本机自测(在同一台机器上验证跨机链路时,地址就是回环)。
835
+ *
836
+ * @param {string} raw 用户填的原文
837
+ * @returns {string|null} 不能用的原因(人话);能用回 `null`
838
+ */
839
+ function baseUrlRejection(raw) {
840
+ var text = String(raw || '').trim();
841
+ if (!text) return null; // 空地址有它自己那一句提示
842
+ var m = /^([a-z][a-z0-9+.-]*):\/\//i.exec(text);
843
+ if (!m) return '地址要以 http:// 或 https:// 开头——就是那台电脑「手机遥控」页里那条。';
844
+ var scheme = m[1].toLowerCase();
845
+ if (scheme === 'https') return null;
846
+ if (scheme !== 'http') return '只支持 http:// 和 https:// 两种地址。';
847
+ // 拆出主机看是不是回环(`[::1]:3090` 这种要先把方括号里的整段取出来)
848
+ var rest = text.slice(m[0].length);
849
+ var hostPort = rest.split('/')[0];
850
+ var host = hostPort.charAt(0) === '['
851
+ ? hostPort.slice(0, hostPort.indexOf(']') + 1)
852
+ : hostPort.split(':')[0];
853
+ host = host.toLowerCase();
854
+ if (host === '127.0.0.1' || host === 'localhost' || host === '[::1]') return null;
855
+ return '这台电脑的地址要用 https://(Tailscale 那条就是)。'
856
+ + 'http:// 能配对、也能取票,但打开那一屏会是白的——进门要种的那枚会话凭据,'
857
+ + '浏览器在非 https 的地址上会直接丢掉。';
858
+ }
859
+
819
860
  var saveAdd = function () {
820
861
  if (!addDraft || busy) return;
821
862
  var baseUrl = (addDraft.baseUrl || '').trim();
822
863
  var token = (addDraft.token || '').trim();
823
864
  if (!baseUrl) {
824
- setAddError('要填那台电脑的地址——就是它的「手机遥控」页那条(http:// 地址:3090,或者 https:// 的 Tailscale 地址;'
825
- + '不要带 /mini 和它后面的参数)。');
865
+ setAddError('要填那台电脑的地址——就是它的「手机遥控」页那条(必须是 https://;'
866
+ + '只有填本机回环地址时才可以用 http://)。不要带 /mini 和它后面的参数。');
867
+ return;
868
+ }
869
+ var rejected = baseUrlRejection(baseUrl);
870
+ if (rejected) {
871
+ setAddError(rejected);
826
872
  return;
827
873
  }
828
874
  if (!token) {
@@ -2517,7 +2563,11 @@ window.__ModuleLoader__.load({
2517
2563
  revoked: false,
2518
2564
  text: '那台电脑的界面一直没打开(等了 ' + Math.round(REMOTE_FRAME_WAIT_MS / 1000) + ' 秒没看到它,'
2519
2565
  + '自动再试了一次还是没看到)。\n'
2520
- + '它可能刚重启、或者这会儿连不上。点下面的「再试一次」换一张通行证再开一次。',
2566
+ // 三个原因并列,**浏览器那一条不能省**:cookie 被拒的后果是静默的(框停在
2567
+ // 「Loading plugins…」),用户只会去怀疑对面没开或网络不通,而修法完全不同。
2568
+ + '可能是它刚重启、这会儿连不上;也可能是你这边的浏览器拦了跨站凭据'
2569
+ + '(开了「阻止第三方 cookie」或严格隐私模式时常见——换一个浏览器试试)。\n'
2570
+ + '点顶上那条横条里的「再试一次」换一张通行证再开一次。',
2521
2571
  }));
2522
2572
  }
2523
2573
  return remoteScreen(remoteName, h('div', { style: styles.frameBox },
package/lib/index.js CHANGED
@@ -3960,7 +3960,9 @@ export function apply(ctx, config = {}) {
3960
3960
  devices,
3961
3961
  bindAddresses: resolveBindAddresses(settings.bindAddress),
3962
3962
  tunnelHost,
3963
- log,
3963
+ // server 那边的 `log.warn` 接到 `report` 上:这样它报的错(比如「窗口凭据没被浏览器
3964
+ // 收下」)会进自检的「最近出错」,而不是只打在终端里——而用户在图形界面上看不到终端。
3965
+ log: { info: log.info, warn: (m) => report('service', m) },
3964
3966
  // 镜像那一路:实例 + **每个请求现读的开关**(关着时那条路由根本不存在)。
3965
3967
  // 上游地址没解析出来时也当关着——宁可回 404(查无此路),也不要回一个
3966
3968
  // 「有这条路但它坏了」的 502:前者是实话,后者会让用户以为功能坏了。
package/lib/mirror.js CHANGED
@@ -69,8 +69,15 @@ const FRAME_BLOCKERS = new Set(['x-frame-options', 'content-security-policy'])
69
69
  *
70
70
  * 所以默认这一条就给「只许同源」:手机页照常能嵌,别的站点一律嵌不进来。
71
71
  * 跨源那一层要嵌,地址里必须带着**一次性票**(见下面 issueTicket/redeemTicket)——
72
- * 票只有本机回环上的宿主那一半拿得到,所以「谁能嵌」由票决定,而不是由一份
73
- * 源白名单决定(桌面端那一侧不是个 http 地址,白名单本来也无从写起)。
72
+ * 票只有本机回环上的宿主那一半拿得到,所以「谁能嵌」由票决定,而不是由一份源白名单决定。
73
+ *
74
+ * **为什么不用白名单(2026-10-18 修正了一处说法)**:原来这里写的是"桌面端那一侧不是个
75
+ * http 地址,白名单本来也无从写起"——**这句不准确**。MDN 的 `frame-ancestors` 明确接受
76
+ * `<scheme-source>`(`https:` 那种按协议整体放行的写法),所以要写 `dsh-app:` 是写得出来的。
77
+ * 真正的原因是**票比白名单更对**:
78
+ * · 白名单是**静态**的——一旦写上 `dsh-app:`,任何跑在那套协议下的页面都能嵌;
79
+ * · 票是**动态**的——这一台、这一次、30 秒、用完即废,还能跟着设备身份一起撤销。
80
+ * 顺带一提:自定义 scheme 在 CSP 里的实际匹配行为也没有标准保证,不能指望它。
74
81
  *
75
82
  * 注意这两个头**不是**上游的头:上游那一份已经在 writeOut 里丢掉了。
76
83
  */
@@ -765,13 +772,22 @@ const ADAPT_CSS = '<style id="mini-mirror-adapt">'
765
772
  // 修法:**只把那一列自己的 `overflow` 从 hidden 放开成「竖着能滚、横着照旧裁」**。
766
773
  // 选这一层是因为**它才是那个 scrollHeight 已经大于 clientHeight 的层**(696 vs 599),
767
774
  // 也正是那个写着 `overflow:hidden` 的层——数据指向它,别的层一个都不动。
768
- // 用 `[data-pane="sidebar"]`(官方渲染在侧栏那一列上的固定标记,全文档实测只有 1 个),
769
- // 不按类名哈希猜;里面那层 `_root` 的类名是「哈希_名字」,每次构建都变。
775
+ //
776
+ // ## 选择器换过一次(2026-10-18,用户报「滚动条又没了、设置被挡、会话被挤压」)
777
+ //
778
+ // 原来写的是 `[data-pane="sidebar"]`——官方 2026-10-07 那版渲染在侧栏列上的固定标记。
779
+ // **官方后来把这个属性整套去掉了**(`data-pane` 在 8.9MB 的客户端包里已经一次都不出现),
780
+ // 于是这条规则**还在、但匹配不到任何元素**,静默失效:侧栏滚不动 → 设置按钮落到屏幕外、
781
+ // 会话列表被上面几块挤没。**症状和 ⑱ 修之前一模一样,但原因完全不同。**
782
+ //
783
+ // 现在改用类名:官方给那一列的类是 `哈希_sidebarCol`(实测 `.BynINW_sidebarCol` 上写着
784
+ // `overflow:hidden`,正是要覆盖的那条)。**哈希前缀每次构建都变,所以必须用 `[class*=]`
785
+ // 只匹配稳定的那半截**——直接写完整类名的话,下次构建就又失效了。
770
786
  //
771
787
  // `overflow-x:hidden` **必须一起写**:侧栏收起成图标条时(那一列只有 52px 宽、
772
788
  // 里面的 root 仍是 280px),横向本来就是靠裁的——放开成 auto 会平白多出一条横向滚动。
773
789
  // 实测收起态 `scrollWidth 103 / clientWidth 52 / 纵向 599 对 599(本来就不用滚)`,前后一致。
774
- + '[data-pane="sidebar"]{overflow-y:auto !important;overflow-x:hidden !important}'
790
+ + '[class*="_sidebarCol"]{overflow-y:auto !important;overflow-x:hidden !important}'
775
791
  // ⑲ **中间那块 `_regionArea` 被压成 0 → 「工作区 / 历史会话」整块看不见**(2026-10-08 量的)。
776
792
  //
777
793
  // 用户报:「电脑端界面的导航栏,现在看不到历史会话了」,截图里「记忆系统」下面是一整块空白。
@@ -831,6 +847,63 @@ const ADAPT_CSS = '<style id="mini-mirror-adapt">'
831
847
  + '}'
832
848
  + '</style>'
833
849
 
850
+ /**
851
+ * **守住那份适配样式**(2026-10-18 加)。
852
+ *
853
+ * 实证(无头 Edge + 390×844 打开镜像页、加载前先挂 MutationObserver):
854
+ * 页面加载后约 **270 毫秒**,HMR(`@deepseek-ai/dsh-client-hmr`)会重建 `<head>`——
855
+ * 它把不认识的 `<style>` 一律当成旧的移除。同一批被移除的还有几个官方的旧样式
856
+ * (12305 / 6879 / 2905 字节),但它们随后会被 HMR 重新插回来,**我们这份不会**。
857
+ *
858
+ * 于是 `ADAPT_CSS` 在 DOM 里**活不过一瞬**:服务端返回的 HTML 里明明有它(位置、长度都对),
859
+ * 浏览器里 `document.getElementById('mini-mirror-adapt')` 却是 null。整份手机适配因此全失效。
860
+ * 这是"代码是好的、现象是坏的"那类问题的第二个来源——第一个是选择器失效(见 ⑱)。
861
+ *
862
+ * 修法(第三版,2026-10-18 当天定稿):**别往 DOM 里放**。
863
+ *
864
+ * 前两版都在跟 HMR 抢 DOM,越抢越糟:
865
+ * · 第一版用 `MutationObserver` 盯 `<head>`、回调里又插节点——和 HMR 的观察器互相触发,
866
+ * **死循环把页面卡死**(用户报「进不去」);
867
+ * · 第二版改成每 350ms 补一次、最多 25 次——手机布局下 HMR 比桌面活跃,补一次删一次,
868
+ * 25 次耗尽后照样失效(实测 `getElementById` 仍是 null)。
869
+ *
870
+ * 现在用 **`adoptedStyleSheets`**:样式以 `CSSStyleSheet` 对象挂在 `document` 上,
871
+ * **根本不在 DOM 里**,HMR 重建 `<head>` 时删不到它。实测:插入后侧栏 `overflow-y`
872
+ * 立刻变 `auto`;6 秒后(HMR 又重建过一轮、`<style>` 标签确实已被删)依然是 `auto`。
873
+ *
874
+ * 老浏览器没有这个 API 时,退回"插 `<style>` + 有上限的轮询"——至少不会卡死。
875
+ *
876
+ * 样式文本从 `ADAPT_CSS` 取、**先编码再内嵌**(`encodeURIComponent`):一是省得同一段
877
+ * CSS 在 HTML 里出现两遍(有测试在数"某条规则只许有一条"),二是避免 CSS 里的引号/
878
+ * 尖括号把这段脚本本身弄坏。**单一源头**,改样式只改上面那一处。
879
+ */
880
+ const ADAPT_GUARD = '<script>(function(){'
881
+ + 'var URI="' + encodeURIComponent(ADAPT_CSS) + '";'
882
+ + 'var ID="mini-mirror-adapt";'
883
+ // ① 首选:样式对象挂在 document 上,不进 DOM,HMR 删不到。
884
+ + 'try{'
885
+ + 'var CSS=decodeURIComponent(URI).replace(/^<style[^>]*>/i,"").replace(/<\\/style>\\s*$/i,"");'
886
+ + 'var sheet=new CSSStyleSheet();'
887
+ + 'sheet.replaceSync(CSS);'
888
+ + 'document.adoptedStyleSheets=document.adoptedStyleSheets.concat([sheet]);'
889
+ + 'return;'
890
+ + '}catch(e){}'
891
+ // ② 回退:没有 adoptedStyleSheets 的浏览器。插 <style>,用有上限的轮询守着(不会自激)。
892
+ + 'var tries=0;'
893
+ + 'function ensure(){'
894
+ + 'if(tries>=25)return;'
895
+ + 'tries+=1;'
896
+ + 'if(document.getElementById(ID))return;'
897
+ + 'var box=document.createElement("div");'
898
+ + 'box.innerHTML=decodeURIComponent(URI);'
899
+ + 'var s=box.firstElementChild;'
900
+ + 'if(!s)return;'
901
+ + 'if(document.getElementById(ID))return;'
902
+ + '(document.head||document.documentElement).appendChild(s);'
903
+ + '}'
904
+ + 'try{ensure();setInterval(ensure,350);}catch(e){}'
905
+ + '})();</script>'
906
+
834
907
  /**
835
908
  * **诊断模式**:只在 `?diag=1` 时注入(开关在 `lib/server.js` 的镜像路由那一段)。
836
909
  *
@@ -1068,7 +1141,7 @@ function stripRemotePrefix(rawUrl) {
1068
1141
  * `diag` 缺省就是关的:**正常路径一个字节都不多**(这一条有测试钉着)。
1069
1142
  */
1070
1143
  function injectHostHook(html, diag) {
1071
- const inject = HOST_HOOK + STRIP_FOREIGN_ADAPT + ADAPT_CSS
1144
+ const inject = HOST_HOOK + STRIP_FOREIGN_ADAPT + ADAPT_CSS + ADAPT_GUARD
1072
1145
  + (diag ? DIAG_SCRIPT : '')
1073
1146
  const at = html.search(/<head[^>]*>/i)
1074
1147
  if (at >= 0) {
package/lib/page.html CHANGED
@@ -1394,21 +1394,25 @@
1394
1394
  在第 28px);**窄到 800px 时最早的可点元素从第 18px 起**(左上那颗图标按钮),所以
1395
1395
  带的高度停在 16px——两边各留 2~6px 余量,谁也不挡。
1396
1396
 
1397
- **只在「主输入设备是有悬停能力的精细指针」时才收起来**(上面那个媒体查询管的就是
1398
- 这件事):触摸优先的设备上它匹配不上,横条保持**常驻**——触摸没有「悬停」这回事,
1399
- 藏起来就会变成摸不到的东西。它是唯一回手机页的出口,这一条不能赌。 */
1400
- #mirrorHot { display: none; }
1401
- .mv-hot { flex: 0 0 auto; overflow: hidden; }
1402
- @media (hover: hover) and (pointer: fine) {
1403
- #mirrorHot {
1404
- display: block; position: absolute; left: 0; right: 0; top: 0;
1405
- height: 16px; z-index: 2;
1406
- }
1407
- /* 100px 是留给 `max-height` 过渡的余量:横条实测 61px,留够就不会被削。
1408
- 收起那一侧同理——`max-height` 掉到 61px 以下才真的开始收。 */
1409
- .mv-hot { max-height: 0; transition: max-height .25s ease; }
1410
- #mirrorView.bar-on .mv-hot { max-height: 100px; }
1411
- }
1397
+ 2026-10-18 起**触摸设备也收起**——但那条"不能赌"的底线换了个守法:顶部留一条
1398
+ **看得见的把手**(`#mirrorHot::before`,一条 40×4 的细条),点它把横条拉出来、
1399
+ 再点一下收回去。所以"摸不到出口"这件事不会发生:把手一直在、而且看得见。
1400
+ 触摸走 click(触摸没有悬停这回事),桌面仍走 hover——两套事件见下面的脚本。 */
1401
+ #mirrorHot {
1402
+ position: absolute; left: 0; right: 0; top: 0;
1403
+ height: 16px; z-index: 2;
1404
+ }
1405
+ /* 把手:收起状态下唯一能看出"这里能拉"的东西。
1406
+ 居中、40px 宽——够小不碍事,也够大点得中。 */
1407
+ #mirrorHot::before {
1408
+ content: ''; position: absolute; left: 50%; top: 5px;
1409
+ width: 40px; height: 4px; margin-left: -20px;
1410
+ border-radius: 2px; background: rgba(128, 128, 128, .5);
1411
+ }
1412
+ /* 100px 是留给 `max-height` 过渡的余量:横条实测 61px,留够就不会被削。
1413
+ 收起那一侧同理——`max-height` 掉到 61px 以下才真的开始收。 */
1414
+ .mv-hot { flex: 0 0 auto; overflow: hidden; max-height: 0; transition: max-height .25s ease; }
1415
+ #mirrorView.bar-on .mv-hot { max-height: 100px; }
1412
1416
  #mirrorFrame {
1413
1417
  flex: 1 1 auto; width: 100%; border: 0; background: var(--bg);
1414
1418
  /* 底部让开手势条(2026-10-07 从「被镜像的那份页面」搬回来的)。
@@ -7629,12 +7633,25 @@
7629
7633
  clearTimeout(barHideTimer);
7630
7634
  barHideTimer = setTimeout(mirrorBarOff, 200);
7631
7635
  }
7632
- mirrorHot.addEventListener('mouseenter', barScheduleShow);
7633
- // 显示这一屏那一刻鼠标可能就停在顶上(那时不会再有 mouseenter)——动一下也算数。
7634
- mirrorHot.addEventListener('mousemove', barScheduleShow);
7635
- mirrorHot.addEventListener('mouseleave', barScheduleHide);
7636
- mirrorBar.addEventListener('mouseenter', function () { clearTimeout(barHideTimer); });
7637
- mirrorBar.addEventListener('mouseleave', barScheduleHide);
7636
+ // **桌面与触摸走两套事件**(2026-10-18):桌面有悬停,鼠标贴到顶上滑出、移开收回;
7637
+ // 触摸没有悬停这回事,改成"点一下切换"——点顶部那条把手拉出来,再点一下收回去。
7638
+ // 判定用媒体查询,和样式表里同一套条件(`hover:hover and pointer:fine` = 有鼠标)。
7639
+ var barHasHover = !!(window.matchMedia
7640
+ && window.matchMedia('(hover: hover) and (pointer: fine)').matches);
7641
+ if (barHasHover) {
7642
+ mirrorHot.addEventListener('mouseenter', barScheduleShow);
7643
+ // 显示这一屏那一刻鼠标可能就停在顶上(那时不会再有 mouseenter)——动一下也算数。
7644
+ mirrorHot.addEventListener('mousemove', barScheduleShow);
7645
+ mirrorHot.addEventListener('mouseleave', barScheduleHide);
7646
+ mirrorBar.addEventListener('mouseenter', function () { clearTimeout(barHideTimer); });
7647
+ mirrorBar.addEventListener('mouseleave', barScheduleHide);
7648
+ } else {
7649
+ // 触摸:按住拖动不算(不在 iframe 里),只有"点"才切换。
7650
+ mirrorHot.addEventListener('click', function () {
7651
+ if ($('mirrorView').classList.contains('bar-on')) mirrorBarOff();
7652
+ else mirrorBarOn();
7653
+ });
7654
+ }
7638
7655
  }
7639
7656
  // Tab 走到「返回」上要看得见它。
7640
7657
  $('mirrorClose').addEventListener('focus', mirrorBarOn);
package/lib/server.js CHANGED
@@ -416,6 +416,20 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
416
416
  const trajectorySubscribers = new Set()
417
417
  let clientSeq = 0
418
418
 
419
+ /**
420
+ * 「刚种下窗口 cookie」的时刻,以及**种下之后却没被带回来**的次数(2026-10-18)。
421
+ *
422
+ * 为什么要专门盯它:那枚 cookie 被浏览器拒掉的后果是**静默的**——跨机那一屏会停在
423
+ * 「Loading plugins…」,用户只会以为"对面没开"或者"网络不通"。而真正的原因往往是
424
+ * 浏览器/企业策略拦了「第三方 cookie」,换一个浏览器、或者把地址加进例外就好了。
425
+ *
426
+ * 判据只认"兑过票之后短时间内又来了不带这枚 cookie 的请求":兑票那一次本来就不带
427
+ * (那时还没种下),所以 `ticketOpen` / `frameOpen` 那两下要排除掉。
428
+ * **连两次才算数**——一次可能只是竞态(刚改过密码、或者用户同时开了两个浏览器)。
429
+ */
430
+ let windowCookieAt = 0
431
+ let windowCookieMisses = 0
432
+
419
433
  /**
420
434
  * 「这条活连接是哪台设备的」——撤销时要**主动掐断**它,所以得先记得住。
421
435
  *
@@ -1636,6 +1650,23 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1636
1650
  // 跑在自定义协议 `dsh-app://app/` 上,对我们的 `http://127.0.0.1:<端口>` 来说是
1637
1651
  // **跨站**——`Lax` 的 cookie 在跨站子框架里根本不会被带上,界面上就是一片 401。
1638
1652
  // `Secure` 在 `http://127.0.0.1` 上是允许的(回环算可信来源),所以这一条成立。
1653
+ //
1654
+ // ## `Partitioned` 试过,**撤回了**(2026-10-18)
1655
+ //
1656
+ // 本来加它是为了防"浏览器收紧第三方 cookie"(CHIPS):把它标成只属于「这个第三方 +
1657
+ // 这个顶层站点」这一个组合,浏览器就不再当追踪工具。想法本身没错,我们的场景也确实
1658
+ // 是 1:1 关系。
1659
+ //
1660
+ // **但顶层是 `dsh-app://app/` 这种自定义协议——那不是有效的分区上下文**,浏览器于是
1661
+ // 可能把整枚 cookie 拒掉。真机症状(B 电脑点开「远程窗口」):框里能看到
1662
+ // 「Loading plugins…」(说明带票那次请求成功了、HTML 到了),但之后**子资源全过不去**,
1663
+ // 页面永远加载不完,20 秒后报「那台电脑的界面一直没打开」。撤掉就恢复了。
1664
+ //
1665
+ // **教训**:加之前我在本机验过"它确实发出去了"(`Set-Cookie` 里带着 `Partitioned`),
1666
+ // 但"发出去"和"浏览器肯收下"是两件事——后者只有真机点开那一屏才看得出来。
1667
+ //
1668
+ // 将来要再碰这一块,先解决"分区键从哪来":要么把这个自定义协议注册成可信来源,
1669
+ // 要么让顶层页面走一个真正的 http(s) 地址。
1639
1670
  res.setHeader('Set-Cookie',
1640
1671
  // **不写 `Max-Age`**(2026-10-15):窗口会话的寿命是「这个宿主进程活着」
1641
1672
  // (见 lib/mirror.js 的 PROCESS_SECRET),浏览器这边就跟着做成**会话 cookie**。
@@ -1650,6 +1681,22 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1650
1681
  // 变成一次次重新取票,体验变差,换来的安全边际接近于零。
1651
1682
  // 本机那一半(`local`)连撤销都不适用(它不是名册里的设备),寿命就是进程本身。
1652
1683
  `${WINDOW_COOKIE}=${opened.session}; Path=/; SameSite=None; Secure; HttpOnly`)
1684
+ // 记下这一刻:之后要是又来了"不带这枚 cookie"的请求,就说明浏览器没存下它。
1685
+ windowCookieAt = Date.now()
1686
+ windowCookieMisses = 0
1687
+ }
1688
+ }
1689
+ // 刚种下这枚窗口 cookie,这次请求却没带回来 —— 浏览器多半没存下它。
1690
+ //
1691
+ // 后果是**静默的**:框里停在「Loading plugins…」,用户以为是网络或对面没开。
1692
+ // 所以在这里记一笔,第二次就报一条人话(见上面 windowCookieAt 那段说明)。
1693
+ if (!windowOpen && !ticketOpen && !frameOpen && windowCookieAt
1694
+ && Date.now() - windowCookieAt < 30_000) {
1695
+ windowCookieMisses += 1
1696
+ if (windowCookieMisses === 2) {
1697
+ log.warn('窗口凭据没被浏览器收下:跨机那一屏会一直停在加载中。'
1698
+ + '常见原因是浏览器或企业策略阻止了「第三方 cookie」,或者开着严格的隐私模式;'
1699
+ + '换一个浏览器、或把这个地址加进允许名单,通常就好了。')
1653
1700
  }
1654
1701
  }
1655
1702
  const openForWindow = (ticketOpen || frameOpen || windowOpen)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mini-remote",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",