dsh-pocket-ui
已验证dsh-pocket-ui · v0.1.9 · MIT · Web 界面
DSH Web UI 移动端适配(轻量核心版):窄屏下侧栏变抽屉、对话框变底部 sheet、刘海安全区避让、输入区不重叠;鼠标操作的桌面端任何宽度都完全 no-op。
安装
dsh plugin add dsh-pocket-ui 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
发布到 npm 但没有公开仓库。安装前请检查包内容。
标签
说明文档
dsh-pocket-ui
DSH Web UI 的移动端适配插件 · 轻量核心版
窄屏好用,宽屏不打扰:手机竖屏下侧栏变抽屉、对话框变底部 sheet、刘海安全区避让、输入区不再打架;鼠标操作的桌面端任何宽度都是完全 no-op。
它解决什么问题
DSH Web UI 是桌面优先的 React SPA。它的外壳 CSS 里一条宽度断点都没有(整个 dsh-web-frontend 只有 3 条 prefers-reduced-motion),没有 viewport-fit=cover,没有一处 env(safe-area-inset-*)。唯一的响应式能力是 JS 里的一个常量:
const SIDEBAR_AUTO_COLLAPSE = 1024 // 低于此宽度把侧栏折成 56px 图标条
在 390px 的手机上,这远远不够:设置面板是 800px 宽的居中弹窗、会话区被 56px 图标条挤掉、刘海遮住顶部、输入区的模型选择器把发送键顶出屏幕。
本插件补齐这些。它不重写 UI,只驯化宿主已有的 DOM——往宿主插槽里塞自己的组件、注入一份改写布局的样式表、用一个协调层给宿主节点打标记。所以宿主的每一次功能更新你都能照常拿到。
功能
| 功能 | 说明 |
|---|---|
| 侧栏变抽屉 | 窄屏下侧栏收进覆盖式抽屉(宽 min(84vw, 320px)),会话区全宽;面板内点击一律不拦截(点会话行就是选会话),收起用宿主自带的「收起侧边栏」按钮;点面板外的遮罩也可关,Esc 同样有效 |
| 抽屉开关按钮 | 左侧贴边的小把手:宽 22px、高 44px,左无圆角、右 8px 圆角,描边与三条杠共用 #4176e6;只在抽屉关闭时出现(打开时由遮罩接管)。纵向位置是量出来的——居中于「页头下沿 → 输入框上沿」这段真正空着的走道,所以空会话(宿主把输入框居中)和长会话(输入框贴底)都不会被遮住。避让安全区 |
| 弹窗变底部 sheet | 设置等 aria-modal 对话框改为贴底全宽 sheet,左侧竖排导航变成顶部横向滚动标签条 |
| 右栏全屏化 | 文件树 / 预览面板在窄屏下铺满,而不是挤在 300px 里 |
| 安全区避让(实测,不盲信) | 补上 viewport-fit=cover 并读取 env(safe-area-inset-*),但先判断这块空间是否真的属于本文档:见下面《顶部空白是怎么来的》 |
| 内置探针 | 页面加 ?pocket=probe 即在设备上显示一份可复制的 DOM 几何报告(门控原因、每个盒子的内边距、env() 实测值、顶部空白的归属),用于手机端排障 |
| 输入区适配 | 覆写宿主的 composer 令牌收窄边距、限制模型选择器宽度、输入框字号提到 16px(避免 iOS 聚焦强制放大且无法缩回) |
| 会话头紧凑化 | 头部高度 97px → 56px,标签条改为横向滚动而不是逐字竖排 |
| 桌面端完全 no-op | 见下节 |
| 设置入口 | 设置 → 通用 里有一行显示状态与版本,并提供「检测更新 / 升级」 |
| 在线更新 | 内置 registry 检查与一键升级(Host 半区),离线静默降级 |
| 互斥保护 | 检测到 dsh-web-mobile 存在时自动让位并在控制台说明,避免两套移动端布局互相打架 |
桌面端为什么是完全 no-op
三层保证,缺一不可:
- JS 门控:
(max-width: 1023px) and (pointer: coarse)。pointer: coarse不是可选项——只按宽度判断会让桌面分屏窗口、未最大化窗口、系统显示缩放 125–200% 都误启移动端 UI(1920 物理像素 @200% 缩放 = 960 CSS 像素)。断点 1023px 刻意压在宿主自己的 1024px 之下,两者永不打架。 - 样式表全部作用域化:每一条规则都挂在
html[data-pocket="on"]之下。门控关闭时零条规则匹配,没有需要撤销的东西。这条不变量由冒烟测试结构化断言(见下)。 - 组件自弃:插槽条目在桌面宽度下依然会被宿主渲染,所以
PocketChrome在未激活时return null,且协调层不会给宿主打任何标记。
顶部空白是怎么来的(安全区只在属于本文档时才生效)
手机上最容易看错的一类问题:页面顶部多出一条空白带,没人认领。
根因是 env(safe-area-inset-top) 说的不是"本文档"的事,而是"这块屏幕"的事。一个已经位于系统状态栏下方的 WebView 照样会报出这个值(Android WebView 实测约 45px)。此时按它补内边距,等于给一段本来就没被任何 chrome 覆盖的区域再留一次位——于是顶部多出一条谁也不属于的空白,看起来就像多了一个标题栏。
宿主不会背这个锅:DSH 的 --dsh-frame-top-clearance / --dsh-frame-chrome-top 只在桌面壳(data-platform=darwin / data-windows-titlebar,由 Electron preload 写入)下发布,文档原话是 "Plain browser documents publish none of these values." 普通浏览器/WebView 文档拿到的是零。
所以判断只能由插件自己做,两条独立的闸门(任何一条成立就不补顶部安全区):
- 几何:外壳自身顶边已经低于视口顶边(
frame.top > 2)说明这块余量已经被别人花掉了,再补一次就是重复计算。浏览器标签页(位于地址栏下方)、把页面放在原生标题栏之下的任何容器都命中这条。 - 显示模式:
display-mode: browser意味着页面不是以独立/全屏应用呈现的,也就不可能与系统栏重叠。App 内置 WebView 同样报browser——而那恰恰是 inset 说谎的场景。独立 PWA / 全屏应用报standalone/fullscreen,保留自己的 inset。matchMedia不可用(unknown)时按保守处理:保留 inset——漏掉一个真实的刘海会让侧栏第一行被时钟盖住,比多一条空白带严重得多。
底部 inset 反向夹紧:viewport - frame.height 放不下就直接归零,否则就会变成幽灵外层滚动("输入框被顶到折叠线以下"那个经典 bug)。
判定结果全部写进 window.__pocket.boot.insets,?pocket=probe 会原样打印。策略在这里是显式的、可观测的——一个沉默的策略和一个"看不见设备"的插件,从现象上无法区分。
在手机上排障:?pocket=probe
App 内置 WebView 没有控制台,所以诊断信息必须能自己长在屏幕上:
http://<你的 NAS>:3080/?pocket=probe # 打开探测覆盖层
覆盖层给出:门控为什么开/关、--pocket-safe-t 与 env(safe-area-inset-top) 的实测值、顶部空白的像素高度与归属元素、每个宿主地标盒子的 padding/margin、/pocket/hello 返回了什么,以及 host 请求实际打到了哪个 URL(document.baseURI、document.origin、__DSH_TRANSPORT__ 是否存在、解析后的 /pocket/hello)。右上角有「复制全部 JSON」,直接粘给助手即可。
最后那一组是"插件装上了但 host 半区连不上"的关键证据:/pocket/hello 不回答和它被发到了哪个地址是两个完全不同的问题,而后者往往一眼就能看出答案(例如地址里缺了反向代理的前缀,或者打到了一个根本没路由的 origin)。
- 探针源码是
lib/probe-src.js(可 lint、可 diff、有独立单测),由 host 半区在/pocket/probe.js按需读取并提供——所以它不会进 bundle,也不影响正常页面加载。 - 探针自带一份 host 路由解析逻辑(它是独立文件,不能引用 bundle 里的那份),单测会断言两者口径一致——否则"探针说连不上"本身也会变得不可信。
?pocket=probe会强制开启移动端布局:在手机上量到的必须是手机上真正在跑的那套布局。?pocket=off优先级更高,显式关闭时不会装探针。
设置行写「host 半区无响应」怎么办
先看服务端日志(dsh web 的 stdout)里有没有这一行:
[dsh-pocket-ui] host half active, version vX.Y.Z, routes under /pocket
没有这行 → host 半区压根没被加载。最常见的原因是装完/更新完没有重启:host 半区是被
import进 Node 进程的,没有卸载路径,而 client 半区在link:安装下是直接读磁盘的,所以会出现"行出来了、host 还不在"的错位状态。重启即可。设置行在连不上时也会直接把这句话写在脸上。有这行 → host 半区活着,问题在请求地址。用
?pocket=probe看它实际打到哪个 URL,对照:- 反向代理挂在子路径(如
/dsh/)→ 路由必须相对document.baseURI解析,不能是根绝对路径; - DSH 桌面壳:页面 origin 是
dsh-app://app/,协议处理器只截静态资源,其余路径会连同宿主 cookie 一起转发给 Host,所以解析基准就是document.baseURI。
- 反向代理挂在子路径(如
另可显式指定 profile 目录(自动探测失败时用):
DSH_POCKET_PROFILE_ROOT=~/.dsh/profiles/web dsh web这只影响"这个安装是不是
link:源码目录"的判定(免得在线升级覆盖掉你的工作副本),不影响路由。
安装
dsh plugin --profile web add dsh-pocket-ui
装完重启 dsh web 生效。
本地开发:
dsh plugin --profile web add link:/path/to/dsh-pocket-ui
从
dsh-web-mobile迁移:两者会抢同一批 DOM,请先移除旧插件 (dsh plugin --profile web remove dsh-web-mobile)。本插件内置互斥检测,即使两个都装也只会有一个生效并在控制台提示。
开发循环:什么需要重启,什么不需要
这是最容易搞混的一点。DSH 的 web profile 里有两个 HMR 插件,但它们默认只有一个是开的:
- id: hmr # @deepseek-ai/cordis-plugin-hmr
disabled: true # ← 插件树 / 配置的热加载:默认关闭
- id: client-hmr # @deepseek-ai/dsh-client-hmr
# 没有 disabled → 默认开启
dsh-client-hmr 每 500ms stat 轮询每个插件 bundle 的 (mtimeMs, size),一变就重新哈希,
并经 /plugins/events SSE 推给浏览器。
| 你改了什么 | 怎么生效 | 为什么 |
|---|---|---|
已装插件的 lib/client.js |
自动热替换(连刷新都不用) | client-hmr 轮询到文件变化 → 新 rev → SSE 推送 |
已装插件的 lib/index.js(Host 半区) |
必须重启 | 模块已被 import 进 Node 进程,没有卸载路径 |
| 新装 / 卸载插件 | 必须重启 | 插件树在启动时合成;cordis-plugin-hmr 是 disabled: true |
改 profile 的 cordis.patch.yml |
必须重启 | 同上 |
所以本插件没有构建步骤是刻意的:lib/client.js 本身就是源码,改完存盘,浏览器里的
页面会自己更新。这比「改 TS → 构建 → 刷新」快得多,也没有「忘记重新构建」的坑。
实测(headless Chrome + CDP):页面加载后完全不碰浏览器,只改磁盘上的
lib/client.js, 数百毫秒后活着的页面里注入的样式表内容就已经变了。注意
hmr那行是 base 层就带disabled: true的,不是被后续层覆盖——所以 「装完新插件刷新一下就能用」对全新安装并不成立,那一步一定需要重启。
开关与卸载
标准做法是 patch 层禁用(不需要卸载):
# ~/.dsh/profiles/web/cordis.patch.yml
- id: pocket-ui
disabled: true # 注释掉或删除即恢复
彻底卸载:
dsh plugin --profile web remove dsh-pocket-ui
调试用 URL 参数(桌面浏览器上预览移动端布局):
| 参数 | 效果 |
|---|---|
?pocket=mobile |
强制启用移动端布局(忽略指针类型) |
?pocket=off |
强制关闭 |
宿主升级后如何对账
本插件的宿主形状知识全部集中在 lib/client.js 的 findFrame() / parentOfSlot() 与样式表的选择器里,宿主升级只需检查这几处:
-
[data-shell-overlay]仍存在,且仍是 AppFrame 的直接子元素 - AppFrame 仍用内联
grid-template-columns(我们的覆盖依赖!important) -
[data-slot="sidebar"|"main"|"rightbar"]仍存在(槽位出口是display:contents,我们选它的父元素) - 设置对话框仍是
[role="presentation"] > [role="dialog"][aria-modal="true"] -
SIDEBAR_AUTO_COLLAPSE仍是 1024(决定我们的断点) -
--dsh-composer-side-clearance等令牌仍存在
不使用任何宿主 class 名——它们是内容哈希(pI_x6G_frame、wSkVaW_header),每次宿主构建都会变,选择器会静默失配。这条由冒烟测试断言。
已对 0.2.0-rc.2 核对过、没有变化的项(逐条读过安装树里的源码/文档):
- 槽位键
sidebar/main/rightbar/shell.overlay仍在(0.2.0 新增shell.leading,是 macOS 桌面壳的窗口位,与本插件无关) - 槽位出口仍是
<div data-slot="<key>" style="display:contents"> ctx.layout.toggleSidebar仍在(四个方法selectPanel/toggleSidebar/openRightbar/closeRightbar一个没动)- 浏览器半区的激活闸门仍是
exports.inject(0.2.0 的dsh.client.inject是包依赖边的声明,字面写着 "Informational package-name dependencies, not Cordis service injection",不是服务注入) data-platform/data-windows-titlebar/data-fullscreen仍只由桌面壳 preload 写入;普通文档依旧不发布--dsh-frame-*任何值——这正是《顶部空白是怎么来的》一节的依据
验证
npm run smoke # 四套全跑(103 项,全离线)
node scripts/smoke-host.js # 20 项:路由 + 探针脚本 + 在线升级全链路 + link 安装的 profile 定位 + manifest 自洽性 + 进程落后于磁盘
node scripts/smoke-client.js # 52 项:bundle 契约、样式表不变量、安全区夹紧算术、host 路由解析、stale 行的优先级、把手落位的走道算术
node scripts/smoke-mount.js # 16 项:客户端半区真正挂到桩 DOM 上,含 teardown 不残留、按页面 base 发请求
node scripts/smoke-probe.js # 15 项:探针本身(含"读的是插件自己的判定"、探针与 bundle 解析一致)
node scripts/verify-cdp.mjs --url '<dsh web 打印的带 token URL>' --screenshot ./shots
容器 / CI 里跑
verify-cdp.mjs需要POCKET_CHROME_FLAGS='--no-sandbox':没有内核 user-namespace 支持时 Chrome 会以sandbox initialization failed: Operation not permitted(exit 133)退出,而且在写出调试端口之前就死了,报错看起来像 "chrome did not expose a debugging port",不像沙箱问题。默认不开——只在被测页面可信时才该去掉沙箱。
四套无浏览器套件与 CDP 探针分工明确,这个划分是踩出来的:
- smoke-*(无浏览器) 管契约:bundle 形状、样式表结构不变量、安全区夹紧的算术、teardown 是否归还宿主 DOM。
也正因如此,桩必须让"桩自己的 bug"能被看见——本仓库在这里连踩三次(
findFrame()拿不到[data-shell-overlay]、querySelectorAll不认识逗号列表、桩document没有baseURI),三次现象都长得像插件坏了,实际是桩不忠实。 - CDP 探针(95 项断言) 管真实 DOM:计算后几何、层叠顺序、可点性——只有真浏览器能验证这些。 每个阶段(移动端 / 窄桌面 / 桌面)独立捕获异常,一处失败不会掩盖后面的阶段。
当前 93/95 通过。唯二两条失败是设置行的
gap与邻行不一致(mine=8px neighbour=normal)——v0.1.4 起就存在, 与把手落位无关,也没有随本次改动变化;它是已知的视觉细节,没有假装成"全绿"。
CDP 覆盖:
- 移动端(390×844 + 触摸模拟):门控开启、样式注入、地标打标、viewport meta、网格塌缩、抽屉开合、遮罩可点、sheet 贴底全宽且不被困在抽屉里、内容真的能滚动、无幽灵滚动、状态行渲染且能连上 host 半区;把手这一侧则断言尺寸/圆角/描边/阴影/贴边、锚点等于实时走道中心、强制钉底输入框后把手跟着下移、整页没有任何可交互元素落在它底下、以及中心点可点
- 窄桌面(900×800,鼠标)+ 桌面(1280×800):门控关闭、零地标残留、零注入控件、宿主网格未被改动、侧栏未被改成抽屉、设置行度量与邻行一致
headless Chrome 没有任何指针设备,三个
(pointer: …)查询全为 false。必须用Emulation.setTouchEmulationEnabled才能让(pointer: coarse)命中——Emulation.setEmulatedMedia对指针特征静默无效。带
?pocket=probe的页面会挂上探针覆盖层,断言几何的 CDP 用例不要带这个参数。第一次跑会撞上宿主的 Internal Testing Notice。关掉它时必须一并解除它对页面的锁: 宿主用
#root[inert]锁住其余文档,只删弹窗不解除,之后每一次命中测试都只会返回<body>——看起来就像"插件的按钮被什么东西盖住了",实际是inert。探针现在会打印 命中的元素、元素栈、祖先链(pe/overflow/clip/transform+ 盒子)与inert链, 这类问题一次就能定位。设置行的
gap与邻行不一致(8pxvs 宿主normal)是长期存在的(v0.1.4 起相同), 属视觉细节,未改;CDP 里仍会报出来。
更新记录
v0.1.9
主题是抽屉按钮改成一枚贴边把手,并且落位是「量」出来的而不是「定」出来的。
- 左下角这个位置是错的,而且是量出来的。手机上输入框贴底,于是固定在左下角的按钮落进了输入框卡片自己的盒子里。实测 390×844(底部无安全区):卡片占 x 16–374 / y 698–812,按钮占 x 10–38 / y 806–834,两者重叠 22×6px。它没有压住宿主的
+(那个控件止于 y 803);被切掉的是卡片左下角,并且和9 轮 456 步状态行共处一行。量不大,但按钮是position: fixed(z-index 40),不在输入框自己的层里,所以还顺带抢走那一角的点击。 - 「抬到输入框上方」也走不通:那条路会把按钮抬到最后一条消息的操作行上,压住复制图标——比原来更糟,因为复制是常用动作而输入框左下角不是。
- 「屏幕正中间」在手机上放不下,这是本次最反直觉的结论。空会话时宿主会把输入框居中,于是屏幕中部偏左正好被它占住:实测 390×844 输入框块占 y 331–569、其中宿主自己的
选择工作区在 y 387–415,而锚在 50% 的把手会落在 y 400–444,同时压住两者。390×667 与 390×932 在 45–46% 处出现同样的碰撞——所以这是宿主布局的性质,不是某一块屏的巧合。 - 改成锚在「真正空着的那条走道」的正中:从页头下沿到输入框块上沿。
syncTabSeat()每次 reconcile 量这两个边并把结果写进--pocket-tab-top。有历史会话时输入框贴底,把手落在约 45%(几乎就是正中);空会话时走道短,它升到约 23%——仍然是它所在空间的正中。两种状态都不遮挡,而且宿主挪动任一条边时会自己跟上。 - 量的是「输入框块」而不是「输入框卡片」:空会话时宿主会在卡片上方再渲染一行
选择工作区,所以卡片的顶边比整块的顶边低约 36px,拿它当上界会漏掉那一行。块级的锚点是[data-composer-seat](composerStack元素)。 - 样式表里留了一个「实测安全」的静态兜底(
top: var(--pocket-tab-top, 30%)):首次测量之前、或页面上找不到那两个锚点时用它,取值按空会话(更难的那个状态)实测无碰撞。 - 形状是半药丸:贴边一侧方角、朝内容一侧 8px 圆角(宿主
--dsw-radius-sm那一档),读起来像屏幕边缘长出来的把手而不是一张飘在页面上的卡片;阴影也据此做成单向的。刻意不用--dsw-radius-md(12px)——在 22px 宽的盒子上会被夹成 11px 的整半圆,把手感就没了。 - 宽度和高度分工明确。只有宽度会挤压内容(会话区自己的左侧留白约 20px),所以宽度压到 22px;高度不占内容,于是把点击区域全花在高度上(44px,iOS 下限)。
- 描边和阴影是承重的,不是装饰。填充用的
--dsw-alias-bg-layer-2在浅色模式下与页面底色同色,所以看得见完全靠描边与阴影;描边取l2(10% 黑 / 12% 白)而不是l1(4% / 6%),后者在会话底色上会消失。 - 输入框长高时把手跟着走,靠一个
ResizeObserver:reconciler 是 MutationObserver 驱动的,且没有观察characterData,输入第二行改的只是同一个文本节点,不产生任何它看得见的 mutation。observer 的共享状态声明在 factory 作用域——这条不是形式主义,见下。 left带安全区 inset 而不是写死 0:竖屏解析为 0、真正贴边;横屏则让开摄像头挖孔。纵向不需要同样的修正,因为那条走道是从实时矩形算出来的,顶部 inset 早已把页头推下来了。- 降级分支不许抛:
observeTabBand()排在一轮 reconcile 的syncSafeArea()与syncDrawerWithHost()之间,所以它在拿不到ResizeObserver时直接return——一个 throw 会把排在后面的安全区同步与抽屉同步一起带崩,整页布局停在上一帧。 - 顺带修掉两个真 bug:
- 删掉落位机制后,
deactivate()里还留着一句lastFabSeat = null。ESM 严格模式下这是ReferenceError,意味着每次 teardown 都会抛——而且只在激活之后、只在真机上发生。 observeTabBand()里对ResizeObserver没有保护(见上)。
- 删掉落位机制后,
测试:
- 四套无浏览器冒烟 91 → 103 项(host 20 / client 52 / mount 16 / probe 15)。
- client 新增的断言读的是样式表里的几何与函数的算术,而不是源码的形状:
- 四个圆角逐个拆开比对(左两角必须恰为 0、右两角必须相等且非零、半径必须小于半宽);
top必须是带百分比兜底的测量值,且兜底本身要同时满足两个约束(低于 45% 那条碰撞线、高于 56px 的页头);syncTabSeat()的真跑:贴底 →377px、空会话 →194px,并在 12 组走道组合上扫「永不出界、永不吃掉间距」;走道短到装不下时钉在下界(宁可靠页头,不可落进输入框);页头/锚点/高度任一读不到就移除已发布值而不是留一个过期值。- 常驻的作用域断言:
tabSeatObserver/tabSeatObserved/lastTabSeat必须声明在apply()之前。 - 退役清单断言:
pocket-fab/syncFabSeat/observeComposer/lastFabSeat/fabSeat等标识符不得再出现。上面那个ReferenceError正是被它抓住的——删一半的代码是这类改动最典型的坏法。
- mount 套件的 DOM 桩补上了
getComputedStyle().getPropertyValue,并且从插件自己的样式表里解析 token 来回答。原先桩缺少这个方法时,代码会走「读不到 token」的降级分支——套件照样全绿,但主路径一行都没跑。这条教训已写进《DSH插件开发流程.md》踩坑 18。 - 现场验证 81 → 95 项断言(390×844 headless Chrome + CDP,93/95 通过;唯二失败是 v0.1.4 起就存在的设置行
gap视觉差异,与本次改动无关)。新增的断言包括:- 把手的锚点等于实时量出的走道中心(不是等于某个常量),并且把手真的落在那里、token 与使用值一致;
- 用一条样式表规则把输入框强制钉到底部(不写任何宿主数据、不点击、不导航),再派发一次
resize唤醒 reconciler → 把手确实跟着下移,且仍等于新走道的中心、仍然不遮挡。锚在常量上的实现不可能通过这一段,所以这是隔离验证而非重复验证; - 扫描页面上所有可交互元素(
button/a[href]/[role=button]/input/textarea/select),确认没有任何一个落在把手底下——正是这条在锚 50% 时抓出了选择工作区。
把手贴在左侧中部,横向必然与会话区重叠——这是「贴边」的代价,也正是纵向必须让干净的原因。
v0.1.8
主题是让「进程里跑的不是磁盘上那份」这种状态自己说出来。
- host 半区同时上报「进程内版本」与「磁盘上的版本」。
VERSION是模块被import时读一次的,因此它描述的是正在运行的代码,而不是用户装了什么;client 半区却是从磁盘现取、会热更的。更新完不重启就会出现「行上写着 v0.1.4、检出却是 v0.1.7」——谁也解释不了,而它还会去点一个注定失败的升级按钮。/pocket/meta现在多返回installedVersion与stale(两者不一致即为真)。这一处必须用
fs.readFileSync重新读,不能用createRequire():req()对.json走的是 CJS 加载器缓存,第二次拿回的正是这份函数要绕开的缓存。 - 状态行优先说「重启」,而不是继续叫你去升级。stale 时行内显示「v0.1.4(进程内) · 已安装 v0.1.7,重启 dsh web 后生效」,并且不再提供升级按钮——升级救不了一个落后于自己安装包的进程,新文件本来就已经在磁盘上了。这个分支必须排在「本地安装」和「可升级」之前,否则那两条都会指向错的解法(一个说"改源码",一个给一个不可能有用的按钮)。
升级不再以一句无法执行的报错收场。原先定位不到安装位置时抛出cannot locate the profile install root——是真话,但用户没法照着做。现在两种情形都给出可执行的出路:- 进程落后于磁盘 →
ok: 'stale',什么都不装,直接让你重启; - 确实找不到 profile →
ok: 'fail',并写出该执行的命令(dsh plugin --profile <profile> add dsh-pocket-ui@<版本>)。
- 进程落后于磁盘 →
测试:
- 四套无浏览器冒烟 88 → 91 项(host 18 → 20,client 39 → 40)。
- 新增两条 host 断言:在运行中的副本脚下改掉
package.json之后必须报stale,并断言包管理器一次都没被调用过;以及「定位不到 profile」时消息里必须含可执行命令、且不再是那句旧死路。 - 现场验证(另起 3081 独立实例):
version=0.1.7 / installedVersion=0.1.7 / stale=false→ 把磁盘改成 0.1.8 →version=0.1.7 / installedVersion=0.1.8 / stale=true→POST /pocket/upgrade返回ok: 'stale';还原后回到stale=false。
v0.1.7
主题是补上一个从 v0.1.0 起就名不副实的声明——package.json 写着类型声明,那个文件却从来不存在。
- 补上
lib/index.d.ts。package.json从模板继承了"types": "lib/index.d.ts"与exports["."].types,但该文件从来没有被创建过(git log --all -- lib/index.d.ts为空——不是某次误删,而是从未存在),v0.1.0 起一直如此。下游 TypeScript 项目按包名引用本包时会撞上TS7016: Could not find a declaration file for module 'dsh-pocket-ui',或在strict下静默退化成any。现在按 host 半区真实导出补齐:name用字面量类型、inject用readonly ['webServer']、apply(ctx: unknown);/pocket/*的响应体也一并声明为PocketHello/PocketMeta/PocketUpgradeResponse/PocketUpgradeStatus——那是双半区之间唯一真实的契约。(files已含整个lib,无需额外改动即可随包发布。) - 新增 manifest 自洽性断言(
scripts/smoke-host.js):收集main/types/exports里出现的每个路径,断言文件真实存在、且被files白名单覆盖(package.json豁免,npm 总是发布它),并断言.d.ts里确实声明了name/inject/apply。把lib/index.d.ts删掉时,该断言会精确报出package.json declares "lib/index.d.ts" but no such file exists——即把这次的问题本身变成一条会红掉的测试。
安装 0.1.7 时会撞上一个与本次改动无关、但会让人误以为"插件没修好"的坑:pnpm 11 起
minimumReleaseAge默认 1440 分钟(24 小时)。本版发布后 24 小时内执行pnpm add,pnpm 会在满足 semver 的候选里挑"最新的够老版本",也就是 0.1.4——输出+ dsh-pocket-ui ^0.1.4,而命令是成功的。要立刻用上本版,在 profile 的pnpm-workspace.yaml里放行:minimumReleaseAgeExclude: - dsh-pocket-ui
测试:
- 四套无浏览器冒烟 87 → 88 项(host 17 → 18,新增 manifest 自洽性断言)。
v0.1.6
主题是**"插件装上了,但设置行说连不上 host 半区"**这一类问题——把结论从"猜"变成"看"。
- host 请求不再用根绝对路径。
fetch('/pocket/meta')只在"应用正好挂在 origin 根"时才成立。两种正常部署里它不成立,而现象都是那句"host 半区无响应":- 反向代理挂在子路径(
https://nas/dsh/):路由属于那个挂载点。 - DSH 桌面壳:页面 origin 是
dsh-app://app/。壳的protocol.handle只截静态资源(/、/index.html、/assets/*、/favicon.svg、/manifest.webmanifest),其余路径一律经forwardWebRequest转发给 Host,并保留 pathname、附上宿主 cookie——所以dsh-app://app/pocket/meta本来就能到达插件,正确的解析基准就是document.baseURI。 现在统一按页面 base 解析(document.baseURI优先)。注意这与平台自己的remoteStreamUrl()顺序相反,不要照抄:那一条加载的是 WebSocket mux(ws:不受 CORS 约束,壳还专门为它重写了 cookie 与 origin),而把跨源fetch()打到streamBaseUrl会因为既没有 cookie、也没有access-control-allow-origin而直接 "Failed to fetch"。探针自带的同名解析函数与 bundle 口径一致,并有单测钉住。
- 反向代理挂在子路径(
- 按钮失败不再沉默。
检查更新/升级以前失败时catch里什么都不做——用户看到的就是"点了没反应"。现在行内直接写出失败的那个 URL 和原因。 - 连不上时直接给解法。host 半区是被
import进 Node 进程的、没有卸载路径,所以新装或更新后必须重启dsh web;而link:安装下 client 半区是直接读磁盘的,于是很容易出现"行出来了、host 还不在"的错位。行内现在会写「刚安装或更新过?重启 dsh web 后生效」。 - 重试不再永久放弃。原来的退避窗口耗尽后就永久停在"无响应";手机上的页面可能开着好几天,期间 host 重启过也永远不会再连。现在快速窗口之后转为 15s 一次的心跳,自愈。
- host 半区现在能找到自己所在的 profile。
link:安装时模块解析到的是源码目录,从import.meta.url往上走永远走不到 profile,findProfileRoot()于是返回null——"别把源码工作副本覆盖掉"的守卫因此形同虚设,状态行也说不出"本地安装"。现在多两条兜底:在$DSH_HOME/profiles/*里找node_modules/<name>真实指向本包的 profile;失败再退回唯一的dependencies匹配。另支持显式指定DSH_POCKET_PROFILE_ROOT=<dir>。实测:同一个
link:安装,旧代码/pocket/meta返回"localInstall": null,新代码返回"localInstall": "link:/…/dsh-pocket-ui"。 - host 半区启动会打一行日志(
[dsh-pocket-ui] host half active, version vX.Y.Z, routes under /pocket)。原先走ctx.logger,在dsh web的 stdout 里根本看不到,于是"host 半区到底加载了没有"无从判断——这正是最难区分的两种情况之一。 - 状态行区分"正在连接"与"连不上",
已是最新/无法访问 npm registry/已关闭更新检查/本地安装 · 已是最新各自有独立文案,不再用一句"未检测更新"冒充结论。
测试:
- 四套无浏览器冒烟 77 → 87 项(新增 host 路由解析、按页面 base 发请求、
link:安装的 profile 定位、探针与 bundle 解析一致)。 - CDP 探针 53 → 81 项,并把三个阶段彼此隔离:一处异常不再掩盖后面的阶段。
- 顺手修掉 CDP harness 自身的一个坑:关掉宿主首次运行的 Internal Testing Notice 时没有解除它对页面的锁(
#root[inert]),结果此后每一次命中测试都只返回<body>,看起来就像"插件的按钮被盖住了"。探针现在会打印命中的元素、元素栈、祖先链几何与inert链。 - 已知未改:设置行的
gap与邻行不一致(8pxvs 宿主normal)。v0.1.4 起就是这样,属视觉细节,CDP 里仍会报出来。
v0.1.5
- 修掉 fnOS App 里顶部那条"标题栏"空白。
env(safe-area-inset-top)在位于状态栏下方的 WebView 里照样返回状态栏高度(实测 ~45px),插件照着补内边距就凭空多出一条空白带。现在安全区实测后再决定是否使用:外壳已被推下(frame.top > 2)或显示模式不是独立/全屏(display-mode: browser,App 内置 WebView 正属此类)时不补顶部 inset;matchMedia不可用时保守保留。底部 inset 也反向夹紧,避免幽灵外层滚动。 - 新增设备端探针
?pocket=probe(lib/probe-src.js+/pocket/probe.js):把门控原因、实测 inset、顶部空白的像素高度与归属元素、宿主地标盒子几何打印成可复制的覆盖层。App 里没有控制台,这是唯一能在真机上取证的方式。 - 修掉设置行长期显示"host 半区未就绪 · 未检测更新"。宿主槽位在插件树启动过程中就会被渲染,而 host 半区路由要等
webServer就绪;原来只 fetch 一次、失败即静默吞掉,于是页面活着多久这句话就挂多久。现在带退避重试,并区分"正在连接 / 无响应"。 未检测更新不再冒充结论:latest为空既可能是"已是最新",也可能是"根本没连上 registry",行内文案按 host 半区返回的updateError/updateDisabled区分显示。- registry 回退:npmjs 不可达时自动尝试
registry.npmmirror.com(国内 NAS 直连 npmjs 经常不通,这正是"未检测更新"的另一半原因)。 dsh.client声明补上immediately: true,让外壳更早拿到这份 UI 补丁。- 冒烟测试拆成四套(host / client / mount / probe,共 77 项);新增的 mount 套件把客户端半区真正挂载到桩 DOM 上跑,teardown 逐项断言不残留。
v0.1.4
- 抽屉开关按钮挪到左下角并做细:40px → 28px,描边与三条杠统一为
#4176e6,图标改用独立的 16 单位网格、1.25 笔画(原先 20 单位网格配 1.6 笔画,缩到 28px 会糊成一坨)。描边和图标颜色由同一个--pocket-accenttoken 驱动。 - 会话头部左侧的 52px 内边距是为旧的左上角按钮预留的,按钮挪走后已恢复为正常的 12px。
- 已知代价:28px 小于 44px 的触摸目标建议值;且左下角与宿主 composer 的底部状态条(
11 轮 639 步 · 133M tok)同处一条带,实测与 composer 卡片左下圆角重叠 22×8px(状态文字本身未被遮挡)。
v0.1.3
- 修掉「点抽屉面板里任何地方都会把面板收起来」。之前给侧栏挂了一个捕获阶段的点击监听器,用一张宽泛的「可交互元素」选择器猜用户点完了;而工作区面板本身就是一棵
[role="treeitem"]行树,所以每一次点击都命中,面板实际是只读的。整条启发式已删除——面板内点击不再被拦截。 - 收起的权威入口改为宿主自带的
aria-label="收起侧边栏"按钮:宿主折叠侧栏后,协调层采纳这个状态来关抽屉。顺带修掉一个隐藏缺陷——原先那个捕获监听器比宿主按钮自己的处理器先跑,会先折叠一次、再被宿主 toggle 回来,导致按钮看起来像坏了。点面板外的遮罩关闭仍然保留。 - 打包白名单从整个
assets/收紧到四张 README 用图,避免验证脚本的截图混进 npm 包。
v0.1.2
- 设置页的状态行在桌面端不再是无样式的裸 HTML。之前把行样式也锁进了
html[data-pocket="on"],而设置槽位在任何宽度都会被宿主渲染,于是桌面端没有内边距、没有分隔线、说明文字是 14px。现在行样式不门控,度量照抄宿主官方行(.5px solid var(--dsw-alias-border-l2)、16px 0、标题 14/22、说明 12/18)。 - 状态行文案改短:「当前 v0.1.1 · 最新 v0.1.1」→「v0.1.1 · 已是最新」;有更新时显示「可升级到 vX.Y.Z」。
v0.1.1
修复两个线上缺陷:
- 插件关掉再启用会报
webserver: duplicate prefix route "/pocket"。webServer.register()是服务方法,返回的 disposer 框架不会自动跟踪,之前被丢弃了,路由因此活过卸载。已改为ctx.effect(() => …)包裹。同批把更新检查的定时器也从ctx.setInterval换成ctx.effect里的裸setInterval(前者绑定在定时器服务的 fiber 上,会活过插件卸载)。 - 设置弹框无法上下滚动,下半截设置项看不到。把面板从
row翻成column后,内容列缺了min-height: 0,无法收缩、撑破面板被裁掉,内层滚动容器拿不到受限高度。同时把max-height升级为88vh+88dvh双写。
v0.1.0
首个版本。
已知边界(轻量核心版刻意不做)
- 无滑动手势:抽屉靠宿主自带的「收起侧边栏」按钮或点面板外的遮罩开合。手势需要处理让位规则、原生滚动冲突、Chrome 边缘返回手势、选词劫持,投入产出比低。
- 不做第三方插件兼容:只适配宿主自身 UI。第三方插件(dshmarket、dsh-file-viewer 等)的移动端表现不在范围内。
- 不压缩响应:那是 Host 半区的流量优化,与 UI 适配无关。
:has()需要 Chromium 105+;更老的 WebView 上设置 sheet 的定位规则会静默失效(抽屉不受影响)。
许可
MIT