npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@wangmingfa/syncx

v0.3.5

Published

Peer-to-peer LAN file synchronization tool (Syncthing-like) in Node.js

Readme

syncx

Peer-to-peer 局域网文件同步工具(类 Syncthing),使用 Node.js 开发。

在个人自己的多台设备之间,通过局域网实时双向同步文件:笔记本 ↔ 台式机 ↔ NAS ↔ 服务器。无中心服务器、无云依赖,所有设备对等直连。

特性

  • 纯 P2P 架构 — 设备两两直连,无中心协调节点、无单点故障
  • 链式拓扑自动中转 — A—B—C(A 与 C 不直连)时,C 的改动经 B 转发给 A:中转沿用原作者的版本向量、只点名变更路径,回环在结构上不可能发生(见 docs/adr/0014)
  • 版本向量冲突检测 — 双方并发编辑同一文件时,保留双方、生成冲突副本,绝不静默丢数据;可按目录改成「新者胜 / 本机优先」
  • 分块增量传输 — SHA-256 内容寻址 + 内容定义分块(CDC):块边界随内容滚动哈希确定(变长,均值约 1MB),大文件中部小改只重传改动所在的一块(1 字节改写实测作废 1 块 / 共 3–11 块);旧对端与无本地可比版本时兼容退回定长 1MB 块(见 docs/adr/0017,指纹的窗口移出项与其代价见 docs/adr/0019)
  • 断点续传 — 已收的块当场落进目标文件旁边的中间态,断线 / 重启 / 崩溃后按位图回读验哈希、只补缺块,不必从头重拉;两侧内存峰值也不再随文件大小增长(见 ADR-0021)
  • 墓碑与防复活 — 删除记录带版本号传播,避免"离线修改的文件复活"
  • 双模式发现 — mDNS 自动发现局域网设备,同时支持手动配置 peers 地址回退;附近发现的未配对设备可一键添加
  • 配对二维码 — 本机配对串画成二维码,多网卡地址可选,对方粘贴即自动填好设备 ID 与地址
  • 忽略规则 — 默认遵循目录内的 .gitignore(可按目录关闭),外加每目录一个 .syncxignore,排除不需同步的内容;带可视化编辑器与「粘路径测命中」测试器
  • 传输加密(链路) — Ed25519 密钥对 + 双向握手,Device ID 由公钥派生,防链路窃听
  • 目录级端到端加密 — 目录口令派生密钥,让某台对端「只存块、看不懂内容」(不受信备份 / 中转节点)
  • 传输限速 — 按目录限制发送带宽,并支持全局兜底;弱带宽 / NAS 场景不占满上行
  • 同步时段 — 每目录可设「只在某时段同步」(如夜间,支持跨午夜),时段外数据面停摆、到点自动恢复
  • 全局暂停 — syncx pause / syncx resume 一键停掉 / 恢复所有同步,备份维护窗口用
  • 按需同步(稀疏文件) — 对端新文件只记索引不落盘,小盘设备(手机 / Treebox)在文件管理器里点「下载」才拉块;可整目录开,也可只挑子目录
  • 接收模式与单文件暂停 — 残缺端只拉不推,防止本机不完整的状态被当成删除误伤对端;单个文件可双向冻结而不影响同目录其余文件
  • Git 提交同步 — 共享目录是 git 仓库时,一笔提交在几秒内变成对端的一笔镜像提交,链式拓扑末端也不漏(见「Git 提交同步」)
  • 磁盘空间守卫 — 接收前按本轮净需求量问一次磁盘,不够就整轮拦下并等空间恢复自动重放,不写到一半失败
  • 单文件优先同步 — 多个大文件排队时,对某个正在下载的文件点「优先同步」:本机带优先标记重发它的块请求,对端把对应块插到发送队列最前,直到该文件落地
  • 计费网络 / 电池感知 — Windows 上检测到按流量计费的网络或低电量自动挂起数据面,条件消失自动续传
  • 版本留档与时间机器 — 文件被覆盖前自动留档历史版本(份数可配),单文件版本时间轴、任意两版 diff、整目录一键回滚到某时刻
  • 回收站 — 同步侧删除与冲突残骸先落回收站再传播墓碑,可还原(可逆)或彻底删除
  • 冲突收件箱 — 冲突副本只留在生成它的那台机器上,收件箱里比差异、合并写回、保留本机版或丢弃
  • 浏览器内文件管理器 — 远程列目录、下载、按需文件落地、分享链接、删除(NAS 场景刚需),路径三级防越界
  • 浏览器内终端 — 独立 /terminal 页,xterm.js + node-pty 完整伪控制台(全屏程序 / 彩色 / 真 Ctrl+C),多会话后台保活;node-pty 不可用时自动降级为受限管道模式
  • 敏感操作验证门 — 终端、删除、还原、创建分享链接需再过一道账号密码 / 控制令牌,10 分钟滑动续期
  • 分享链接 — 给单个文件生成免登录、限时、只读的匿名下载链接,总开关默认关闭、一键止血
  • 同步事件 Webhook — 完成 / 冲突 / 错误三类事件推到 webhook(飞书群机器人自动识别并加签),补齐无人值守 NAS 的通知闭环
  • 桌面通知与角标 — 邀请到达 / 冲突 / 出错的系统浏览器通知 + favicon 数字角标,标签页标题联动
  • 传输统计与同步周报 — 顶栏实时累计与近 24h 双向柱状图;周报汇总近 7 天变更趋势、目录贡献与冲突
  • 多设备拓扑视图 — 本机居中的关系图 + 最近中转活动,看清「谁和谁在同步哪个目录」
  • 多实例集中管理 — 一个 UI 管多台远端 daemon(/fleet),经本机白名单代理,只看状态、只暂停 / 恢复
  • 差异报告 — 目录卡「差异报告」(或 syncx diff)现场取两端索引逐条比对,每条差异带结论而不只是路径(见「内容对比」)
  • 双栏对比与跨机改写 — 目录卡「对比」打开左右结构对齐的独立页,双击文件看两边内容级 diff,并能把某一边写进另一边(本机 ↔ 对端双向)
  • 版本一致锁 — 任何在线配对设备版本比本机新时,每一条路由都被不可关闭的升级弹窗盖住,杜绝混版本跑出诡异差异(见「升级与版本一致」)
  • 四种升级方式 — npm 全局、从对端 P2P 拉包、拖入本地安装包、npm 新版本横幅一键升级;换包失败自动回滚
  • Web UI — 浏览器查看状态、添加/移除共享目录、配对设备
  • 两档皮肤 × 深浅主题 — 板岩(实心)与液态玻璃(半透磨砂 + 极光底光 + 真折射),两个维度正交,首绘不闪白
  • 系统服务安装 — syncx install 生成 systemd / launchd / Windows 服务模板

快速上手(普通用户)

下面是最短路径:装好 → 启动 → 配对 → 开始同步。全程用 syncx 命令,不需要克隆仓库。

1. 安装与启动

要求 Node.js ≥ 22.13(内置 SQLite,无额外依赖)。

npm i -g @wangmingfa/syncx
syncx start

开发者 / 想跑源码:见下方「从源码运行(开发者)」。

首次启动会自动完成:

  • 生成设备身份与配置文件(~/.syncx/,内含 Device ID)
  • 启动 P2P 同步服务(默认端口 22000)——其他设备通过这个端口连你
  • 启动 Web UI(默认端口 8384,默认仅本机可访问)

日志会打印你的 Device ID(形如 GP72F7K72B)、Web UI 地址与访问令牌。保持终端运行即可; 想开机自启 / 后台常驻,用 syncx install 生成 systemd / launchd / Windows 服务模板(见「生产命令」)。

2. 用 Web UI 管理(推荐)

  1. 浏览器打开日志里的地址:http://127.0.0.1:8384
  2. 登录令牌:日志里有,或查看 ~/.syncx/control.token
  3. 嫌令牌难记?顶栏「登录密码」里设一个用户名 + 密码,之后就用账号登录。令牌不会被替代,而是降级为恢复通道 —— 忘密码时用令牌进来重设即可(详见「登录密码」)
  4. 状态页可完成日常操作:
    • 添加共享目录:填本机目录绝对路径,并记下自动生成的目录 ID(跨机同步对方须用同一 ID)
    • 移除共享目录:目录列表里删除
    • 设备配对:右栏粘贴对方设备 ID 即发起配对,对方网页弹出请求、点「确认」即双向生效;目录卡上把目录指派给某设备即发出共享邀请,对方点「确认」并选好本地路径即开始同步(详见「两台设备配对」)
    • 待确认:对方发来的配对 / 共享邀请会出现在页面顶部「待确认」区,确认或忽略都在这里
    • 全局设置:顶栏「全局设置」里配发送带宽兜底、版本留档份数、同步记录保留条数、Webhook、分享链接总开关
    • 查看对端连接状态与同步进度、手动触发扫描、手动重连某台对端
    • 顶栏两个下拉换外观:主题(跟随系统 / 浅色 / 深色)与皮肤(板岩 / 液态玻璃),两个维度互不干扰

状态页之外还有六个独立页面,都由顶栏或目录卡的动作条打开:

| 页面 | 打开方式 | 能做什么 | |---|---|---| | / 状态页 | 直接访问 | 日常全部操作 | | /compare/<目录 id> | 目录卡「对比」(新开标签) | 双栏对比:左右目录结构对齐,双击文件看内容级 diff,能把某一边写进另一边 | | /files?folder=<目录 id> | 目录卡「浏览文件」 | 远程列目录、下载、按需文件落地、单文件暂停、生成分享链接、删除 | | /trash?folder=<目录 id> | 目录卡「回收站」 | 列出本机回收站,还原或彻底删除 | | /terminal | 顶栏「终端」(新开标签) | 浏览器内完整终端,多会话后台保活 | | /fleet | 顶栏「多实例」(新开标签) | 一个 UI 管多台远端 daemon |

目录卡的动作条上另有四个弹窗:差异报告(实时取两端索引,按「冲突 / 待推送 / 待拉取 / 内容错位 / 被规则挡住」逐条给结论)、同步记录(该目录的历史事件)、版本(按路径分组的版本时间轴 + 两版 diff + 整目录回滚)、忽略规则(编辑 .syncxignore 并试粘路径测命中)。

想让局域网内其它设备也能打开 Web UI?启动时加 --host 0.0.0.0 --expose-control(详见「端口」一节)。

3. 两台设备配对(Syncthing 式)

每台设备先各自启动 daemon,记下自己的 Device ID(网页顶部,或 syncx status)。

syncx 采用类 Syncthing 的配对模型:粘贴对方设备 ID 即发起配对,对方网页点「确认」即双向生效; 把目录指派给某设备即发出共享邀请,对方网页点「确认」并选好本地路径即开始同步。

在 A(本机)上完成配对:

  1. 左栏「共享目录」添加要同步的目录,记下它的目录 ID(跨机同步对方须用同一目录 ID)。
  2. 右栏「设备」粘贴 B 的 Device ID → 点「添加设备」。B 的网页立刻弹出配对请求, B 点「确认」后双方互相把对方加入已知设备,配对完成。
  3. 回到 A 的目录卡,在「选择可同步此目录的设备」里勾选 B → B 的网页弹出目录共享邀请, B 点「确认」并填写自己机器上对应的本地路径后,该目录开始在两端同步。

邀请是实时推送的:双方 daemon 需同时在线(A 操作经已建立的连接推送给 B)。 若对方暂时离线,待其上线并互连后,本机会在连接建立时自动重发共享意图,对方上线即可看到待确认项。

方式二:手动配置(无 mDNS / 跨网段)(~/.syncx/config.json,格式见「配置」一节)

{
  "sharedFolders": [
    {
      "id": "docs",
      "path": "/home/me/Documents",
      "devices": ["对端A的DeviceID"]
    }
  ],
  "peers": ["ws://对端A的IP:22000"]
}
  • 双方目录配置相同的 id(如 docs),否则对端无法识别这是同一个目录
  • 每方的 devices 填对方的 Device ID
  • mDNS 不可用(跨网段/关防火墙)时,peers 填对方地址;同一局域网内一般自动发现,可不配

方式三:命令行邀请码(专家 / 无局域网发现)

仍可用 CLI 的 invite / join / revoke(见下方「配置」一节),适合完全无 mDNS、且不便于粘贴设备 ID 的场景。 该方式以签名邀请码交换白名单,与 Web UI 的配对/共享邀请并存、互不冲突。

4. 日常使用

  • 同步全自动:目录内文件变化几秒内传到对端,删除也会传播;局域网内无需手动操作
  • 冲突处理:双方同时编辑同一文件时,保留双方内容——本地版本存为 xxx.sync-conflict-时间-设备ID.ext 副本,远端版本落地,绝不静默丢数据
  • 忽略不需要同步的内容:目录里的 .gitignore 默认生效(卡片上可按目录关闭);也可放一个 .syncxignore(gitignore 语法,如 node_modules/、*.tmp),新建的忽略规则只影响之后的变化,不会被对端覆盖
  • 限制带宽:给目录加 "maxBandwidthKbps": 1024 可限制单个对端的发送速率,避免大文件占满局域网;也可在顶栏「全局设置」里设全局兜底(目录未单独限速时生效)
  • 定时同步:目录编辑里设「同步时段」(如 22:00-08:00,支持跨午夜),时段外该目录数据面停摆(卡片显示「时段外」徽标),到点自动恢复——适合 NAS / 备份机只在夜间同步
  • 临时全停:syncx pause 一键暂停所有同步(备份、维护时),syncx resume 恢复;连接与配对照常,Web 顶栏也有同样开关
  • 误覆盖可回滚:文件被覆盖前自动留档,目录卡「版本」里按路径列出历史版本(默认每路径 10 份),选择恢复即可;同一文件可勾任意两版对比,底部还能把整个目录回滚到某个时刻(先出计划再执行,当前内容一律先进版本 / 回收站)
  • 同步周报:目录栏头「同步周报」按钮看近 7 天的变更趋势、各目录贡献与冲突汇总
  • 按需同步(小盘设备):目录编辑里勾「对端新文件先不下载(占位)」,对端的非空文件只记索引不占本机盘;在文件管理器里点开单个文件(或整个子目录设成按需)才拉块落地
  • Git 提交同步:目录是 git 仓库时,目录编辑里把「Git 提交同步」设为「双向」,你在一台机器上的提交会在几秒后变成另一台机器上的一笔同样信息的提交(细节与重要边界见「Git 提交同步」)
  • 无人值守通知:顶栏「全局设置 → 同步事件 Webhook」填地址,同步完成 / 冲突 / 出错都会推过去(飞书群机器人地址自动识别并加签),旁边有「发送测试消息」
  • 手机 / 笔记本省流量:Windows 上可开「计费网络 / 低电量自动挂起」,条件消失自动续传;其余平台用目录的「同步时段」或 syncx pause
  • 某个文件等着用:目录卡「传输中」列表里那一行有个「优先同步」按钮,点一下对端就把这个文件的块插到发送队列最前(行上会标「已优先」)。只影响排队顺序,不影响正确性;文件落地后自动回到默认排队
  • 怀疑没同步成功? 目录卡上的「差异报告」按钮(或 syncx diff <目录路径>)会把两台设备此刻的内容逐条比一遍,每条差异都带结论而不只是路径;想知道「到底差在哪一行」再用「对比」打开双栏页,双击那个文件看内容级 diff。见「内容对比:两台设备究竟差在哪」

常见问题(FAQ)

| 问题 | 解决 | |---|---| | Web UI 打不开? | 默认仅监听本机。局域网访问需 --host 0.0.0.0 --expose-control 启动 | | 两台设备互相找不到? | mDNS 依赖同一局域网;跨网段或路由器隔离时,双方在 peers 互填 ws://IP:22000,并放行 UDP 5353 与 TCP 22000 | | 启动提示端口被占用? | --port 24001 改同步端口;--control-port 8385 改 Web UI 端口 | | 忘了自己的 Device ID? | syncx status | | 想临时停掉所有同步? | syncx pause(Web 顶栏也有同样开关),syncx resume 恢复。暂停只停数据面,连接、配对、Web 管理照常;各目录自己的暂停/时段设置独立保留 | | 目录卡显示「时段外」? | 该目录设了同步时段,当前不在时段内,数据面已暂停,到点自动恢复;想改或清除时段,在目录卡「编辑」里清空「同步时段」即可 | | 文件被覆盖了,能找回旧版吗? | 能。目录卡「版本」按路径列出留档的历史版本(覆盖前自动快照,默认每路径 10 份,顶栏「全局设置」可调),选择恢复即写回原路径 | | 想撤销已发出的 CLI 邀请码? | syncx revoke <邀请码>(仅对命令行邀请码有效;Web UI 的配对/共享可在对方确认前于「待确认」里忽略,或移除设备/取消目录指派) | | 想升级 / 卸载? | 升级有四条路径(见「升级与版本一致」):页面横幅走 npm 官方源、设备卡「升级到 v…」从对端 P2P 拉包、顶栏「上传升级」或拖 .tgz 进页面、命令行 npm i -g @wangmingfa/syncx@latest / syncx upgrade。换包失败会自动回滚旧版本。卸载:npm uninstall -g @wangmingfa/syncx(配置与数据在 ~/.syncx/,需手动删除) | | 忘了命令 / 想确认装的是哪个版本? | syncx --help 看全部命令与选项,syncx --version 看版本号 | | 令牌太长记不住 / 忘了登录密码? | 用令牌登录 → 顶栏「登录密码」设置账号密码;反之忘密码就用令牌登录进来重设(令牌是永久恢复通道) | | 对方没收到配对 / 共享邀请? | 邀请经实时连接推送,双方须同时在线且已互连(状态页该设备显示「在线」);离线时会于对方上线并互连后自动补发。先确认设备 ID 填写正确、且对方 daemon 已启动 | | 换电脑了,原来配对的设备怎么办? | 新设备按「配对」一节重新配对即可;旧设备 ID 不再出现在任何 devices 白名单中即断开 | | 目录卡一直显示「传输中」? | 正常情况下对端停止拉块后 15 秒内自动回到「已同步」。若两市计数恒定不变、且 syncx status 显示 pending 为 0,说明是索引回声(两端互推「对方没提到的条目」),升级到含 ADR-0010 的版本即可;旧版可先重启两端 daemon 止血 | | 怎么确认两台设备真的同步到位了? | 目录卡上的「差异报告」按钮(或 syncx diff <目录路径> [--device <设备ID>]):现场向对端要一份此刻的索引快照逐条比对,每条差异都给结论(待推送 / 待拉取 / 冲突 / 内容错位 / 被忽略规则挡住)。全程只读,不打扰任何一端,也不影响正在进行的同步。要看到具体差在哪一行,用「对比」开双栏页,双击文件看内容级 diff | | 对比报告里的「内容错位」是什么? | 两边版本向量相同、内容却不同。这是最强异常信号:版本相同意味着两端都不会再传这个文件,所以它会一直留在报告里,必须人工处理(通常是一方索引被错写、或文件被 syncx 之外的程序改动过)。正常运行的目录不该出现这一条 | | 页面状态多久刷新一次? | 变更即时推送(通常不到 1 秒),不需要手动刷新。推送通道用不了时自动退回 4 秒轮询,功能不受影响、只是不再实时(见「Web UI 的状态是推送的」) | | 我把某个目录加进 .gitignore 了,为什么同步记录里还会冒出它的条目? | 规则不回溯清理索引库:两端的库里都还留着旧行,对端每次声明它、本机这次丢弃它(旧版本会收下来覆盖你的文件,含 ADR-0012 的版本不会)。想彻底清掉,在两端各做一次「移除共享目录(含索引库)」再重新添加。另外别只看记录——syncx status 里的「索引条目 N」同样把被忽略的旧行算在内 | | 两台设备版本不一样会怎样? | 任何一条路由(/、/compare、/files、/trash、/terminal、/fleet)都会被一层关不掉的「需要升级」弹窗盖住,唯一的出路是从版本更高的那台拉包升级(见「升级与版本一致」)。这是刻意的:混版本跑出来的诡异差异极难归因 | | 为什么我这台没有弹强制升级弹窗? | 三种正常情况:① 本机是 dev 运行态(源码 / npm run dev),或本机与对端一个是 dev 一个是发行包 —— 跨形态不做自动升级,也就不锁;② 版本更高的那台当前离线(版本是上次握手学到的缓存,升级也需要对方在线),它一上线锁就落下;③ 你这一侧跑的是 0.3.2 及更早的发布物 —— 这把锁从 0.3.3 才随发布物分发,老版本里压根没这段代码,而锁不追溯,得先把那一侧升上来 | | 终端会话显示「受限模式」? | node-pty 原生模块没装上(单文件发行物不携带原生模块;Linux 上还要看版本——只有带 prebuilds/linux-* 的档才装得上,见下文「自升级」第 5 条),或者装上了却起不来(原生层缺件,如 spawn-helper 少了执行位)。受限模式是逐行管道:没有提示符、没有回显、Ctrl+C 靠重开会话、输出可能错位。用 Web UI 的「从对方升级 / 上传安装包」升级时会自动现装 node-pty 并补回那个执行位,装不上也只降级终端、不影响升级本身——为什么没装上会记在升级结果文件里并在 daemon 启动时打日志(装不上 / 版本范围被白名单挡下,各有文案),不会让你对着一个不闪的光标猜 | | 终端一直停在「连接中…」不动? | 不该再出现。这个卡死的根因是会话起不来时异常没人接(前端连「我就绪了」都收不到),现在两条起不来的路都兜住了:node-pty 起不来就退回受限管道模式照常可用,连管道 shell 都起不来就关掉这条连接并在 daemon 日志里写明原因([terminal] 会话异常终止 …)。真遇到就查那条日志,别猜 | | 每次开终端 / 删文件都要再验一次密码? | 敏感操作验证门的 cookie 有效期 10 分钟,且每过一次敏感请求就滑动续期;闲置超过 10 分钟(只听点击 / 键盘 / 滚轮,挪鼠标不算)会重新回门,终端页回门时还会回收全部会话 | | git 自动提交把我本地还没提交的改动一起卷进去了? | 是的,这是设计:接收端执行的是 git add -A + 一笔带原始提交信息的提交,所以工作树上未提交的改动会一起进那一笔。不想被卷,就在接收端也保持「改完即提交」;工作树完全干净时接收端会跳过提交,只推进基线 | | 对端少了一笔 git 提交通知? | 出站广播是逐目标记账并落盘的(daemon 重启也继续补投),但某个目标连续 5 次重发都没回执会被摘除并打一条 warn —— 对端跑的是没有投递回执(git-commit-ack)的旧版本时必然走到这一步。文件内容照常同步,只是提交记录少一笔,下一笔提交会覆盖台账槽 | | 盘快满了会怎样? | 接收前按本轮净需求量(已扣除将被覆盖文件将释放的体积)问一次磁盘:可用空间不足以覆盖它并再留 256MB 水位,就整轮拦下、不写到一半失败,空间恢复后自动重放被拦下的那轮索引 | | 手机 / 小盘设备不想全量落盘? | 目录编辑里勾「对端新文件先不下载(占位)」(整目录),或在文件管理器里只对某个子目录点「按需同步」。占位文件不占本机盘,在文件管理器里点「下载」才拉块;注意开关是惰性的 —— 关掉不会落地既有占位,也不会改动既有实体文件 | | 回收站会自己清理吗? | 不会。回收站(~/.syncx/trash/)没有容量或条数上限,要手动在回收站页「彻底删除 / 清空」 | | 想给别人一个临时下载链接? | 文件管理器里该文件的「分享」按钮,可设 1 小时 / 24 小时 / 7 天 / 30 天。分享链接总开关默认关闭,要先在顶栏「全局设置 → 分享链接」里打开;创建链接需过敏感操作验证门,撤销立即失效。关掉总开关时全部既有链接立刻 404,不留可达入口 | | 大文件传到一半 daemon 重启了,会续传吗? | 会。已收字节当场就躺在目标文件旁边的中间态里(<文件>.syncx-tmp 是在途内容,<文件>.syncx-partial 是它的块位图),重启后按位图逐个回读验哈希,只补缺的那几块;内容一变(哪怕只有一块)指纹就对不上,整对作废重传 —— 宁可重传,不可错接(见 ADR-0021)。这两个后缀对同步双向不可见,对端推来的同名文件也不会收。没人续的残骸默认 7 天整对回收 | | 传几个 G 的大文件会不会把 daemon 内存吃爆? | 两侧都不会再随文件大小增长。发送侧:块响应受在途字节窗口(默认 64MB)约束,请求一次全发过来也不会把整文件读进内存排队(见 ADR-0020;实测 600MB 文件在加这道闸门前的 A/B:发送端 2.27GB RSS 且 11 分钟不收敛,加完 0.96GB / 约 2 秒收敛)。接收侧:在途块不再攒在内存里,而是逐块写进中间态直到落地(见 ADR-0021)。同一份基准(loopback 双真 daemon + 进程内 RSS 采样)接收端峰值前后对比:600MB 1.34GB → 0.64GB,1.2GB 1.98GB → 1.16GB,2.4GB 1.26GB(文件翻倍只涨 0.1GB)。诚实边界:RSS 报的是峰值不是活集,每块的分配 churn 仍会推高 V8 水位 —— 活集由 test/receive-shape.test.ts(接收管线里不许出现块数组或 concat)与 executor 的 192MB 拼接守卫钉住 |

功能详解

Git 提交同步:让两端的提交历史也一致

文件同步只保证「内容一样」,不保证「提交历史一样」:A 上提交了三笔,B 拉回内容后这些改动是未提交的工作区状态,或者被 B 自己的一笔大提交吞掉。gitSync 补的就是这一层。

按目录配置(config.json 的 sharedFolders[].gitSync,或目录卡「编辑 → Git 提交同步」,改档即时热生效):

| 取值 | Web 上的名字 | 行为 | |---|---|---| | off(默认) | 关闭 | 完全不碰 git,不产生任何 git 命令调用 | | send | 仅发送 | 检测并广播本机提交,但不自动提交对端的通知(因此也不从中继) | | receive | 仅接收 | 收到通知时自动提交,但不广播本机提交、也不中继(只进不出) | | full | 双向 | 广播本机提交 + 自动提交对端通知 + 向其余对端中继 |

一轮完整流程:

  1. 发送侧每轮扫描(≤5 秒节拍)拿当前 HEAD 与落盘的基线 gitLastCommitHash 比对,不同即视为新提交,读出提交哈希、父哈希、完整提交信息、改动文件清单与 diffStat,广播给该目录的每一台对端;
  2. 接收侧收到后先回一条投递回执(git-commit-ack),然后只入队不提交——控制面的通知常常早于数据面的块传完,立刻提交会把「半套文件」定格成一笔提交;
  3. 等这笔内容的传输台账排空(健康传输不打断;死认领最多等 60 秒后放行并告警),才执行 git add -A + git commit -m <原始完整提交信息> --allow-empty。工作树干净则跳过提交,只推进基线;
  4. 真正产生了提交的设备会把原始通知(仍署最初提交者的设备 id 与哈希)中继给本机该目录的其余对端,所以链式拓扑(A—B—C,C 只与 B 配对)的末端同样会落一笔。中继只在真正提交时发生、接收方按原哈希去重,环路有界;镜像提交本身永远不会被当成「本机新提交」再广播出去。

可靠性与边界:

  • 逐目标记账:出站广播为每个目标各记一笔,拿到回执才摘除;台账落盘,daemon 重启后继续补投。目标此刻离线不消耗重发次数;某目标连续 5 次重发仍无回执则摘除并打 warn(对端跑没有 git-commit-ack 的旧版本时必然走到这一步)。
  • 停机期间的提交不丢:基线存在配置里而不只是内存,daemon 停机期间提交的 HEAD 重启后仍会被检出并补广播。
  • 改档即重置:切换 gitSync 会清空基线与两份台账,视为「重新启用」——从当前 HEAD 重新起线,绝不把启用前积压的历史提交一次性重放出去。
  • 必须是 git 仓库:非仓库目录发送侧静默不工作;git 命令缺失或失败只记进目录错误,不影响文件同步本身。
  • ⚠️ git add -A 会把接收端本机未提交的改动一起卷进那笔镜像提交。不想被卷,接收端也保持「改完即提交」。

冲突:三种策略与冲突收件箱

版本向量交叉(两边都改过同一个文件)时的处理策略,按目录配 sharedFolders[].conflictPolicy:

| 取值 | 行为 | |---|---| | keep-both(默认) | 拉回对端内容,本机那份存成 .sync-conflict-* 副本,进冲突收件箱等人工合并 | | newest-wins | 按双方 mtime 判新旧,新者胜出、覆盖另一端,不落冲突副本。mtime 经索引协议传给对端;任一侧缺 mtime(旧版对端 / 未扫描)自动退回 keep-both。⚠️ 跨设备比较依赖各机时钟,时钟漂移大的环境慎用 | | local-wins | 本机内容始终保留,不拉对端块;只合并版本向量后把本机条目推回对端,对端下一轮自动拉取本机内容收敛 |

三者都只作用于「真正的版本并发」——中转来的陈旧副本与接收模式的镜像覆盖仍走各自既有路径。

冲突副本的文件名是 <原路径去扩展名>.sync-conflict-<时间戳>-<对端设备ID><扩展名>,可无歧义反解出原路径与来源设备。它们硬忽略、不参与同步(副本是「本机当时输掉的那份」,流传出去只会让副本套副本自我繁殖),所以只留在生成它的那台机器上,由目录卡「冲突 N」徽标 + 冲突收件箱在本机处理:比差异、合并写回、保留本机版、丢弃进回收站、清理已无差异的残骸;处理完触发一轮扫描,结果沿正常同步路径传播给对端。

按需同步:小盘设备只记索引,点开才下载

手机 / Treebox / 小容量 NAS 的刚需:整目录 onDemand 打开后,对端推来的非空文件只记索引(含块哈希)为占位条目、不在本机落盘;在文件管理器里点那个文件的「下载」才拉块落地。空文件无盘可省,照常即时落地。

也可以只挑几个子目录:onDemandDirs(文件管理器里对某子目录点「按需同步」,卡片上标「按需」徽标)。它与整目录开关是「或」关系。

三条必须知道的语义:

  • 惰性:开关只影响之后的接收。打开不会把既有实体文件变成占位,关闭也不会落地既有占位——要实体请手动下载;
  • 占位条目不进线上协议、不在全量宣告中、不参与扫描墓碑——不谎称供得出内容,也不会因为「盘上没有」被判成本地删除广播出去;
  • 与目录级端到端加密同开时,加密通道优先(盲区端本就不收明文,无需再占位),即该目录的按需同步会被强制关闭。

接收模式与单文件暂停

  • 接收模式(receiveOnly,目录卡「接收」徽标):本机只从对端拉取变更、应用对端删除,绝不把本机的新增/修改/删除反灌出去。用在「完整端 ↔ 残缺端」:残缺端本机缺文件是常态,若允许它外推,缺失会被当成删除并把对端成批删掉。典型用法是在残缺端勾选,让它单向镜像完整端。
  • 单文件暂停(pausedFiles,文件管理器里对某文件点「暂停」,列表标「已暂停同步(双向冻结)」):该路径双向冻结——本机改动不外推、对端改动不落地(含对端删除),但文件保留在索引与盘上。恢复后下一轮索引交换自然收敛。与整目录暂停的区别:那是通道级停摆,这里只冻结列出的路径,目录其余文件照常同步。

目录级端到端加密:挂一台「只存块、看不懂内容」的节点

给目录设一个口令(scrypt 派生密钥),并把某些对端勾进「不可信节点」名单,这些设备就只能看到密文视图:

  • 路径被确定性加密成单个 base64url 段文件名——目录层级与真实文件名全部丢失;
  • 内容逐块 chacha20-poly1305 加密,宣告的块哈希是密文块自己的 SHA-256;
  • 盲区端永不回源:可信端整轮忽略它的索引宣告,并拒绝它的索引快照与内容请求(所以跨端对比、文件预览对盲区端不可用);
  • 口令本体永不落盘,派生密钥写进 config.json;密钥从不出 daemon(状态里只下发一个「已设置」标志)。

入口是目录卡上的挂锁图标。代价与边界要清楚:该目录的按需同步被强制关闭;对盲区端不做冲突裁决;变长分块(CDC)在加密通道上忽略;泄露路径长度、文件大小(每块 +16 字节)与修改时序,同一密钥流恒定,理论上可被 XOR 比对;重连需全量重算密文视图。口令遗失基本等于数据丢失——唯一的救命通道是内部 recoverFile(凭口令 + 盲区盘还原),目前没有 CLI / UI 入口。

它与「传输加密」是两回事:Ed25519 + 双向握手防的是链路窃听,对端落地后看到的仍是明文。

版本时间机器与回收站

  • 版本留档:文件被对端覆盖修改前自动快照旧内容,默认每路径保留 10 份(顶栏「全局设置」可调),存 ~/.syncx/versions/<hash>/。目录卡「版本」里是按路径分组的版本时间轴,可勾选任意两个版本直接 diff,也可恢复单个版本;底部能把整个目录回滚到某个时刻——先出计划再执行,当前内容一律先进版本 / 回收站,所以可反悔。
  • 回收站:同步侧的删除、冲突残骸、回滚让出的文件都进 ~/.syncx/trash/<hash>/,文件名是 <相对路径>.<时间戳>,由名字反解原路径,不额外落元数据。目录卡「回收站」页可还原或彻底删除;还原是可逆的——若原路径已有文件,会先把现有那份挪进回收站再写回,然后触发一轮扫描以新版本广播对端。回收站没有自动清理(不像版本留档有份数上限),要清得手动。
  • 文件管理器里的「删除」是硬删(本机不留副本),删除会作为墓碑传播给对端。

浏览器内文件管理器与分享链接

/files?folder=<目录 id>:列目录(目录在前、按名排序,超 2000 条截断)、面包屑进子目录、下载单个文件、按需占位文件的「下载」、单文件暂停 / 继续、子目录「按需同步」开关、删除(需过敏感操作验证门)。共享目录本身不允许删除。

路径防越界是三级,一道比一道深:① 拒绝含 NUL 的路径,反斜杠归一后不得以盘符或 / 开头;② resolve 之后必须仍在共享根内(挡 ../);③ realpath 之后必须仍在共享根的 realpath 内(挡目录里那条指向外面的软链)。

分享链接:给单个文件生成免登录、限时、只读的匿名下载链接,形如 http://host:port/s/<令牌>.<过期时间戳>.<签名>。有效期白名单 1 小时 / 24 小时 / 7 天 / 30 天(杜绝「分享一百年」的手滑)。总开关默认关闭(shareEnabled),关闭时全部既有链接立刻 404——一键止血,不留可达入口;创建链接属敏感操作要过验证门,撤销立即失效;匿名端点另有同 IP 失败限流,令牌无效一律同一个 404 语义,不给爆破者区分「不存在 / 过期 / 撤销」的信息。

浏览器内终端与敏感操作验证门

/terminal 是独立页面(顶栏「终端」新开标签——终端是长驻工作区,不该顶掉正在看的状态页):xterm.js + node-pty 真伪控制台,支持全屏程序、彩色输出、真实 Ctrl+C;侧边栏多会话,可新建、双击重命名、删除,切走的会话后台保活。

node-pty 是原生模块、不随单文件发行物分发,拿不到时自动降级为受限管道模式(TERM=dumb):没有提示符与回显、输入按行补齐换行、Ctrl+C 靠重开会话、输出可能错位,会话状态会写明「受限模式」。装上了也可能起不来 —— 原生层缺件(实测是 npm 提取丢掉 spawn-helper 的执行位)要到 pty.spawn 那一步才抛,这一样回退受限模式,而不是让页面停在「连接中」:每条连接要么收到 ready,要么被服务端关掉,两种都会打日志。降级不是静默的。服务端闲置 10 分钟回收会话、单会话绝对上限 1 小时。

哪些操作要再过一道门:开终端、文件管理器删除、回收站还原 / 彻底删除 / 清空、创建分享链接。验证方式仍是账号密码或控制令牌两条通道,签发的是一枚独立签名的 cookie(syncx_su,10 分钟):登录 cookie 冒充不了它,每次敏感请求放行即滑动续期。前端的活动计时只听点击 / 键盘 / 滚轮(挪鼠标不算活动),闲置超限就重新回门并回收终端全部会话。只读操作(列目录、下载、列回收站)只需登录,不额外问。

通知与统计

  • 桌面通知 + favicon 角标:待确认邀请、新冲突、目录错误三类「注意力」计数上涨的边沿触发系统浏览器通知,favicon 叠数字角标,顶栏铃铛可关。标签页标题同步联动(默认「SYNCX · 主机名 · IPv4」,有邀请时改成邀请标题,多条显示「等 N 条邀请」)。
  • 传输统计:顶栏「流量」按钮直接把累计收 / 发当标签用,点开是近 24 小时双向柱状图(上=发出、下=接收,同一峰值尺度)。账本是内存态:5 分钟一格 × 288 格,daemon 重启清零。
  • 同步周报:目录栏头入口,近 7 天变更趋势、各目录贡献、冲突汇总。
  • 拓扑视图:本机居中,同一目录的设备两两连边(边上标目录名),节点标在线 / 离线;下方列「最近中转活动」(最近 20 条),对应 ADR-0014 的接收即中转。

Webhook:无人值守 NAS 的通知闭环

顶栏「全局设置 → 同步事件 Webhook」填地址与可选密钥,三类事件会 POST 过去:completed(某目录传输从活跃落到全零)、conflict(每条冲突落地)、error(目录错误,文案变化才推一次,不刷屏)。载荷是 JSON.stringify({kind,ts,folderId,folder,path,deviceId,message});配了密钥就带 x-syncx-timestamp 与 x-syncx-signature(HMAC-SHA256,签 ${ts}.${body})。

地址是飞书群机器人(*.feishu.cn / *.larksuite.com / 路径含 /bot/v2/hook/)时自动换成飞书的 text 载荷并按飞书算法加签,不需要你区分处理。旁边有「发送测试消息」按钮可当场验通。投递不重试——串行队列保序 + 5 秒超时,失败只打一条 warn;地址与密钥改完即生效,不用重启。

磁盘空间守卫与电源 / 计费网络守卫

  • 磁盘守卫:每轮接收规划完成后,把净需字节数(已扣除将被覆盖的本地文件将释放的体积)问一次所在盘:可用空间必须同时覆盖它并留下 256MB 水位,否则整轮拦下而不是写到一半失败;被拦下的那轮索引原文留着,空间恢复后自动重放(再次不足则继续等)。
  • 电源 / 计费网络守卫(仅 Windows,默认关闭):顶栏「全局设置 → 计费网络 / 电池感知」可开「检测到计费网络时自动挂起」与「用电池且电量过低时自动挂起」(阈值 1–99,默认 20%)。daemon 每分钟用一次 PowerShell 探针读 WinRT 的联网成本类型与电池状态;命中就走独立的挂起通道,不污染你手动的全局暂停(两者互不覆盖),目录栏会显示「自动挂起」原因徽标;条件消失自动恢复。探针失败维持上一次判定。非 Windows 平台不启用探测。

配对二维码与附近设备发现

  • 本机二维码:设备栏「本机二维码」把配对串画成二维码,格式 syncx://pair?v=1&d=<设备ID>&h=<host>&p=<端口>;多网卡时每个地址一个按钮,点选即换地址重新生成。对方在自己的「添加设备」里粘贴这串(或扫出来后的文本)即自动拆出设备 ID / 主机 / 端口填好表单——Web UI 不接摄像头扫码,扫码由手机系统相机完成。二维码编码器是仓库内手写的极简实现(字节模式、纠错 L、版本 1–4),不引第三方库。
  • 附近发现的设备:mDNS 广播 / 查询 _syncx._tcp.local,扫到未配对的邻居列入设备栏下方,「添加」一键带上它学到的 ws://host:port 直连地址。发现记录 3 分钟过期,已配对的自动剪掉。

多实例集中管理(/fleet)

一个 UI 管多台远端 daemon(不是本机多进程):实例 = 控制地址 + 控制令牌,注册表存在浏览器本地(localStorage),不写进任何 daemon 的配置。跨实例的请求经本机 daemon 的白名单代理转发,只透出三件事:看对方状态、全局暂停 / 恢复、单目录暂停。代理不开任意 URL 转发,也不透出对方的令牌。

界面:两档皮肤 × 深浅主题

顶栏两个下拉各管一个维度,正交:主题(跟随系统 / 浅色 / 深色)与皮肤(板岩 · 实心不透明 / 液态玻璃 · 半透磨砂),四组组合都成立。选择存在浏览器本地(syncx:theme / syncx:skin,默认档不落盘),index.html 的首绘内联脚本用同一个键提前挂好属性,所以刷新不闪白。

液态玻璃档不是「把颜色调透明」:板面是半透的、靠 backdrop-filter 磨砂,底下有一层会缓慢漂移的极光底光(尊重系统「减弱动态效果」设置),弹窗与吐司另吃一档更实的「抬起面」令牌(垫在暗遮罩上不能混成脏灰板);不支持 backdrop-filter 的浏览器经 @supports 退回近实底,能力探测通过时还会挂一层 SVG 位移滤镜做边缘真折射。

架构

                     ┌─────────────┐
                     │  Device A    │
                     │  identity    │
                     │  index.db    │  SQLite 元数据(node:sqlite)
                     │  executor    │  文件落盘/删除/冲突副本
                     └──────┬──────┘
        mDNS / peers       │   WebSocket + mTLS
   ┌───────────────────────┼───────────────────────┐
   │                       │                       │
┌──┴──────┐           ┌────┴────┐            ┌─────┴────┐
│Device B │  ...       │Device C │   ...      │  Web UI  │
│  对等    │           │  对等    │            │ 控制 API │
└─────────┘           └─────────┘            └──────────┘

索引交换:全量与会话内的增量

同步的单位不是文件而是索引条目(路径 + 版本向量 + 块哈希)。index 消息有两种语义, 必须分清(混淆过一次,见 docs/adr/0010):

  • 全量(full: true):会话建立时双方互发,并集语义——「本机有、消息里没有」确实 意味着对端缺失,应当推过去。这是初次同步能收敛的前提。
  • 增量:扫描器发现改动时只广播那几条,只判定消息里提到的路径——「消息里没有」只说明 对端这轮没提,不等于它没有。

把增量当全量,两端就会交替回推「对方没提到的条目」:不落盘、不记历史、不打日志,只有网卡和 CPU 知道(实测 400ms 内往返两万余条)。所以 index 帧带 full 字段,缺省按增量处理—— 旧版对端不发这个字段,而它确实会发增量。

卡片进度同源:接收 = 正在等块落地的文件数;发送 = 对端正向我拉块的文件数(15 秒租约,对端 停止拉块即自动归零)。两者都为 0 时显示「已同步」,不需要任何人工干预来清账。

同一台设备的两条连接:发送去重与接收单飞

设备对之间至多两条连接(每个方向各主动拨一次,这是设计内的),每条连接各挂一个 SyncPeer。于是同一份增量会在两条管线上各到一次,两条管线并发比对同一份旧 localIndex、 各自判定要收、各自落地。第二次通常覆盖第一次的同内容,平时无感;但若两次落地之间本机恰好有 真实编辑,第二次会把编辑打回远端旧内容(被覆盖的编辑有 snapshotVersion 存档,但索引已一致, 扫描器看不出差异 —— 静默永久分叉,全程无报错)。两道闸门:

  • 接收侧目录级单飞台账(peer.ts 的 receiveLedger):规划 receive/conflict 之前先按 「路径 + 版本指纹」认领;同版本已被另一条管线认领(保鲜期 5 分钟内)就跳过。落地 / 中止 / 放弃 / 会话拆除都会交还认领 —— 最后一条很关键:死掉的管线若不交还,活连接对同一版本会 一直跳到保鲜期过,一次普通断开就变成分钟级的收敛延迟。
  • 发送侧按设备去重(broadcast.ts / relay.ts):同一设备的两条 transport 只发一份增量, 来源设备的反向连接不再收到中转回声。

接收侧是主闸门,所以新旧版本可以混跑(旧对端不认认领协议也无妨,是本机自己在拦)。

Web UI 的状态是推送的

状态页不轮询 GET /api/status,而是连一条 WS /api/events:服务端在变更时推一帧完整状态 (设备上下线、邀请到达、目录错误、扫描结果、配置热重载),连上后立即先给一帧 —— 打开页面 不需要等任何一次轮询,空闲时一帧都不发(见 docs/adr/0011)。

  • 变更在 250ms 窗口内合并;另有 5 秒兜底重算(状态没变则不推)。于是「漏了某个通知点」的 表现只是「晚几秒」,而不是「界面永不更新」。
  • 没有任何客户端连接时,服务端的定时器全部不存在 —— 空闲 daemon 零开销。
  • 一个标签页里可能有两条这样的连接:状态页自己一条,页面之外的版本一致锁宿主一条 (web/components/UpgradeLockHost.vue,它要在 /terminal 等不渲染状态页的路由上也拿到 status)。 多这一条不改变上面的结论 —— 扇出按连接走、且只在状态真的变了时发帧。
  • WS 建不起来时自动退回 4 秒轮询,连上即停。若在控制端口前挂了反向代理,必须转发 Upgrade(nginx:proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";), 否则页面会静默退回轮询 —— 功能正常,只是不再实时。
  • 鉴权与其它控制端点一致(同源 cookie 或 Bearer token);未通过的握手返回 401。

内容对比:两台设备究竟差在哪

排查「同步是不是坏了」时,结论比数据重要。目录卡上的「差异报告」(或 syncx diff <目录路径> [--device <设备ID>])会现场向对端要一份它此刻的索引快照,与本机逐条比对,给出每条差异 的判定(见 docs/adr/0013)。

| 分类 | 判定依据 | 该怎么读 | |---|---|---| | 内容错位 | 版本向量相同,但大小或内容摘要不同 | 最强的异常信号,正常运行绝不该出现:版本相同意味着两边都不会再传,这个错位会一直留着,得人工处理 | | 冲突 | 版本向量交叉(两边都改过) | 看 .sync-conflict-* 副本,两边内容都在 | | 本机较新 | 本机版本更高 | 该推给对端;长期不变 = 推送卡住了 | | 对端较新 | 对端版本更高 | 该拉回来;若本机是接收模式则属正常 | | 对端被忽略 | 只有本机声明,且命中对端的忽略规则 | 不是故障,报告里会附上命中的规则原文 | | 本机被忽略 | 只有对端声明,且命中本机的忽略规则 | 不是故障(见「忽略是双向的」) | | 一致 | 版本相同且内容摘要相同 | 折叠成计数,不逐条列出 |

几点刻意的设计:

  • 全程只读:不重新扫描、不改版本向量、不写盘,也不靠「强制重连触发全量交换」——诊断工具不该 扰动被观察的系统,否则你看到的是自己造成的现象;
  • 对端离线就报错,不拿会话建立时那份内存镜像糊弄:那份只在连接那一刻准确;
  • 文件时间只展示、不参与判定(FAT/NTFS 精度与时区差异会造成大量假阳性);
  • 差异条目会在本机再 stat 一次,把「索引陈旧(数说有、盘上没了)」与真差异分开;
  • 旧版本对端不认这条请求时超时报错,报告里也会标注 remoteRulesKnown: false(这时无法判断 「差异是不是规则使然」,只能如实降级);
  • 只比文件:索引不记录目录,空目录不在范围内;
  • 报告会标出双方的设备身份(设备 id、主机名、IP):设备 id 是随机串,多台设备或 IP 变动时 靠它认不出「这是哪台机器」;对端的主机名与地址取的是本机记录的那份,与设备卡同源,不会两处不一致;
  • 推送的忽略规则取的是当前生效的那份(点一次拉一次),不是连接建立时的快照。

服务端在回快照前会校验共享关系:该目录没有把请求方列进 devices 就回一条错误,否则这条诊断 通道会变成绕过共享关系的索引读取入口。

报告回答「哪些文件不一样、属于哪一类」,回答不了「到底差在哪一行」。所以目录卡上另有一个 「对比」按钮打开双栏对比页(/compare/<目录 id>,见 docs/adr/0015):左右两侧目录结构对齐, 双击任意文件拉出两边的内容级 diff,并且能把某一边写进另一边 —— pull 把对端内容落到本机、 push 把本机内容写给对端(也可以只把 diff 窗口里编辑过的内容写过去)。这是全套诊断入口里唯一 会写盘的一个,前一份报告仍是只读的。

安装

要求 Node.js ≥ 22.13(内置 node:sqlite 无需额外依赖)。

全局安装(普通用户)

npm i -g @wangmingfa/syncx     # 安装 / 升级
syncx start                    # 装完即可用,无需克隆仓库
npm uninstall -g @wangmingfa/syncx

全局安装后命令名就是 syncx,下文所有 node dist/syncx.js … 都可写成 syncx …。

从源码运行(开发者)

改代码、跑测试、或用未发布的功能时才需要:

git clone <repo-url> && cd syncx
npm install
npm run build          # 编译到 dist/
node dist/syncx.js start

日常开发用 npm run dev(热重载,见「开发命令」),只在验证生产产物时才 npm run build + node dist/syncx.js。

配置

配置文件默认在 ~/.syncx/config.json(用 --config <路径> 指定其他位置,或 --config-dir <目录> 整体换数据目录——开发环境即用它隔离,见「开发命令」)。

{
  "sharedFolders": [
    {
      "id": "docs",
      "path": "/home/me/Documents",
      "devices": ["DEV1234567", "DEVABCDEFG"],
      "maxBandwidthKbps": 1024,
      "schedule": "22:00-08:00",
      "useGitignore": true,
      "gitSync": "full",
      "conflictPolicy": "keep-both",
      "receiveOnly": false,
      "onDemand": false,
      "onDemandDirs": ["archive/"],
      "pausedFiles": ["big.iso"],
      "paused": false
    }
  ],
  "peers": ["ws://192.168.1.10:22000"],
  "paused": false,
  "maxSendKbps": 2048,
  "versionsPerPath": 10,
  "historyMaxEvents": 2000,
  "shareEnabled": false,
  "webhookUrl": "",
  "webhookSecret": "",
  "pauseOnMeteredNetwork": false,
  "pauseOnLowBattery": false,
  "batteryPauseThreshold": 20
}

绝大多数配置项都能在 Web UI 里改(目录卡「编辑」、顶栏「全局设置」),手改 config.json 会被热重载,无需重启。下面按「谁在用」分组列全。

共享目录字段(sharedFolders[*])

  • path — 要同步的本地目录绝对路径(必填)
  • devices — 允许与该目录同步的设备 ID(来自对端 syncx status)
  • id — 跨设备约定同一目录的目录 ID,wire 消息用它在同一连接上区分目录。缺省回退为 path,所以跨机同步必须双方填同一个 id
  • maxBandwidthKbps — 限制向单个对端的发送速率(KB/s);未配置时回退全局 maxSendKbps,0 或不设置 = 不限速
  • useGitignore — 是否把目录内 .gitignore 并入忽略集,缺省 true = 遵循;.syncxignore 优先级更高,可用负向规则覆盖
  • schedule — 同步时段 HH:MM-HH:MM(支持跨午夜);留空 / 非法 = 全天同步;时段外该目录数据面停摆,连接与配对照常,到点自动恢复。格式非法按全天处理,不会因笔误把目录锁死
  • paused — 该目录单独暂停(目录卡开关)。数据面停摆:不扫描、不广播、不挂传输通道(入站变更也因此被忽略);控制面照常
  • receiveOnly — 接收模式(只读):只从对端拉取变更、应用对端删除,绝不把本机的新增/修改/删除反灌出去。用于「残缺端镜像完整端」,防止本机把不完整状态当删除广播、误删对端文件。缺省 false = 双向
  • gitSync — Git 提交同步:off(缺省)/ send / receive / full,语义见「功能详解 · Git 提交同步」
  • conflictPolicy — 版本并发时的自动策略:keep-both(缺省)/ newest-wins / local-wins,语义见「功能详解 · 冲突:三种策略」
  • onDemand — 按需同步:对端推来的非空文件只记索引不落盘,文件管理器点「下载」才拉块。缺省 false
  • onDemandDirs — 选择性同步:这些目录前缀下的对端非空文件按 onDemand 同款处理;目录级 onDemand=true 时本字段多余,二者是「或」关系
  • pausedFiles — 单文件暂停:列出的相对路径双向冻结(本机不外推、对端不落地,含删除),文件仍在索引与盘上,恢复后下一轮索引交换自然收敛
  • remote — 标记该目录来自「接收对端邀请」而非本机添加,仅用于展示区分

顶层字段

  • peers — 手动对端地址(mDNS 不可用时的回退),默认 []
  • paused — 全局暂停(syncx pause / resume):所有目录数据面一起停摆,各目录自己的 paused 独立保留
  • maxSendKbps — 全局发送带宽兜底(KB/s):目录未单独限速时生效;缺省不限速
  • versionsPerPath — 每路径版本留档份数,默认 10(最小 1),超出删最旧
  • historyMaxEvents — 每目录同步记录保留条数,默认 2000
  • shareEnabled — 分享链接总开关,默认关闭。关掉时在册链接立即全部 404,不残留可达入口
  • webhookUrl / webhookSecret — 同步事件(完成 / 冲突 / 错误)推送地址与签名密钥;飞书群机器人地址自动按飞书格式加签。缺省 / 空 = 关闭
  • pauseOnMeteredNetwork — 检测到按流量计费的联网时自动挂起数据面,仅 Windows 生效
  • pauseOnLowBattery — 用电池且电量低于阈值时挂起数据面,仅 Windows
  • batteryPauseThreshold — 低电量阈值(%),1–99,缺省 20
  • knownDevices / pendingOffers / shares — 已配对设备、待确认项、在册分享链接,由 Web UI 与 daemon 维护

这些字段是机器记账用的,别手改

sharedFolders[*] 里还有一批 daemon 自己写入的字段,手改会破坏同步正确性,列出来是为了你知道它们不是笔误:

| 字段 | 作用 | |---|---| | instanceId | 本机目录实例 ID,索引库按它命名 —— 目录被移除再添加时开一个全新空库,不可能继承上一轮的旧条目/旧墓碑(否则磁盘上已消失的旧条目会被判成「本地删除」广播出去,成批删掉对端文件) | | folderIdentity | 共享根的身份指纹(dev + ino),区分「盘没挂载 / 目录被整体清空」与「用户真删光了」。只在首次纳入时采集,缺了绝不补采 | | gitLastCommitHash | 上次观测到的 HEAD,让 daemon 停机期间的提交重启后仍能被检出并补广播 | | gitRelayPending / gitBroadcastPending | 逐目标的提交通知台账,离线对端上线后接着补投(回执到达才摘除) | | e2eKey / e2eUntrusted / e2eKeySet | 目录级端到端加密的密钥记录与盲区名单;口令经 scrypt 派生,明文永不落盘 | | markerChecked | 旧版标记文件标志,已废弃,仅迁移时清理 |

全局设置三项与目录的时段 / 限速 / 各项开关都可以在 Web UI 里改(顶栏「全局设置」、目录卡「编辑」)。

忽略规则:见下方「忽略规则(.syncxignore)」一节。

忽略规则(.syncxignore 与 .gitignore)

排除不需要同步的内容有两种来源,语法都与 gitignore 一致:

  1. .gitignore(默认启用) — 目录里已有的 .gitignore 自动生效:其中命中的文件/目录不参与同步。每个目录卡片上有「忽略 .gitignore 中的文件」开关,默认勾选;取消勾选后 .gitignore 仅保留给 git 用,syncx 会同步其中列出的文件。
  2. .syncxignore — syncx 专属的忽略文件,优先级高于 .gitignore(后读的规则可覆盖先读的),适合写同步专属规则或用 ! 负向规则豁免 .gitignore 里命中的文件。

在共享目录根下放一个 .syncxignore 文件即可排除不需要同步的内容。

基本用法

# 注释行
node_modules          # 任意层级下的 node_modules 目录(扫描时整棵剪枝)
*.log                 # 无斜杠模式:匹配任意层级的文件名
/tmp/                 # 前导斜杠 + 尾斜杠:仅根下的 tmp 目录
build/**/*.map        # 含斜杠模式:按完整相对路径匹配
!important.log        # 取反:从之前的忽略中豁免

规则语义:

| 写法 | 含义 | |---|---| | # 开头 | 注释,跳过 | | ! 开头 | 取反(豁免),后写的规则覆盖先写的 | | * | 匹配任意字符,但不跨 / | | ** | 跨任意层级(须紧邻 /,如 **/foo、a/**) | | ? | 匹配单个非 / 字符 | | 模式含 / | 按完整相对路径匹配;否则按文件名在任意层级匹配 |

行为说明

  • 每设备本地生效:规则文件不会同步给对端,各设备可维护各自不同的规则(想让两端一致,需各放一份相同内容);
  • 热加载:修改 .gitignore / .syncxignore 或切换目录卡片上的忽略开关后自动生效,无需重启 daemon;
  • 可视化编辑与实时测试:目录卡「忽略规则」里直接编辑 .syncxignore,旁边有测试器——粘一个相对路径(以 / 结尾按目录测)即回答「按当前草稿(不用先保存)它会不会被忽略、由哪条规则决定、是硬忽略还是可解除」。规则命中顺序复杂时,这比反复保存试错快得多;
  • 忽略是双向的(本机说了算):规则同时挡外推与入向 —— 命中路径本机不扫描、不外推,对端推来的新增/修改/删除也一条都不应用(见 docs/adr/0012)。所以两端可能为同一条被忽略的路径安静地保有各自的内容,这是预期行为,不是「没同步成功」;
  • 新增规则只影响之后的变化,也不会回溯清理索引:已同步的文件不会因为你后来才 ignore 就从对端消失,但本机索引库里的旧行仍在(「索引条目 N」会因此偏大,内存里真正参与同步的那份已经过滤干净)。想让数字干净、并让对端不再声明这些路径,需在两端各做一次「移除共享目录(含索引库)」再重新添加;
  • 已有的墓碑仍会外推:本机在加规则之前删掉的文件,其删除仍会传给对端(避免对端永久留下一份已删文件);反方向不成立——对端删了被忽略的文件,本机不再跟着删;
  • 被忽略的目录会整棵跳过:扫描时命中目录规则即不深入,其下内容不参与同步。匹配语义以 git 为准(git check-ignore 判什么就判什么):不带斜杠的模式(node_modules/、dist/)匹配任意层级的同名目录,带斜杠的模式(admin/dist/)相对共享根定位,目录规则里的通配符(**/tmp/)同样成立(见 docs/adr/0016);
  • 有内置默认忽略(见下方「硬忽略」):.git、.hg、.svn、.syncx-trash、.syncx-folder 默认都不参与同步;其余隐藏文件、系统垃圾文件正常同步,需要排除请写入 .gitignore 或 .syncxignore;
  • .syncxignore 自身会被当作普通文件同步,如不想传播规则,在文件里加一行 **/.syncxignore。

硬忽略(VCS 目录、同步元数据与冲突副本,不可解除)

.git、.hg、.svn、.syncx-trash、.syncx-folder 这五个名字,以及冲突副本(文件名形如 xxx.sync-conflict-<时间>-<设备ID>.ext)在任何配置下都不会被同步,.gitignore / .syncxignore 里写 !.git 也解不开:

  • 扫描时整棵剪枝,并且不为它们生成墓碑(不会把「本机没有」变成「对端删除」);
  • 本机索引里的历史条目与外发条目一律过滤,不传给对端;
  • 接收侧同样拦截:对端推来的这些条目一条都不收(活条目与墓碑都不收),对端版本旧、或对端把规则负向覆盖了,都推不进来;
  • 真正落盘的文件操作也会拒绝这些路径,这是最后一道闸门。

冲突副本为什么也硬忽略:它是「本机当时输掉的那份内容」,属于设备本机的恢复残骸,对别的机器没有意义。若参与同步,多设备间副本会互相流传、副本撞副本再套娃出双层命名,风暴自我繁殖。所以副本只留在生成它的那台机器上,由目录卡的「冲突 N」徽标 + 冲突收件箱在本机处理(收件箱直接扫盘,不依赖索引)。

原因:双向同步下,一端如果因为任何原因把 .git 当成普通目录,缺失的文件会被判为「已删除」并生成墓碑广播出去,把对端完好的 .git 删空(git 仓库报废,见 docs/adr/0008)。这类损坏不可逆,所以做成不可配置的硬约束,而不是默认值。

判定按路径段、不分大小写:.github/workflows/ci.yml 不受影响(段是 .github),.GIT/config 会被拦下。冲突副本按命名模式(设备 ID 段是 10 位 base32)精确匹配,普通文件名里带 sync-conflict 字样不会被误伤。

syncx 不会往你的共享目录里写任何文件

共享目录只承载你自己的文件。syncx 的全部运行期状态都在配置目录(~/.syncx/,可用 --config 改)里:

| 数据 | 位置 | |---|---| | 索引库 | ~/.syncx/index-<hash>.db | | 目录身份指纹 | ~/.syncx/config.json 的 sharedFolders[].folderIdentity | | 删除回收站 | ~/.syncx/trash/<hash>/ | | 版本留档 | ~/.syncx/versions/<hash>/ |

所以共享目录用 git 管理时,git status 不会被 syncx 弄脏。两个唯一的例外:

  • 传输进行中的中间态 *.syncx-tmp(在途内容)与 *.syncx-partial(它的块位图):必须与目标文件同目录才能靠 rename 原子落地,而断点续传要求它们一直躺到该文件收齐(大文件按分钟计,期间 git status 会把这一对报成未跟踪)。但它们不会进版本库:自动提交的 git add -A 与「工作树有没有待提交内容」的判定都用负向 pathspec 排除这两个后缀,免得一笔为别的文件触发的提交把几百 MB 的半截内容写进历史、再经 git 提交同步镜像给对端。落地即改名消失;断线 / 崩溃留下的残骸由扫描器按 7 天 TTL 整对回收(见 ADR-0021)。
  • 升级前的遗留痕迹:旧版本会在共享根写 .syncx-folder 标记文件、.syncx-trash 回收站目录。升级后启动时会自动清理前者、把后者的内容搬到 ~/.syncx/trash/ 再移除空目录(搬不动的文件会保留原目录,绝不丢数据)。

「盘未挂载 / 目录被整体清空」与「用户确实删光了文件」的区分,靠的是记录在配置里的目录身份指纹(共享根的 dev + ino),不再是共享目录里的标记文件。指纹比标记文件更强:换盘、重新挂载、目录被删了重建都会改变 dev/ino,而「同一个路径上换了另一块盘」标记文件根本认不出。极少数文件系统(部分网络盘)不提供 inode,此时退化为结构守卫——只有「一个文件都没扫到、却要删东西」这种极端形态才会被拦下。

从 Syncthing 迁移

.stignore 的 gitignore 部分可直接复用,但 (?d) 前缀不被支持——它会被当成模式的一部分导致规则静默失效,迁移时请剥掉该前缀;目录规则不要加尾斜杠(syncx 的目录规则不支持通配符,加尾斜杠会使规则失效,保持 **/dir 形式即可)。

登录密码

Web UI 默认用 ~/.syncx/control.token(48 位随机串)登录。令牌难记,所以支持在设过一次之后改用账号密码:

  1. 用令牌登录 → 顶栏「登录密码」→ 填用户名(≥1 字符)与密码(≥6 位)→ 保存
  2. 之后登录页直接显示用户名 + 密码框,不再需要令牌

规则与边界:

| 项 | 说明 | |---|---| | 恢复通道 | 令牌始终有效,不因设置密码而失效。忘记密码就用令牌登录进来重设 | | 密码存储 | scrypt 加盐派生后写入 ~/.syncx/auth.json(0600),不存明文 | | 会话 | 登录后签发 HMAC 签名的无状态 Cookie(24h),daemon 重启不掉线 | | 改 / 清密码 | 会让所有已登录页面重新登录(签名密钥随密码哈希变化)。清除后退回令牌登录 | | 爆破防护 | 同一来源 IP 连续失败 5 次锁定 60 秒 | | 安全边界 | 密码是便利而非加固:能读 ~/.syncx/ 的人照样能读令牌。且 --host 0.0.0.0 --expose-control 下是明文 HTTP,密码与令牌一样可被嗅探 |

升级与版本一致

syncx 的协议在版本之间是附加式的(旧对端读不懂的字段会被忽略),但混版本跑会丢功能: 旧版本不会宣告自己的 CDC 视图、不会回送提交通知回执、也不会应答「拉取安装包」请求。所以产品上把 「配对设备版本必须一致」做成硬约束,低版本一侧的页面会被强制升级。

四条升级路径

| 路径 | 入口 | 说明 | |---|---|---| | npm 官方源 | 页面顶部「发现新版本 v… (当前 v…)」横幅 → 立即升级;或 syncx upgrade [tag] | daemon 启动 30 秒后查一次 registry,之后每 6 小时查一次,发现更高版本经 status.updateAvailable 下发,横幅由 Web UI 弹出(可点「忽略」在本页面内收起该版本,刷新会重新出现)。页面内升级只下载 tgz 走自更新管线(不需要 npm,Windows 下也就此避开 npm.cmd 的 shell 依赖);命令行 syncx upgrade 则是 npm install -g,装完需要自己重启(syncx stop && syncx start)才生效 | | 从对端拉取(P2P) | 设备卡上该设备的「升级到 v<版本>」按钮;版本锁弹窗里的「从对方升级并重启」 | 经加密控制通道让对端把自己的安装目录打成 tgz 传过来,不接触外网。对端必须在线且已宣告版本号,响应限时 30 秒 | | 上传本地安装包 | 顶栏「上传升级」,或把 .tgz 拖到页面中间 | 先做只读预检(把包完整解压校验一遍、显示「将升级到 v…」),确认后才替换。适合无外网 / 内网机器,包由另一台机器 npm pack 或 npm run pack:local 产出 | | 手动 npm 安装 | npm i -g @wangmingfa/syncx@latest | 万能兜底,升级完重启 daemon |

命令行升级支持通道:syncx upgrade(默认 latest)、syncx upgrade beta。已是最新会直接返回不触发安装。

自更新为什么不会把机器升坏

整包替换最怕升到一半起不来。管线是先验后换、换完可退(src/selfupdate.ts):

  1. 落盘前四道校验:大小(过小 / 超过 64MB 视为脏数据)→ 包结构(必须含 package.json 与 dist/syncx.js)→ 包名与版本合法 → 实跑 node dist/syncx.js -v,输出必须等于包内版本。来源「宣告」的目标版本(npm registry / P2P 对端)必须与包内版本一致;P2P 载荷还带 sha256 指纹,接收端落地前重算比对,把「收到的」与「发送方真正打出来的内容」绑定。上传来源没有宣告——本地打包的包,版本以包内 package.json 为权威。
  2. 换包交给独立 updater 脚本(detached,与本进程解耦):等旧进程优雅退出 → 新包拷到目标目录同卷 → 目录级原子换入(旧包先备份,换入失败立刻还原)→ 强制补 0755 可执行位(对端产物可能缺 +x,否则新进程 Permission denied)→ 现装 node-pty(见第 5 条)→ 拉起新 daemon。新 daemon 的 stdout/stderr 接的是你启动时 --log-file 那个文件,不是 /dev/null:启动期任何 console.error(含崩溃兜底)都还在日志里查得到。
  3. 2.5 秒观察窗:新进程在窗口内退出即判定失败,自动回滚旧包并拉回旧版本。
  4. 结果写进 update-done.json(临时工作目录),新 daemon 启动时读它打日志——成功失败各有文案,升级出问题时有根因可查。
  5. node-pty 是原生模块、不随 tgz 分发:换包成功后、拉起新 daemon 前现装一份。尽力而为:装不上(无 npm / 够不着源 / 声明的版本没有本机平台的预编译件)只让浏览器终端降级成受限模式,升级本体照常成功,失败原因写进 update-done.json。四处细节:
    • 装的动作发生在临时目录里的一份一次性 package.json(只声明 node-pty 这一个依赖),装好再拷进安装目录旁。不在安装目录里直接 npm install —— 那会按包里的 package.json 把整棵依赖树补齐(实测 273MB),而 vue / naive-ui / xterm 早就内联进单文件 bundle,纯属死重。
    • 版本范围取新包 package.json 的声明,而「从对端升级」时那是对端打出来的字符串,所以执行前先过一道 semver 字面白名单(挡 shell 元字符,也挡 file: / git+ / https: 这类会把 npm 支使去别处的协议式规格)。范围本身也不上命令行,只作为 npm 自己读的依赖声明存在。
    • 范围必须落在带 prebuilds/linux-* 的那一档(现在是 ^1.2.0-beta.15)。1.1.0 只发 darwin/win32 预编译,Linux 上它的 scripts/prebuild.js 会打印 Rebuilding because directory prebuilds/linux-arm64 does not exist 然后转 node-gyp;而 npm 11 默认拦住 install 脚本(npm warn install-scripts … approve),连这一步都不执行,包落地即无 .node——import('node-pty') 抛错,终端静默落受限模式。反过来说预编译件到位之后,脚本被拦也完全不影响:pty.spawn 照样能开出 /dev/pts/N(实测于 aarch64 / Android PRoot / glibc 2.39)。改这条范围前请看 test/selfupdate.test.ts 里 keeps this repo's own declaration installable 的说明:它只钉下限,新档有没有 linux 预编译得在目标机上量。
    • 装完补一次 spawn-helper 的执行位:npm 提取会把这个位丢掉,而 node-pty 在 unix 上要 exec 它(它自己的 install 脚本只看 prebuilds/ 目录存不存在,没人管这一位)。丢了的后果是 pty.spawn 抛 posix_spawnp failed。
    装出来的 node_modules/ 只在接收侧有用,发送侧打包一律 --exclude=node_modules,否则几十 MB 的原生模块会把整包顶穿体积上限,「发给对端升级」会静默变成「不可用」。 这一整套(装、补执行位、stderr 落日志)跑在旧进程生成的 updater 里,所以它天然晚一代:把带修复的包装上去的那次升级,用的还是旧版本的 updater。已经踩过这代的,手动补一次即可:chmod 755 <安装目录>/node_modules/node-pty/prebuilds/<平台-架构>/spawn-helper,此后由升级自带。

两道护栏:

  • dev / 源码运行态一律拒绝自更新(没有产物可替换,/api/self-update* 直接 503);
  • 目标目录像源码仓库(含 .git 或 src/main.ts)时也拒绝——整包替换会把 src/ 一起删掉。自升级请装在 npm 全局目录。

版本一致锁

配对设备之间,只要有一台在线的对端版本比本机新,本机页面就会弹出「需要升级」弹窗:

  • 不可关闭:没有右上角 ×,点遮罩不关闭,Esc 也关不掉;页面上唯一的出路是点「从对方升级并重启」。
  • 只在在线对端上生效:对端版本更新但当前离线时不锁——你无法给离线设备升级,锁住只会把人困在页面外面。它上线的瞬间锁就会出现。
  • 判据是 daemon 侧的 canUpgrade:要求双方版本号都是具体 semver,且本机更低。所以:
    • dev / 源码态(runtimeVersion() 报 'dev')永不触发,也不会被请求;
    • 旧版本对端不宣告版本时不触发;
    • 本机版本更高(对端偏旧)时同样不锁本机 —— 该升级的是那一侧。
  • 每一条路由都锁。弹窗挂在页面之外(App.vue 最外层的一个宿主),所以 /terminal、/files、/fleet 这些独立页也一样被盖住 —— 锁说的是「这台机器版本不对」,和你在看哪个页面无关。未登录时不挂(登录页上拿不到对端列表)。
  • 升级瞬间页面会短暂无响应:daemon 换包重启,连不上属正常,几十秒后自动恢复。
  • 跨过 CDC 指纹修复的那次升级,改动过的文件会先整文件重传一次:CDC 的滚动指纹补上了「窗口移出项」(见 ADR-0019),块边界随之全变,升级前算出的 cdh 一律作废。不损坏数据,也影响不到定长 blocks(那仍是正确性骨架、旧对端唯一认的口径),代价只是本机留着的旧 cdh 与新对端宣告的新 cdh 对不上 → 那个文件这一次按整文件过网,落地后写入新的 cdh,之后恢复增量。从不改动的文件不受影响(它本来也不需要传)。
  • 本地打包 / 预发布版本会被判「过时」,并被降回正式版:compareVersions 的规则是「基础版本相同时,无预发布后缀的更大」,所以 0.3.2-local.1 < 0.3.2。拿 pack:local --append 的包与正式版设备配对,这一侧会触发锁、然后「从对方升级」把本机覆盖回已发布的 0.3.2——那条链路的闸门是「本机不低于对方才拒」(src/session-manager.ts:1932),正好拦不住这个方向。要拿本地包验 fleet,先把仓库 package.json 的 version 临时提到高于所有对端再打(--version 不是 pack:local 的参数,别指望它),否则就接受这一侧被升回正式版。

一个引导期的事实要说清:锁只存在于装了它的那个版本里。混版本的两台设备,没装锁的那一侧不会被强制 升级 —— 第一次必须人工升它;锁保护的是「已经都带上锁」之后的混版本。眼下这就不是假设:npm 上作为 @wangmingfa/[email protected] 发出去的那份产物还没有这把锁(它早于该提交;探针:弹窗的类名前缀 upgrade-lock 在发布物里 0 次命中,而当前源码打出来的包是 14 次),所以「从 npm 自动升级就自动锁上」 要等下一次发布才成立。缘由与代价清单见 ADR-0018。

为什么做成页面级而不是协议级:能修好版本不一致的唯一动作就是「从版本更高的对端把包拉过来」,而那条 请求走的正是这条连接——协议级拦掉不匹配的配对,锁也就同时锯断了它自己唯一的出路。

发布通道(维护者)

npm run release 有两种发布方式(交互可选,非交互默认 GitHub):

  • --via=github(默认):打 vX.Y.Z tag 并 push,由 GitHub Actions 经 npm Trusted Publishing 自动发布,本地不需要 OTP / Token。--redeploy <version> 重发某个已存在版本(强制把 tag 移到当前 HEAD 并 force-push 重新触发),只有 github 通道支持。
  • --via=local:沿用旧流程,本地 npm login + OTP 直接发布;会先预检 registry 必须是 npmjs 官方源(镜像源不接受 publish)。

发布通道(--tag):latest(默认,正式版 x.y.z)/ beta / alpha / 自定义,预发布版本号形如 x.y.z-beta.N。 注意 Actions 里的 publish.yml 执行的是不带 dist-tag 的 npm publish(即发到 latest),所以发预发布通道请走 --via=local。 判断有没有新版本要用 npm view @wangmingfa/syncx dist-tags,不能用 npm view ... version(它只看 latest,会漏掉预发布版本)。

端口

| 端口(默认) | 用途 | 绑定 | 局域网可访问 | |---|---|---|---| | 22000(--port) | WebSocket P2P 同步 | 所有接口 | ✅ | | 8384(--control-port) | Web UI + 控制 API | 127.0.0.1 默认,--host 0.0.0.0 暴露 | ✅(需 --host 0.0.0.0) |

启动后日志会打印所有可达的 control UI 与 peer 地址,方便直接复制。

健康检查:控制端口提供免认证的 GET /health,适合监控 / 反向代理探活:

curl -f http://127.0.0.1:8384/health   # → {"ok":true,"msg":"syncx is ok","uptime":1234}

只报存活与进程运行时长,不暴露设备 ID 等细节;其余 API(含 /api/status)仍需认证。

开发命令

npm run dev            # 开发运行:tsx watch 自动重载,默认 --host 0.0.0.0(局域网可访问)
npm run test           # 运行全部测试(vitest)
npm run test:watch     # 测试监视模式
npm run typecheck      # TypeScript 类型检查(tsc --noEmit)
npm run build          # 编译到 dist/(tsc)

npm run dev 会同时起两个进程:vite dev server(固定 5173)和 syncx 后端。 后端与生产实例完全隔离:控制端口 9384、peer 端口 23000(助记:生产默认 8384/22000 各 +1000),数据目录独立在仓库根 .syncx-dev/(生产是 ~/.syncx), 两边可以并行运行,互不抢端口、互不碰数据。

dev 下浏览器请打开 http://localhost:5173 —— 页面由 vite 原生提供,改 .vue 即时热更新, 也不需要先跑 vite build。vite 会把前端用到的控制端点(/api/*、POST /login、/favicon.svg) 代理回 9384,登录与会话行为和生产一致。

为避免走错入口,dev 下 9384 不再提供 web 页面:访问它会 302 重定向到 5173(只换端口、 沿用你输入的主机名,因此局域网访问 http://<内网IP>:9384 也会正确跳到 http://<内网IP>:5173)。 9384 此时只保留控制 API。

为什么不能从 9384 打开页面?HMR 走 WebSocket,而 9384 侧只能做 HTTP 反向代理、 无法转发 WebSocket upgrade —— 以前从控制端口打开的页面能加载却不会热更新, 是个静默失效的坑,所以现在直接重定向到 5173。

build 之后则相反:不带 --dev-vite 启动时,8384 直接提供页面(前端来自内嵌 bundle), 5173 不参与,这就是生产形态。

npm run dev 的参数

npm run dev 等价于 tsx watch src/main.ts start,任何 start 支持的参数都可以通过 npm run dev -- <参数> 传入(注意中间的 --):

| 参数 | 说明 | 默认值(syncx 不带 dev 脚本时) | |---|---|---| | --config <路径> | 指定配置文件路径(任意 JSON 文件) | ~/.syncx/config.json(dev 脚本已传 --config-dir .syncx-dev) | | --config-dir <目录> | 数据目录,取 <目录>/config.json(与 --config 同时给出时后者优先) | ~/.syncx(dev 脚本已传 .syncx-dev) | | --port <端口> | P2P 同步端口(其他设备连接用) | 22000(dev 脚本已传 23000) | | --control-port <端口> | Web UI / 控制 API 端口 | 8384(dev 脚本已传 9384) | | --host <地址> | 控制服务绑定地址 | 0.0.0.0(dev 脚本已默认;127.0.0.1 仅本机) | | --dev-vite <url> | dev 模式:web 页面请求 302 重定向到该 vite dev server(HMR 由 vite 原生提供),控制端口只保留 API | dev 脚本已传 http://127.0.0.1:5173 |

不传 --dev-vite 时,后端从 dist/web/client.js 提供前端 bundle,即生产行为 —— 此时必须先 npm run build:web,否则 /client.js 返回空内容导致页面白屏。

示例:

# 使用自定义配置文件(不修改默认配置)
npm run dev -- --config ~/syncx-home.json

# 指定同步端口与控制端口(避免与其他服务冲突)
npm run dev -- --port 24001 --control-port 9484

# 仅本机访问 Web UI(关闭局域网访问)
npm run dev -- --host 127.0.0.1

# 组合使用
npm run dev -- --config-dir ~/syncx-dev-home --port 24001 --control-port 9484

启动后日志会打印所有可达的 Web UI 地址(http://IP:控制端口)与 peer 同步地址(ws://IP:同步端口),可直接复制。

生产命令

全局安装(npm i -g @wangmingfa/syncx)后直接用 syncx:

# 启动 daemon(前台;部署时配合 systemd/launchd 后台常驻)
syncx start [--config <路径>] [--config-dir <目录>] [--port <端口>] [--control-port <端口>] [--host <主机>]

# 停止运行中的 daemon(优先经控制 API 优雅关闭,失败回退信号)
syncx stop [--config <路径>] [--control-port <端口>]

# 全局暂停 / 恢复同步(备份、维护窗口用;连接与配对照常)
syncx pause  [--config <路径>] [--config-dir <目录>] [--control-port <端口>]
syncx resume [--config <路径>] [--config-dir <目录>] [--control-port <端口>]

# 查看状态:daemon 运行状态、设备 ID 与共享目录数
syncx status [--config <路径>] [--control-port <端口>]
# daemon: running (pid 1234, up 1h02m)   ← daemon 未运行时显示 not running

# 生成系统服务模板
syncx install

# 自升级 npm 包(通道默认 latest,可填 beta;装完需重启 daemon 才生效)
syncx upgrade [tag]

# 内容对比:排查「两台设备究竟差在哪」(全程只读)
syncx diff <目录路径> [--device <对端设备ID>]

# 命令行邀请码(无 mDNS / 不便粘贴设备 ID 时的配对方式)
syncx invite <目录路径>        # 生成一次性邀请码交给对端
syncx join <code> <本地路径>   # 接受邀请并添加本地共享目录
syncx revoke <code>            # 吊销未使用的邀请码

# 查看用法 / 版本号
syncx --help
syncx --version

从源码运行时把 syncx 换成 node dist/syncx.js(需先 npm run build):

npm run build
node dist/syncx.js start

常用选项:

| 选项 | 说明 | |---|---| | --config <path> | 指定配置文件路径(默认 ~/.syncx/config.json) | | --config-dir <dir> | 整体换数据目录,取 <dir>/config.json(与 --config 同时给出时后者优先) | | --port <port> | peer 同步端口(默认 22000) | | --control-port <port> | Web UI/控制 API 端口(默认 8384) | | --host <host> | 控制服务绑定地址(默认 127.0.0.1;0.0.0.0 暴露局域网) | | --expose-control | 允许把控制 API 绑到非回环地址(需与 --host 同用;明文 HTTP,谨慎) | | --log-file <path> | 同时把日志写入文件(默认仅输出到 stdout) | | -h, --help | 查看全部命令与选项 | | -v, --version | 查看当前版本号 |

双设备同步测试

仓库内置两种集成测试,自动化验证同步正确性:

npm test   # 含 test/integration/two-daemons.test.ts

two-daemons.test.ts 会 spawn 两个真实 daemon 进程(不同 configDir/端口/共享目录),通过 peers 相连,双向同步文件后断言两边一致——正是验证"同步服务是否正确"的方式。

手动体验双设备同步:写两个 config.json,peers 互指对方地址,分别启动两个 daemon,两边目录即自动互相同步。

许可证

MIT