# dsh-session-sync
**DSH 的局域网会话同步插件。** 装了它的 DSH 会在局域网里互相发现,列出对方设备,可以通过普通 HTTP 浏览、拉取、推送会话。**你给某台设备起的外号只存在本机**——对方离线、重启、换 IP 再上线,你看到的还是自己给它起的名字。
[](https://github.com/XXGxiaoyu/dsh-session-sync/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/dsh-session-sync-xxg)
[](https://github.com/XXGxiaoyu/dsh-session-sync/stargazers)
[](https://opensource.org/licenses/MIT)
[](https://www.npmjs.com/package/@deepseek-ai/dsh)
🌏 **简体中文** · [English](./README.md)
> **0.8.0 界面重构**:会话台、整行选择、选择条、传输坞与设置页全部重画。完整稿(19 个场景,可切深浅主题 / 中英文)见 [`docs/redesign/index.html`](https://github.com/XXGxiaoyu/dsh-session-sync/tree/main/docs/redesign),实现映射与验收见 [`DESIGN.md`](https://github.com/XXGxiaoyu/dsh-session-sync/blob/main/docs/redesign/DESIGN.md)。整包文件迁移的分片 / 断点续传设计见 [`session-bundle-transfer.md`](https://github.com/XXGxiaoyu/dsh-session-sync/blob/main/docs/architecture/session-bundle-transfer.md)(下一阶段)。
## ✨ 功能
| | |
|---|---|
| **📡 零配置发现**
每 4 秒向每个 IPv4 网段的广播地址发一次 UDP 心跳;收到陌生设备的广播会立刻单播回礼,所以新上线的机器不用等下一轮。 | **🏷️ 仅存本机的外号**
写在 `/session-sync/state.json`,按**稳定设备 id**(uuid)索引,不看 IP、不看主机名。对端改自己的显示名,不会覆盖你起的外号。 |
| **🗂️ 浏览 · 拉取 · 推送**
看到对方每个会话的标题、cwd 和时间;一键把某个会话拉到本机,或把本机的推过去。拉回来的会话在列表里有标记。 | **🔐 SHA-256 校验**
导出时附带 `sha256 = SHA-256(JSON.stringify({format, session, events}))`;导入**先比摘要再写库**,不一致直接拒收(HTTP 409 `checksum-mismatch`)。每个会话行都有「校验」。 |
| **🔁 可选自动拉取**
每 20 秒扫一次在线设备,把还没拉过的会话同步过来。去重靠内容指纹(不是 id),两台都开自动拉取也不会把同一段对话反复互导。 | **🎨 原生观感与 i18n**
全部用 DSH 的设计变量(颜色、圆角、字号阶梯、0.2s 动效),深浅主题跟随宿主;语言默认跟随 DSH,也可固定中文 / English。 |
| **🖱️ 选择与拖拽**
**点整行(标题、元信息、状态徽章)即可选中**,勾选框只显示状态;Shift 连选、⌘/Ctrl 追加、空格/回车切换。会话可多选,拖到设备卡片即推送;拖到侧栏工作区的**标题行、或其展开后的任意一行**(含空白)都能命中;拖到会话行时按指针位置决定插入点,行间会出现一条插入线,松手先给 90ms 成功态再弹确认框,并写明这条对话会导入到哪个工作区。 | **🚦 可确认的传输队列**
推送/拉取前先查重:已存在时弹框让你选「保留本机」「覆盖本机」或单独取消这一条,其余条目照常排队执行。 |
| **🔎 找会话更快**
搜索(按 `/` 聚焦)、全部 / 未拉取 / 已拉取筛选、按时间或标题排序,↑↓ / 空格 / 回车操作。每行只留状态徽章,校验与拉取在悬停或聚焦时出现。 | **🧰 设备与队列归位**
设备栏只负责选择;命名、复制地址、刷新、推送、移除收进右栏头部的 ⋯。传输进度收到底部传输坞,向上展开队列;网络与高级选项默认折叠。 |
| **📁 自动同步目录**
`syncDir` 决定自动拉取落到哪:插件自己的「未分组」目录、某个工作区,或任意绝对路径(保存时创建)。 | **🪟 主从两栏**
左侧是设备列表(每行显示会话数与箭头,整行可点),右侧常驻所选设备的会话列表——不需要先发现「点设备才能展开」。默认自动选中第一台在线设备。 | **🧭 设备操作**
右栏头部的 ⋯ 放推送、重命名、复制地址、刷新、移除;重命名在头部就地改。 |
| **🪄 拖到侧栏**
拖动时高亮 DSH 自己的工作区行(读 `data-row-key`),不改侧栏 DOM、不抢插槽;松手仍走二次确认。 |
| **⚡ 一键同步**
「一键同步」/「一键推送」一次规划整台设备,按内容指纹跳过已有会话,逐条显示完成、跳过或失败。 | **🌐 跨网段扫描**
广播只能留在本网段;在「跨网段扫描」里填 CIDR、IP 或 `IP:端口`。网段走静默 UDP 发现包,单个 IP 额外用 HTTP 探测 47802–47817 端口——防火墙丢弃 UDP 也藏不住对端。 |
| **🚦 看得见过程的队列**
每条传输是一张卡:旋转图标、实测耗时、失败原因与进度轨道;拉取走流式字节计数,卡片和底部条显示**实时百分比**。队列按 CPU 核数自动开多个 worker(上限 5),也可在设置里指定 1–16 个并发。 |
### 界面上的三条约定
- **「本机」永远在第一行,同 IP 不算第二台。** 设备区顶部固定一张本机卡片:名字、局域网地址:同步端口、运行版本。地址属于本机的 peer 是同一台机器(别的 profile、别的端口),**不会出现在设备列表里**;列表数量永远只表示**其他机器**。
- **未命名会话显示 `#会话 id 前 8 位`。** 一堆“未命名会话”长得一样,靠 id 才分得清;拉取计划里的「已存在」也是按同一台设备的同一个会话 id 判定的,改标题不会把重复判成新会话。
- **界面出错不再是一片空白。** 渲染异常会被错误边界接住,显示平台、Node 版本、宿主版本、端口、UA 和堆栈摘要,并带「重新加载 / 复制诊断」。Windows 上如果仍然空白,把这块诊断内容发到 issue 即可。
## 🚀 安装
从 npm 安装(scope 包名;npm 上的 `dsh-session-sync` 属于另一个项目):
```sh
dsh plugin --profile web add dsh-session-sync-xxg
```
或从 GitHub 跟踪最新提交:
```sh
dsh plugin --profile web add github:XXGxiaoyu/dsh-session-sync
```
需要 DSH **0.2.1-rc.2**(当前桌面版内置的版本)与 Node 22+。
然后重启 DSH 桌面应用(宿主半边的改动在启动时加载,页面刷新即可拿到新 UI)。装完会自动启用:包里声明了 `dsh.bundle`,`dsh plugin add` 会自己把它加进 `dsh.profile.bundles`。页脚显示的是**真正在运行**的那份构建;如果比 Latest 版本旧,说明进程还没重启。
发布页:
从本地目录手动安装
```jsonc
// ~/.dsh/profiles//package.json
{
"dependencies": {
"dsh-session-sync-xxg": "link:/absolute/path/to/dsh-session-sync"
},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-session-sync-xxg"]
}
}
}
```
```sh
cd ~/.dsh/profiles/ && pnpm install
```
加载成功的话,日志里会出现:
```text
[dsh-session-sync] 已就绪:设备 ,同步端口 47802,发现端口 47801
[dsh-session-sync] 发现设备 <另一台的名字> (192.168.1.7:47802)
```
## 🧭 在哪用
侧边栏左侧「插件」下面多了一行**会话同步**(`sidebar.panellist` + keyed `main` 面板,id 都是 `session-sync`);同一个组件也注册成 **设置 → 会话同步**(`settings.section`)。
- **本机**——别的设备看到的你的名字,改完立刻广播。
- **设备**——在线/离线、最近心跳、会话数、平台与版本。
- **命名 / 移除**——只影响本机看到的名字;离线设备保留这一行(和你的外号),只是变灰。
- **查看会话 / 拉取 / 推送**——列对方会话、拉一个到本机、把本机的送出去。
- **校验**——重新拉一遍对方的整包,重算 SHA-256 与本机副本比对(`sha256 2b12f7854d80… 校验通过`)。
- **摘要证明完整性,不证明身份。** 任何拿到内容的人都能重算摘要,所以校验保持自动、失败即拒收,不做“每次传输都要手动确认摘要”的打扰(那只会让人无脑点通过)。真需要带外核对时,点行内的「复制完整摘要」,用另一条通道(手机、终端)对比即可。
- **同步设置**——界面语言、自动拉取开关、手动设备地址(广播被挡的网络用,填 `ip` 或 `ip:udpPort`,一行一个)。
## 🌏 界面语言
| 选项 | 行为 |
| --- | --- |
| 跟随 DSH(默认) | 用 DSH 当前语言。DSH 本身就会按系统语言回退,所以非中文系统一律 English,中文系统中文 |
| 中文 | 固定中文 |
| English | 固定 English |
```text
settings.locale = 'auto' | 'zh' | 'en' // 持久化在 /session-sync/state.json
```
插件读 `ctx.locale.getSnapshot().active`(`zh*` → 中文,其余 → English);只有拿不到 locale 服务时才退回 `navigator.languages`。切换语言先落盘再换 UI,不会出现「下次打开又变回去」。侧栏行和设置项的文字由宿主解析,所以语言变化时插件会重新登记槽位——页面和侧栏永远不会一个中文一个英文。
## 🎨 界面风格
界面不写死任何色值,直接用 DSH 的变量:颜色 `--dsw-alias-*`,圆角 `--dsw-radius-*`,字号用 `--dsw-font-*` 阶梯,动效统一 `var(--ds-transition-duration) var(--ds-ease-in-out)`。排版照搬内置设置页——左边「标题 + 灰色说明」、右边控件、行间 `.5px` 分隔线、32 高输入/下拉、36×22 的 pill 开关,所以两套主题、窄窗口下都与宿主一致。
## ⚙️ 配置(loader row 的 `config`)
| 键 | 默认 | 说明 |
| --- | --- | --- |
| `name` | 主机名 | 首次启动的本机显示名(之后以 GUI 为准) |
| `lanPort` | `47802` | 同步 HTTP 端口,占用时往后试 16 个 |
| `discoveryPort` | `47801` | UDP 发现端口 |
| `token` | 无 | 设置后局域网请求需带 `x-dsh-session-sync-token` |
| `home` | `$DSH_HOME` 或 `~/.dsh` | 状态与会话根目录 |
| `autoPullIntervalMs` | `20000` | 自动拉取间隔(测试里会调小) |
| `verbose` | `true` | 关掉插件日志 |
| `subnets` | `[]` | 广播到不了时额外扫描的 CIDR/IP/`IP:端口`(如 `192.168.5.0/24`、`10.0.0.7`、`10.0.0.7:47802`)。网段用 UDP 发现包;单个 IP 还会用 HTTP 探测 47802–47817 |
## 🔌 局域网协议(`/v1/*`)
绑定 `0.0.0.0`;除 `/v1/health` 外都要 `x-dsh-session-sync: 1`。
| 方法 | 路径 | 作用 |
| --- | --- | --- |
| GET | `/v1/health` | `{ok:true}` 探活 |
| GET | `/v1/info` | 设备 id、名字、会话数、版本 |
| GET | `/v1/sessions?limit=N` | 会话摘要列表 |
| GET | `/v1/sessions/:id` | 导出 `{format:'dsh-session-sync/1', session, events, sha256}` |
| POST | `/v1/sessions` | 导入同一格式(带 `sha256` 会先校验),返回新 id |
发现走 UDP 广播:每 4 秒向各 IPv4 网段广播地址、`127.0.0.1` 和配置的 seeds 发 `dsh-session-sync/hello` 报文(`{id, name, port, platform, version, sessions}`),陌生 id 立刻单播回礼,新设备不用等下一轮。
GUI 的 `/session-sync/api` 跑在 DSH 自己的回环 web 服务上,受宿主连接策略保护;真正暴露到局域网的只有 `/v1/*`。
## 🧵 设计决定
- **命名按设备 id,不按 IP。** `deviceId` 是首次启动生成的 uuid,`peers[].customName` 只属于本机。
- **导入走官方持久化服务**:`ctx.sessionPersistence.create() → append() → flush()`,v4 header 只留允许字段(`version/id/createdAt/cwd/isSeeded/delegationDepth/parentSession/agentPreset`)。多一个 `type` 就会被 `format v4 header has unexpected field type` 拒掉。
- **导入保留源 `createdAt`,重编 id 和 seq。** id 必须全局唯一,seq 从 0 连续;`createdAt` 保留是为了让内容指纹成立。
- **跨机 cwd**:源 cwd 在本机存在就保留,不存在落到 `process.cwd()`,否则会话会在工作区列表里「消失」。
- **SHA-256 认字节**:两端序列化同一份对象,键序一致所以摘要可比;不一致在落库前就中止。
- **去重靠指纹不靠 id**:`state.signatures[sha1(createdAt, 事件形状, 首条预览)]` 在导出/导入时登记,自动拉取遇到就跳过。没有它,两台都开自动拉取的机器会把同一段对话换 id 反复互导。
- **本机可见**:外号、导入记录、设置全在 `/session-sync/state.json`,不上传、不同步。
## 🩺 常见问题
0.5.5 之前的版本导入会话时写的是 `isSeeded: false`,却保留了对方的继承标记,DSH 会拒绝打开(`released v2 unseeded Session contains an inherited end-seed marker`)。在「网络与高级 → 修复历史会话」里点一下即可修复 `/sessions` 下的全部记录,也可以离线跑:
```bash
node scripts/repair-sessions.mjs # 就地修复,保留 *.bak
node scripts/repair-sessions.mjs --dry-run # 只报告,不写盘
```
## 🧪 开发
```bash
node --test --test-force-exit --test-timeout=120000 'test/*.test.mjs'
```
35 个用例:客户端 bundle 契约、词典一致性、页脚与版本一致性(13)、jsdom 挂载与交互(5)、双实例端到端——发现/改名持久化/重启保留/拉取推送/自动拉取不反弹/接收端去重/SHA-256 篡改拒收/仅 HTTP 可发现的设备(8)、HTTP 兜底探测契约(7)。
单独起一个不带桌面壳的实例:
```bash
DSH_HOME=/tmp/dsh-ss-home node /lib/bin.js --profile ssync --no-open --host 127.0.0.1 --port 4399
```
`--profile` 要放在 `--no-open` 这类 launcher flag **前面**;`web --profile x` 会报 `select a profile only once`。
## 📄 许可证
[MIT](./LICENSE)