dsh-chat-background 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ # 发布指南
2
+
3
+ > 把插件发布到 GitHub、npm 和 DSH 插件市场(DSH Plugin Hub)的流程。
4
+
5
+ ## 1. 发布前检查
6
+
7
+ ```bash
8
+ npm run verify # 格式 + 构建一致性 + 静态检查 + 单元测试
9
+ npm run test:e2e # 11 个端到端套件(需要本机 DSH)
10
+ npm pack --dry-run # 确认 tarball 内容
11
+ ```
12
+
13
+ - `package.json` / `client.js` banner / `CHANGELOG.md` 的版本必须一致;
14
+ - `README.md` 的安装命令与实际包名一致;
15
+ - 不提交 `_scratch/`、`.tmp/`、`node_modules/`、任何个人路径或凭据。
16
+
17
+ ## 2. GitHub 发布
18
+
19
+ ### 2.1 历史清洗(首次开源必做)
20
+
21
+ 如果仓库曾作为私有开发仓库使用,旧提交里可能包含个人路径、内部报告或测试产物。首次公开前:
22
+
23
+ - 用 `git grep -nE "C:\\\\Users|/Users/|E:/deepseek|docs/handoff" $(git rev-list --all)` 检查**全部历史**;
24
+ - 推荐直接 `rm -rf .git && git init -b main`,把当前工作区作为**单个初始提交**发布;
25
+ - 删除旧 tag(如 `git tag -d v1.2.0`),只保留要发布的版本;
26
+ - 复查 `git log --oneline` 与 `git ls-files`,确认没有个人文件后再推送。
27
+
28
+ 当前仓库已按此方式重建为单提交的 1.0.0 起点,当前发布版本 1.1.0。
29
+
30
+ ### 2.2 首次创建仓库
31
+
32
+ 1. 在 GitHub 新建公开仓库 `dsh-chat-background`(默认分支 `main`);
33
+ 2. 推送代码与 `v1.1.0` 标签;
34
+ 3. 仓库 **About** 填写简介:
35
+ - 描述:`给 DeepSeek Harness 桌面端换背景:背景图/轮换分组/三栏遮罩/输入栏背景条/任务进度条/Token 用量`;
36
+ - 网站:留空或填仓库地址;
37
+ - Topics:`dsh-plugin`、`deepseek-harness`、`dsh`、`background`、`wallpaper`、`theme`、`cordis`;
38
+ 4. README 必须包含可复制的安装命令:
39
+
40
+ ```bash
41
+ dsh plugin --profile desktop add github:LittleLemonJam/dsh-chat-background
42
+ ```
43
+
44
+ `dsh-plugin` topic 是插件市场自动扫描的识别标记,必须添加。
45
+
46
+ ### 2.3 Release
47
+
48
+ 推送 `v*` 标签会触发 `.github/workflows/release.yml`:校验标签与 `package.json` 版本、跑 `npm run verify`、
49
+ 生成 npm tarball 并创建 GitHub Release。发布后在 Release 中附上主要更新内容即可。
50
+
51
+ ```bash
52
+ git tag v1.1.0
53
+ git push origin main --tags
54
+ ```
55
+
56
+ ## 3. npm 发布
57
+
58
+ `1.1.0` 已发布到 npm,`dsh plugin --profile desktop add dsh-chat-background` 可直接安装。
59
+ 发新版本时:
60
+
61
+ ```bash
62
+ npm whoami # 确认是维护者账号 lemonjam
63
+ npm publish --access public # 需要带 bypass 2FA 的 granular token
64
+ ```
65
+
66
+ 验证:
67
+
68
+ ```bash
69
+ npm view dsh-chat-background version # 应等于当前版本号
70
+ dsh plugin --profile desktop add dsh-chat-background@1.1.0
71
+ ```
72
+
73
+ > npm 页面上的 README 是发布时 tarball 的快照,发布后再改 README 不会回写 npm 页面,
74
+ > 下次发版时才会同步;以 GitHub 仓库的 README 为准。
75
+
76
+ ## 4. 提交 DSH 插件市场
77
+
78
+ DSH 0.2.x 的插件市场会扫描 GitHub 上的公开 DSH 插件;收录要求:
79
+
80
+ 1. GitHub **公开仓库**;
81
+ 2. 仓库 Topics 含 `dsh-plugin`;
82
+ 3. README 含安装命令;
83
+ 4. 仓库导出符合规范的 `apply(ctx)` 插件(本项目 `index.js` + `cordis.patch.yml` 已满足);
84
+ 5. 不冒充官方项目。
85
+
86
+ 满足后到 **[dsh-plugin.org 提交插件](https://dsh-plugin.org/zh/submit)** 填写仓库地址、名称、简介与分类
87
+ (建议 `界面体验` / `Themes & Appearance`),等待自动扫描与人工确认。
88
+
89
+ 收录后市场会定期同步仓库的 Star、更新时间和 README;后续发版只需保持仓库与 Release 规范,无需重复提交。
90
+ 也可以同时提交到 `awesome-dsh-plugin` 等社区目录,按各仓库模板提 PR。
91
+
92
+ ## 5. 版本策略
93
+
94
+ - **PATCH**:修复、文档、内部重构;
95
+ - **MINOR**:新增功能、新增设置项、新增存储键;
96
+ - **MAJOR**:删除/重命名存储键、改变默认行为或最低 DSH 版本。
97
+
98
+ ## 6. 回滚
99
+
100
+ - GitHub Release 保留历史标签;用户可安装指定版本或旧 commit;
101
+ - npm 发布 72 小时内可 unpublish(或发布修复版本);
102
+ - 存储键只增不减,旧版本会忽略新键,新版本会忽略旧键,回滚不需要迁移。
103
+
104
+ ## 相关文档
105
+
106
+ - [DEVELOPMENT.md](DEVELOPMENT.md)
107
+ - [TESTING.md](TESTING.md)
108
+ - [ARCHITECTURE.md](ARCHITECTURE.md)
109
+ - [CHANGELOG.md](../CHANGELOG.md)
@@ -0,0 +1,129 @@
1
+ # 测试指南
2
+
3
+ > 测试分层、如何运行、如何新增断言,以及端到端测试的前置条件与排障。
4
+ > 相关:[DEVELOPMENT.md](DEVELOPMENT.md) · [ARCHITECTURE.md](ARCHITECTURE.md) · [CONVENTIONS.md](CONVENTIONS.md)
5
+
6
+ ## 1. 测试分层
7
+
8
+ | 层 | 命令 | 覆盖 | 是否需要 DSH |
9
+ | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
10
+ | 格式 | `npm run format:check` | 缩进/引号/分号/行宽/Markdown 表格 | 否 |
11
+ | 静态检查 | `npm run check` | 清单字段、locale、构建产物新鲜度、编码/换行、i18n 键、遗留标记 | 否 |
12
+ | 单元测试 | `npm run test:unit` | 宿主路由(成功/404/405/403/304/HEAD)、图片名校验、目录清单、token 汇总、格式化/颜色/滑杆工具、i18n 键 | 否 |
13
+ | 端到端 | `npm run test:e2e` | 真实 DSH Web 实例里的背景、侧栏、背景条、进度条、Token 行、背景/UI 两个标签页、开关/滑杆/调色盘、深色主题、语言切换、resize、宿主路由 | 是 |
14
+
15
+ CI(`.github/workflows/ci.yml`)在 ubuntu(Node 22/24)与 **Windows(Node 24)** 上跑格式、构建、静态检查与单元测试;
16
+ 端到端因为需要本机 DSH 安装与浏览器,只在本地运行。
17
+
18
+ ## 2. 端到端测试
19
+
20
+ ### 2.1 前置条件
21
+
22
+ 1. 本机已安装 DSH 桌面端(`>= 0.2.1-rc.3`);
23
+ 2. 有 Edge 或 Chrome;
24
+ 3. 工具能定位到 DSH CLI:
25
+ - 默认尝试 `dsh`(PATH)、环境变量 `DSH_CLI`,或 `--dsh <path>` 显式指定;
26
+ 4. 工具能定位到浏览器:默认尝试常见 Edge/Chrome 路径、`DSH_E2E_BROWSER`、`--browser <exe>`。
27
+
28
+ ### 2.2 运行
29
+
30
+ ```bash
31
+ npm run test:e2e # 全部套件(跳过 00- 开头的调试探针)
32
+ npm run test:e2e -- --suite 01-smoke # 只跑一个套件
33
+ npm run test:e2e -- --keep # 保留临时 DSH_HOME 与产物,便于排查
34
+ ```
35
+
36
+ 运行过程:
37
+
38
+ 1. 在 `.tmp/e2e-home/` 生成隔离的 `DSH_HOME`(**不会**碰用户 `~/.dsh`)与 profile `e2e`;
39
+ 2. 复制本插件的运行时文件、写入 `~/.dsh/backgrounds` 同级的样例图片;
40
+ 3. 用独立端口启动一个 DSH Web 实例(`--no-open`),从输出解析带 token 的 URL;
41
+ 4. 逐个套件用无头 Edge/Chrome + CDP 执行步骤;
42
+ 5. 结束(或失败)时杀掉实例、删除临时 home;`--keep` 时保留,产物始终保留。
43
+
44
+ ### 2.3 产物
45
+
46
+ ```text
47
+ .tmp/e2e-artifacts/
48
+ ├─ summary.json 每个套件的状态与产物目录
49
+ └─ <suite>/
50
+ ├─ report.json 页面事实、逐步日志、断言结果、控制台错误、截图
51
+ └─ *.png 截图
52
+ ```
53
+
54
+ 产物按套件保留(重复运行只覆盖对应套件目录),全量运行的 `summary.json` 可作为发版证据;
55
+ `report.json` 中的实例 token 已脱敏为 `token=…`。`.tmp/` 已被 git 忽略,不会进入仓库。
56
+
57
+ `report.json` 的 `checks[]` 是断言明细;`facts` 是终态 DOM 快照(背景标记、变量、背景条几何、Token 文本、
58
+ 设置行标题等),排查回归时先看它。
59
+
60
+ ### 2.4 套件一览
61
+
62
+ | 套件 | 覆盖 |
63
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `01-smoke` | 背景应用、样式表/测量规则安装、背景条覆盖用量行且与输入卡片等宽、进度条空闲隐藏、Token 行位置、设置页标签页、背景区块、UI 分组卡片与控件类型、行 `aria-labelledby/describedby` |
65
+ | `02-controls` | 3 个开关、3 个滑杆、2 个调色盘、深色主题默认色、语言切换(经设置页语言控件) |
66
+ | `03-robustness` | 稳定钩子优先(英文 pill + `display: contents` 包装层)、零布局影响与 fixed 探针、纯样式尺寸变化跟随、移除/重建用量行、连续开关不重复、窗口 resize |
67
+ | `04-host-routes` | `/list`、图片 200/ETag/304、路径穿越 404、`/folder` 的 405/403、token 汇总自洽 |
68
+ | `05-rightbar` | 右栏收起/展开的临时网格过渡(中间帧宽度)、拖动宽度无过渡、全屏 panel 覆盖窗口/统一遮罩/主窗口隐藏、退出全屏恢复 |
69
+ | `08-security` | `/health`、目录脱敏、响应头、SVG CSP、真实导航到 SVG 后脚本不执行 |
70
+ | `06-image-zoom` | 固定层图片滚轮缩放/双击还原;设置弹窗内图片不缩放;右栏图片预览的普通滚轮转换为 Ctrl+滚轮 |
71
+ | `07-rotation` | 轮换分组内切换、过渡层出现、三行设置顺序/控件值/无小字、分组 5 行滚动、仅勾选框选择、缩略图、选择不重排、拖动排序动画、切换固定后停止轮换 |
72
+ | `07b-animated-background` | 动图文件被列为背景并由已加载的固定 IMG 层绘制,根元素不再绘制背景图 |
73
+ | `10-turn-masks` | 每个对话一轮一张遮罩卡(宽度=阅读列宽+两侧留白)、舒适档 28px 轮次布局间距(写回宿主 `--dsh-chat-flow-gap`)、卡片间约 14px 壁纸缝(上边额外 6px 出血,保证用户气泡顶边被盖住;卡片层 margin 清零不受宿主 flow gap 影响)、用户消息与回复同卡、主窗口不再整列铺遮罩、移除会话后卡片/列标记/间距一起还原 |
74
+ | `09-windows-titlebar` | Windows 标题栏标记给主窗口列铺不透明底色后,靠槽位标记而不是一次性测量结果保持透明;丢失测量标记后仍透明;focus/visibility 重新测量;移除标记后图片仍在 |
75
+
76
+ ### 2.5 新增断言
77
+
78
+ 步骤是 JSON 数据,不是脚本。常用步骤:
79
+
80
+ ```json
81
+ { "wait": 12000 }
82
+ { "clickText": "继续", "tag": "button" }
83
+ { "click": "[aria-label=\"账号菜单\"]" }
84
+ { "eval": "document.title" }
85
+ { "check": "可读的断言说明", "expr": "(() => { ... return true })()" }
86
+ { "shot": "my-screenshot" }
87
+ ```
88
+
89
+ - `check` 的 `expr` 必须返回 `true` 才算通过;可以是 `(async () => { ... })()`。
90
+ - 断言的 `label` 用中文写清「期望什么」,失败时 `report.json` 会带表达式返回值。
91
+ - 控制台错误默认视为失败;确有噪声时在套件顶层加 `"allowConsoleErrors": ["忽略片段"]`。
92
+ - 步骤里不要写注释(JSON 不支持),用 `{ "log": "..." }` 做进度标记。
93
+
94
+ ### 2.6 排障
95
+
96
+ | 现象 | 处理 |
97
+ | -------------------------------------- | ----------------------------------------------------------------------------- |
98
+ | `could not locate the DSH CLI` | 传 `--dsh <dsh 可执行文件>` 或设 `DSH_CLI` |
99
+ | `devtools endpoint never became ready` | 浏览器路径错误或被占用;传 `--browser`;确认没有同 user-data-dir 的残留进程 |
100
+ | 套件超时 | 默认 10 分钟/套件;首次启动较慢可先单独跑 `--suite 01-smoke --keep` 观察 |
101
+ | 页面显示 `Failed to load plugins` | 客户端 bundle 抛错;看上一条控制台错误,通常是 free variable 或 hook 用法问题 |
102
+ | 找不到 `继续`/`新会话` 按钮 | DSH 版本 UI 变化;先用 `01-smoke` 的日志观察按钮清单再改选择器 |
103
+
104
+ ## 3. 单元测试
105
+
106
+ - 位置:`test/unit/*.test.mjs`,用 Node 自带 `node:test` + `assert/strict`,无第三方依赖;当前 **78** 个用例。
107
+ - 只测试纯逻辑:通过 `import` 直接引用 `host/*.js`、`src/lib/*.js`、`src/i18n.js`(这些模块在导入期不碰 DOM)。
108
+ - 临时文件一律放 `os.tmpdir()` 并用 `mkdtemp`,测试结束 `rm` 清理。
109
+ - 宿主路由用 `Writable` 伪造 Node response,覆盖成功、HEAD、304、404、405、403 与缓存窗口。
110
+
111
+ ## 4. 手工验收清单(发版前)
112
+
113
+ - [ ] 设置 → 页面设置:背景标签 6 行、UI 标签 3 组卡片,顺序与控件类型正确;
114
+ - [ ] 选择背景图后:整窗透出图片;左侧栏/主窗口/右侧栏遮罩分别生效;设置面板保持不透明;
115
+ - [ ] 左/中/右三个遮罩滑杆互不影响(0 全透明、100 不透明);
116
+ - [ ] 背景/UI 标签切换正常,UI 下三组设置分别在同一边框卡片内;
117
+ - [ ] 背景条:宽度等于输入卡片、覆盖用量行;行变高/变宽时跟随;
118
+ - [ ] 进度条:空闲时隐藏;运行中不为 100% 且有脉动;关闭开关即消失;
119
+ - [ ] Token 行:位于账号行上方,文本形如 `Token总用量:438M`,悬停显示精确值与会话数;
120
+ - [ ] 深色主题下背景条默认色为深色;两个颜色「恢复默认」回到主题默认;
121
+ - [ ] 切到英文后行标题为英文;切回中文正常;
122
+ - [ ] 刷新页面后所有设置恢复;
123
+ - [ ] 控制台无错误;卸载/禁用插件后页面恢复原样(无残留属性/节点)。
124
+
125
+ ## 相关文档
126
+
127
+ - [DEVELOPMENT.md](DEVELOPMENT.md)
128
+ - [ARCHITECTURE.md](ARCHITECTURE.md)
129
+ - [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
@@ -0,0 +1,121 @@
1
+ # 故障排查
2
+
3
+ > 常见问题的现象、原因与处理方式。按「先确认插件加载 → 再看功能 → 最后看开发环境」的顺序排查。
4
+ > 相关:[README](../README.md) · [架构说明](ARCHITECTURE.md) · [测试指南](TESTING.md)
5
+
6
+ ## 1. 一眼判断插件是否加载
7
+
8
+ | 检查 | 方法 | 正常结果 |
9
+ | -------- | --------------------------------------------------------------------------------- | ------------------------------------------ |
10
+ | 宿主半 | 浏览器打开 `http://127.0.0.1:<端口>/chat-backgrounds/health` | `200` + JSON(含插件版本、限制与栅栏状态) |
11
+ | 宿主路由 | `/chat-backgrounds/list` | `200` + 图片清单(文件夹路径已脱敏为 `~`) |
12
+ | 浏览器半 | DevTools 执行 `document.documentElement.hasAttribute('data-dsh-chat-background')` | 选了背景时为 `true` |
13
+ | 样式表 | DevTools 查 `style[data-plugin-css="dsh-chat-background/background.css"]` | 存在 |
14
+ | 设置页 | 设置 → 页面设置 | 背景 6 行 + UI 3 组卡片 |
15
+
16
+ `/chat-backgrounds/*` 是本地路由但带请求栅栏(详见 SECURITY.md);非回环访问默认 `403`。未知路径返回 `404`。
17
+ 若返回 `404`,说明宿主半没有加载。若返回 `403`,多为 Host/反代问题,见 §8。
18
+
19
+ ## 2. 设置页没有「页面设置」分区
20
+
21
+ | 可能原因 | 处理 |
22
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
23
+ | 插件未安装 / 未启用 | 插件 → 已安装 里确认 `dsh-chat-background` 已启用;检查 profile 的 `dsh.profile.bundles` 与 `cordis.patch.yml` 的 insert 行 |
24
+ | 安装后没有重启 | 宿主半只在启动时加载:**完全退出**桌面端(含托盘)再打开 |
25
+ | bundle 加载失败 | 看设置 → 插件列表里的错误;或用 `dsh --profile desktop --dump-config` 检查组合结果 |
26
+
27
+ ## 3. 选了背景但界面没变化
28
+
29
+ | 可能原因 | 处理 |
30
+ | ---------------------- | ----------------------------------------------------------------------------------------- |
31
+ | 图片文件名不存在 | 在设置里点「重新扫描」后重新选择;`localStorage` 里残留的旧名字会在下拉框显示 `(missing)` |
32
+ | 背景文件夹为空 | 把图片放进 `~/.dsh/backgrounds/`(或 `DSH_BACKGROUNDS_DIR` 指向的目录)后重新扫描 |
33
+ | 图片格式不支持 | 支持 png / apng / jpg / jpeg / webp / gif / avif / bmp / svg |
34
+ | 宿主半 404 | 见 §1;检查 `DSH_BACKGROUNDS_DIR` 是否正确 |
35
+ | 同名图片覆盖后仍是旧图 | 图片响应使用 `must-revalidate`,刷新页面即可 |
36
+
37
+ ## 4. 背景条不出现 / 位置不对
38
+
39
+ - 出现条件:**有背景图** + 「输入栏下方背景条」开关为开 + 页面存在用量数据行(全新会话、没有用量时不出现)。
40
+ - 位置由「输入卡片 + 用量行」两个锚点测量得来;DSH 升级改变 composer 结构时可能失效。
41
+ - 自查:DevTools 执行
42
+ `document.querySelector('[data-dsh-chat-strip]')?.getBoundingClientRect()`,
43
+ 与 `[data-slot="conversation.composer.bar"] > * [data-composer-dock]` 的矩形对比。
44
+ - 若宽度等于整个 composer 包装层而不是输入卡片,通常是 `findInputCard` 没找到卡片(见 `src/strip.js`),
45
+ 需要按新 DOM 重新校准。
46
+
47
+ ## 5. 进度条一直是 100% / 不动
48
+
49
+ - 空闲(没有任务清单、也没有运行中的回合)时进度条**隐藏**;运行中或存在任务清单时才会出现。
50
+ - agent 运行时若没有维护 todo 清单,走步数估算:运行中最高 92%,结束后回到 100%。
51
+ - 若运行中仍显示 100%,检查 DSH 版本是否还提供 `todos` / `sessionStats` 投影(插件对未知投影做了容错)。
52
+ - 关闭开关后进度条消失;刷新后恢复上次状态。
53
+
54
+ ## 6. Token 行显示 `…` 或 `0`
55
+
56
+ | 现象 | 原因 / 处理 |
57
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | 一直 `…` | 宿主 `/chat-backgrounds/token-total` 返回非 200,或页面还没拿到第一次响应;等 60 秒或刷新 |
59
+ | 显示 `0` | 还没有任何会话投影缓存(全新安装),或缓存目录被清理过 |
60
+ | 数字比预期小 | 只统计磁盘上仍存在的 `session_projcache/sessions/*.json`;缓存被清理的会话统计不到;单个投影文件超过 8 MB(`MAX_PROJECTION_BYTES`)会被跳过 |
61
+ | 位置不对 | 依赖 `[class*="settingsArea"]` 锚点;DSH 改侧栏结构后会重新插入失败,需要校准 |
62
+
63
+ ## 2.5 图片滚轮缩放
64
+
65
+ - 聊天图片:点开灯箱后 **滚轮缩放**(以光标为锚点,0.4×–8×),**双击**还原;
66
+ - 右侧栏图片预览:普通滚轮等价于组件自带的 **Ctrl+滚轮** 缩放,刷新后保持缩放偏好;
67
+ - 这是内置手势,没有设置开关;内联缩略图不参与,避免误缩放页面。
68
+
69
+ ## 2.6 用诊断对象看「哪个锚点没找到」
70
+
71
+ DevTools 执行:
72
+
73
+ ```js
74
+ window.__dshcb.diagnostics()
75
+ ```
76
+
77
+ 返回背景是否生效、被测量的不透明容器数、背景条/输入卡片/用量行分别用的哪个锚点(`data-composer-card` /
78
+ stable-hooks / wording-fallback)、进度条是否空闲、Token 行状态与最近一次列表/Token 请求的 `source`。
79
+ 只剩一个字段异常时,通常就是该功能在 DSH 升级后失效的位置。
80
+
81
+ ## 7. 改代码不生效
82
+
83
+ | 改了什么 | 生效条件 |
84
+ | ------------------------------------------- | ---------------------------------------------------------------------------- |
85
+ | `src/`(浏览器半) | 必须 `npm run build` 并同步 `client.js` 到已安装目录;客户端轮询产物后热替换 |
86
+ | `host/`、`index.js` | 需要重启桌面端(或 DSH HMR 触发 profile 重新组合) |
87
+ | `package.json` 的 `dsh.client`/`dsh.bundle` | 需要重启 |
88
+ | 改了工作区但没同步安装目录 | 检查已安装目录里的文件 mtime;用 `npm run check` 确认产物新鲜度 |
89
+
90
+ ## 8. 开发/测试环境问题
91
+
92
+ | 现象 | 处理 |
93
+ | --------------------------------- | ------------------------------------------------- |
94
+ | `npm run test:e2e` 找不到 DSH CLI | 传 `--dsh <dsh.cmd 路径>` 或设置 `DSH_CLI` |
95
+ | 找不到浏览器 | 传 `--browser <exe>` 或设置 `DSH_E2E_BROWSER` |
96
+ | 端到端套件超时 | 用 `--suite 01-smoke --keep` 看页面实际状态与截图 |
97
+ | `client.js is stale` | `npm run build` 后重新提交 |
98
+ | 格式检查失败 | `npm run format` 后重新提交 |
99
+
100
+ ## 8.5 反向代理 / 远程访问(请求栅栏 403)
101
+
102
+ 五条本地路由会校验 `Host`/`Origin`/`Sec-Fetch-Site`(防 DNS rebinding)。如果通过非回环地址访问 Web GUI,
103
+ 图片与 Token 行会 `403`。设置 `DSH_CHAT_BACKGROUND_ALLOWED_HOSTS=<你的主机名>[,...]` 后重启;只有在可信代理下才用 `*`。
104
+ 这是有意为之的默认拒绝,不表示插件损坏。
105
+
106
+ ## 9. 收集报障信息
107
+
108
+ 提 Issue 时请附:
109
+
110
+ - DSH 版本(设置 → 关于)与插件版本(设置 → 插件列表);
111
+ - 现象截图;
112
+ - DevTools 控制台错误(如果有);
113
+ - `window.__dshcb.diagnostics()` 的 JSON(去掉个人信息)与 `/chat-backgrounds/health` 的响应;
114
+ - `document.documentElement.getAttribute('data-dsh-chat-background')` 与三个背景 CSS 变量的值;
115
+ - `/chat-backgrounds/list` 的响应(去掉个人路径)。
116
+
117
+ ## 相关文档
118
+
119
+ - [README](../README.md)
120
+ - [DEVELOPMENT.md](DEVELOPMENT.md)
121
+ - [TESTING.md](TESTING.md)
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Route paths, security policy and cache limits shared by the host half.
3
+ *
4
+ * The browser half mirrors the route strings in `src/constants.js`; keep both
5
+ * halves in sync when a route changes.
6
+ */
7
+
8
+ /** Route namespace owned by this plugin. */
9
+ export const ROUTE_ROOT = '/chat-backgrounds'
10
+
11
+ /** Lists the background folder's images as JSON. */
12
+ export const LIST_PATH = `${ROUTE_ROOT}/list`
13
+
14
+ /** Prefix serving one image's bytes. */
15
+ export const FILE_PREFIX = `${ROUTE_ROOT}/files`
16
+
17
+ /** Reveals the background folder in the platform file manager. */
18
+ export const FOLDER_PATH = `${ROUTE_ROOT}/folder`
19
+
20
+ /**
21
+ * Custom header the folder route requires. The route is public and its side
22
+ * effect opens a window, so requiring a header a cross-site request cannot send
23
+ * without a CORS preflight keeps a random web page from triggering it (`BG-10`).
24
+ * It is one layer of the request fence, not the fence itself (`SEC-02`).
25
+ */
26
+ export const FOLDER_HEADER = 'x-dsh-chat-background'
27
+ export const FOLDER_HEADER_VALUE = '1'
28
+
29
+ /** Aggregated token usage over every cached session projection. */
30
+ export const TOKEN_PATH = `${ROUTE_ROOT}/token-total`
31
+
32
+ /** Read-only self-report: version, limits, data source and fence mode. */
33
+ export const HEALTH_PATH = `${ROUTE_ROOT}/health`
34
+
35
+ /**
36
+ * Comma-separated extra request hostnames accepted by the request fence; `*`
37
+ * disables the host check. Loopback names plus the Web server's own origin are
38
+ * always accepted. Set this only for a remote-access proxy that rewrites Host.
39
+ */
40
+ export const TRUSTED_HOSTS_ENV = 'DSH_CHAT_BACKGROUND_ALLOWED_HOSTS'
41
+
42
+ /**
43
+ * Cache policy for image bytes.
44
+ *
45
+ * The URL carries no content revision, and a user may overwrite one image with
46
+ * another of the same name (the folder is theirs), so the response must
47
+ * revalidate: an `immutable` one-year cache would keep painting the old picture
48
+ * until a manual browser cache clear. `private` keeps the user's own pictures
49
+ * out of shared caches (`SEC-06`).
50
+ */
51
+ export const IMAGE_CACHE_CONTROL = 'private, max-age=0, must-revalidate'
52
+
53
+ /** How long the token aggregation may be reused while the browser polls. */
54
+ export const TOKEN_CACHE_MS = 10_000
55
+
56
+ /** How long one directory listing may be reused by `list` and `files`. */
57
+ export const MANIFEST_CACHE_MS = 1_000
58
+
59
+ /**
60
+ * Resource limits. A background folder is a user-owned local directory, but the
61
+ * routes are reachable from the browser, so both scanning and serving are
62
+ * bounded (`SEC-08`).
63
+ */
64
+ export const MAX_MANIFEST_ENTRIES = 500
65
+ export const MAX_MANIFEST_SCAN = 5_000
66
+ export const MAX_IMAGE_BYTES = 64 * 1024 * 1024
67
+ export const MAX_TOKEN_FILES = 5_000
68
+
69
+ /**
70
+ * Headers applied to every response. The routes are same-origin resources; they
71
+ * must never be sniffed into another type, embedded by another origin, or leak
72
+ * the application URL through `Referer` (`SEC-06`).
73
+ */
74
+ export const SECURITY_HEADERS = Object.freeze({
75
+ 'x-content-type-options': 'nosniff',
76
+ 'cross-origin-resource-policy': 'same-origin',
77
+ 'referrer-policy': 'no-referrer'
78
+ })
79
+
80
+ /**
81
+ * Response policy for images that browsers may also open as a top-level
82
+ * document (SVG is the only accepted active format). `sandbox` gives the
83
+ * document a unique opaque origin and `default-src 'none'` blocks subresources;
84
+ * scripts therefore cannot run in the DSH origin (`SEC-01`). As an image
85
+ * subresource (CSS background / `<img>`) the document policy is not applied.
86
+ */
87
+ export const IMAGE_DOCUMENT_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
package/host/media.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Background image discovery: which files count as images, how one file name is
3
+ * validated, and how the folder is listed.
4
+ *
5
+ * The manifest is also the access-control list for the `files` route (`SEC-04`):
6
+ * a name that is not listed is not served, so Windows 8.3 short names, hidden
7
+ * files and arbitrary case variants of unlisted files cannot be used to fetch
8
+ * bytes that the settings page never offered.
9
+ */
10
+ import { lstat, readdir, realpath, stat } from 'node:fs/promises'
11
+ import { basename, extname, isAbsolute, join, relative } from 'node:path'
12
+ import { MAX_MANIFEST_ENTRIES, MAX_MANIFEST_SCAN } from './constants.js'
13
+
14
+ /** Extensions accepted as background images, mapped to their media type. */
15
+ export const MEDIA_TYPES = new Map([
16
+ ['.png', 'image/png'],
17
+ ['.apng', 'image/apng'],
18
+ ['.jpg', 'image/jpeg'],
19
+ ['.jpeg', 'image/jpeg'],
20
+ ['.webp', 'image/webp'],
21
+ ['.gif', 'image/gif'],
22
+ ['.avif', 'image/avif'],
23
+ ['.bmp', 'image/bmp'],
24
+ ['.svg', 'image/svg+xml']
25
+ ])
26
+
27
+ /** The accepted extension list, for display in the settings row. */
28
+ export const ACCEPTED_EXTENSIONS = [...MEDIA_TYPES.keys()].join(', ')
29
+
30
+ /**
31
+ * Media type of one file name, or `application/octet-stream` when unknown.
32
+ * @param name - file name.
33
+ * @returns the media type header value.
34
+ */
35
+ export function mediaTypeFor(name) {
36
+ return MEDIA_TYPES.get(extname(name).toLowerCase()) ?? 'application/octet-stream'
37
+ }
38
+
39
+ /**
40
+ * Validate one user-supplied image name.
41
+ *
42
+ * The value arrives from a URL and becomes a path segment; it is accepted only
43
+ * when it is a plain single-segment file name of an accepted image type. A
44
+ * dot-leading name is rejected so the folder's hidden files never surface.
45
+ * @param value - candidate file name.
46
+ * @returns the name when safe, otherwise undefined.
47
+ */
48
+ export function safeImageName(value) {
49
+ if (typeof value !== 'string' || value === '' || value.length > 200) return undefined
50
+ if (value !== basename(value)) return undefined
51
+ if (value.includes('/') || value.includes('\\') || value.includes('\0')) return undefined
52
+ if (value.startsWith('.')) return undefined
53
+ if (!MEDIA_TYPES.has(extname(value).toLowerCase())) return undefined
54
+ return value
55
+ }
56
+
57
+ /**
58
+ * Whether `candidate` resolves inside `root`. Both paths must be absolute.
59
+ * @param root - absolute directory path.
60
+ * @param candidate - absolute path.
61
+ */
62
+ function isInside(root, candidate) {
63
+ const relation = relative(root, candidate)
64
+ return relation !== '' && !relation.startsWith('..') && !isAbsolute(relation)
65
+ }
66
+
67
+ /**
68
+ * List the folder's images, newest first.
69
+ *
70
+ * Symbolic links that point outside the folder are skipped, so a link dropped
71
+ * into the folder cannot turn this route into an arbitrary-file reader
72
+ * (`SEC-05`). Scanning and result size are bounded (`SEC-08`).
73
+ * @param directory - absolute folder path.
74
+ * @returns image entries with their stable revision and a truncation flag.
75
+ */
76
+ export async function readImageManifestDetailed(directory) {
77
+ let names
78
+ try {
79
+ names = await readdir(directory)
80
+ } catch (error) {
81
+ if (error?.code === 'ENOENT') return { entries: [], truncated: false }
82
+ throw error
83
+ }
84
+ let root
85
+ try {
86
+ root = await realpath(directory)
87
+ } catch {
88
+ return { entries: [], truncated: false }
89
+ }
90
+ const entries = []
91
+ let scanned = 0
92
+ for (const name of names) {
93
+ if (scanned >= MAX_MANIFEST_SCAN) break
94
+ scanned += 1
95
+ if (safeImageName(name) === undefined) continue
96
+ const path = join(directory, name)
97
+ let linkInfo
98
+ try {
99
+ linkInfo = await lstat(path)
100
+ } catch {
101
+ continue
102
+ }
103
+ if (linkInfo.isSymbolicLink()) {
104
+ /* A link is acceptable only when its target stays inside the folder. */
105
+ try {
106
+ if (!isInside(root, await realpath(path))) continue
107
+ } catch {
108
+ continue
109
+ }
110
+ }
111
+ let info
112
+ try {
113
+ info = await stat(path)
114
+ } catch {
115
+ continue
116
+ }
117
+ if (!info.isFile()) continue
118
+ entries.push({
119
+ name,
120
+ bytes: info.size,
121
+ mtimeMs: Math.floor(info.mtimeMs),
122
+ rev: `${info.size.toString(36)}-${Math.floor(info.mtimeMs).toString(36)}`
123
+ })
124
+ }
125
+ entries.sort((left, right) => right.mtimeMs - left.mtimeMs || left.name.localeCompare(right.name))
126
+ const truncated = entries.length > MAX_MANIFEST_ENTRIES || scanned >= MAX_MANIFEST_SCAN
127
+ return { entries: entries.slice(0, MAX_MANIFEST_ENTRIES), truncated }
128
+ }
129
+
130
+ /**
131
+ * List the folder images without truncation metadata. Kept for callers and
132
+ * tests that only need the entries.
133
+ * @param directory - absolute folder path.
134
+ * @returns the image entries.
135
+ */
136
+ export async function readImageManifest(directory) {
137
+ return (await readImageManifestDetailed(directory)).entries
138
+ }
139
+
140
+ /**
141
+ * Find the actual manifest entry for a requested file name. Windows and macOS
142
+ * file systems are case-insensitive, so an exact match is preferred and a
143
+ * case-insensitive one is accepted; anything else is not served.
144
+ * @param entries - the current manifest.
145
+ * @param name - validated request name.
146
+ * @returns the manifest entry, or undefined when the name is not listed.
147
+ */
148
+ export function findManifestEntry(entries, name) {
149
+ const exact = entries.find((entry) => entry.name === name)
150
+ if (exact !== undefined) return exact
151
+ const lowered = name.toLowerCase()
152
+ return entries.find((entry) => entry.name.toLowerCase() === lowered)
153
+ }
package/host/paths.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Directory resolution for the host half.
3
+ *
4
+ * Both paths honour an environment override so tests and alternative profiles
5
+ * can point the plugin at a disposable folder. `DSH_HOME` mirrors the harness
6
+ * home; `backgroundsDir` additionally accepts `DSH_BACKGROUNDS_DIR`.
7
+ *
8
+ * Overrides are normalised with `resolve`, so a value copied from a file manager
9
+ * with a trailing separator (`...\backgrounds\`) still compares equal to the
10
+ * joined child paths further down (`BG-05`). A leading `~` / `~\` is expanded
11
+ * like the host's own home-path resolver does, because users copy such values
12
+ * from the DSH documentation (`R2 F8`).
13
+ */
14
+ import { homedir } from 'node:os'
15
+ import { join, resolve } from 'node:path'
16
+
17
+ /**
18
+ * Expand a leading `~` or `~\`/`~/` to the user's home directory.
19
+ * @param value - raw environment value.
20
+ * @returns the expanded path.
21
+ */
22
+ export function expandHomePath(value) {
23
+ const trimmed = value.trim()
24
+ if (trimmed === '~') return homedir()
25
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) return join(homedir(), trimmed.slice(2))
26
+ return trimmed
27
+ }
28
+
29
+ /** The harness home directory: `DSH_HOME` when set, else `~/.dsh`. */
30
+ export function dshHomeDir() {
31
+ const home = process.env.DSH_HOME
32
+ return home !== undefined && home.trim() !== '' ? resolve(expandHomePath(home)) : join(homedir(), '.dsh')
33
+ }
34
+
35
+ /**
36
+ * The background image folder: `DSH_BACKGROUNDS_DIR` when set, else
37
+ * `<dsh home>/backgrounds`.
38
+ */
39
+ export function backgroundsDir() {
40
+ const override = process.env.DSH_BACKGROUNDS_DIR
41
+ if (override !== undefined && override.trim() !== '') return resolve(expandHomePath(override))
42
+ return join(dshHomeDir(), 'backgrounds')
43
+ }