免费公网消息中转

SKILL开发
开发 未标注权限 许可证不明确
安装这个技能

用免费的公共消息中转站(ntfy.sh 等)给"两台设备必须互相够得着"的功能做外网通道 —— 手机在 4G/5G、电脑在家用宽带、没有公网 IP、不想租服务器、不想注册账号、不能让人装内网穿透客户端时用。覆盖:免费中转站的选型与实测黑名单(含**"端口通"不等于"能用"、必须真发一条**)、**多站冗余(别只押一个公共站:发送端并行发多份 / 接收端多路订阅 / 按密文去重 / fallback 默认开 / 探测并行加缓存)**、**诊断与连通性测试消息必须带标记并跟内容分流(不然会冲掉剪贴板、还会被当成真消息显示、同一条记三遍)**、**"突然连不上"的定性方法(公共实例按小时不可用 vs 本机没网,日志长什么样)**、**同一段内容被"显式发送 + 剪贴板自动同步"两条路各发一次时怎么定性、记账要记在动作那一刻(不要在回调里记)、日志去重窗口必须盖住一次网络往返**、ntfy 的发布/增量拉取/订阅流三种用法、**限流 5 秒 1 次必须用长连接而不是轮询**、**正文 4KB 上限自动转附件**、**主题名必须哈希(公共实例的 /v1/stats 能列出主题名)**、频道码→主题名+密钥的派生规则、**频道码按当天日期自动派生(两端免手填 / 跨天自动切)**、AES-256-GCM 端到端加密的两端逐字节对齐、以及"手机在外网收不到""跨端解密失败但毫无线索"的排查顺序。当用户说"有没有免费网站能当中转""测试消息把我剪贴板冲掉了""连通性测试跟普通消息要区分开""post 到网站再取下来""不租服务器能不能外网用""内网穿透太麻烦""手机流量下用不了""突然连不上了""昨天还好好的今天就不通""同一个内容发了两遍/日志里两条一样的""气泡里两段文字一样""cloud relay / 公共中转 / 消息队列当中转 / 按日期自动生成频道码 / 每天换一次频道码 / 两端不用手填 / 换几个中转站一起用"时使用。

一句话选型结论

按资料字段整理

免费公网消息中转是开发类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类开发 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_825f001d/ntfy-free-relay
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

开发
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_825f001d仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验未标注上游最后核验时间 · 数据更新 1791621867
正文体量42.1 KBSKILL.md 原文大小,越大说明说明书写得越细

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
写代码与改 bug

按需求补代码、定位并修掉报错。

读仓库与生成改动

让模型读懂项目结构,再给出可落地的改动。

跑测试与排查故障

按现象排查问题,补测试用例并复跑。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在开发场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
免费公网消息中转当前 未标注权限 未标注 未标注 未标注 有
技能审查员 浏览器控制 ClawHub 市场 877.9 万 OpenClaw 有
Vercel React 最佳实践 浏览器控制 GitHub 仓库 849.1 万 Codex、Claude、Cursor 有
GitHub 代码协作 联网访问 ClawHub 市场 763.3 万 OpenClaw 有
网页设计指南 联网访问 GitHub 仓库 668.6 万 Codex、Claude、Cursor 有
本体论 只读 ClawHub 市场 663.7 万 OpenClaw 有

SKILL.md 全文

42.1 KB
模型实际读到的说明书原文,来自技能仓库

免费公共中转站(ntfy.sh)—— 不租服务器的外网通道

什么时候用

场景长这样:A 设备和 B 设备要同步数据,但它们互相够不着。
典型是手机在 4G/5G(运营商内网、没有任何可被反连的地址)+ 电脑在家用宽带(NAT/CGNAT/动态 IP)。

先明确一件事:这是网络本身的限制,不是代码 bug。
两端都不可能有固定可达地址,唯一出路是两端都主动去连一个第三方公共点。

然后按这个顺序排除:

  1. 有公网 IP 的机器吗? → 自己写中继(TLS 长连接 + 帧协议),最可控。
  2. 没有,但能接受装客户端? → 现成内网穿透工具。
  3. 上面都不行 / 只想最快跑通? → 用免费的公共消息中转站。这就是本 skill。

一、选型:先实测,别信文档

用 curl 挨个打一遍,只看四件事:要不要注册 / 要不要 Key / 能不能覆盖写 / 有没有大小限。
2026-09 的实测结论(可以直接当黑名单用):

站点 结果
ntfy.sh 零注册、零 Key,主题名自己起(天然零配对);但 2026-09-21 实测有整段(约一小时)不通的时段(当天两次探测还见过它时而 200 时而 TCP 超时)—— 别单独用,也别当默认站,本项目已把它从内置默认列表删掉,见第十二节
ntfy.envs.net 可用。2026-09-21 实测:发布 / 增量拉取 / JSON 流 / 大正文转附件四项全通,/v1/health 约 1.1 s
ntfy.mzte.de 可用。同上四项全通,/v1/health 约 1.0 s
ntfy.tedomum.net TCP 能连上,但发布被 RST —— "端口通"不等于"能用",必须真发一条才算数
jsonblob.com 403
paste.rs 不支持 PUT 覆盖(404)
kvdb.io 要 email
jsonstorage.net 要 API Key
0x0.st 已停上传

探站别只打 /v1/health(它只证明"这个站活着")。四条都要真跑一遍:
发布 / ?poll=1&since= 拉取 / /json 流 / 大正文转附件 ——
ntfy.tedomum.net 就是死在这条上的。

为什么 ntfy 特别适合这个场景:主题名就是"频道",两端填同一个名字就能通,
不需要任何配对/握手/注册流程 —— 这正好对上"用户自己抄一个码到另一台设备"的交互。

二、ntfy 的三种用法(选错就会踩限流)

# 1) 发布:正文就是消息内容
curl -d "内容" https://ntfy.sh/mytopic

# 2) 增量拉取(轮询用)
curl "https://ntfy.sh/mytopic/json?poll=1&since=1758345600"

# 3) 订阅流(实时用,NDJSON,每 45 秒一条 keepalive 事件)
curl -N "https://ntfy.sh/mytopic/json"

必须记住的四个坑

  1. poll=1 不带 since 返回空。 只写 since=all 才给全量。
    要做增量就老老实实传上次那条消息的 unix 时间戳。
  2. 同 IP 平均 5 秒只允许 1 次请求,突发上限 60 次,超了返 HTTP 429。
    实测:900ms 一轮的轮询跑到第 17 轮就开始稳定 429。
    ⇒ 长连接订阅是正解,轮询只当兜底(比如每 3 次订阅失败兜一次)。
  3. 正文 4KB 上限,超了服务端自动把内容转成附件,消息体变成
    {"event":"message","message":"att","attachment":{"url":"..."}}。
    发布时带 Filename: xxx.bin 头。90KB / 240KB 都实测往返一致。
    ⇒ 两端都要实现"小载荷走正文、大载荷走附件",用户无感。
  4. 公共实例的 /v1/stats、/v1/stats?topic=... 能列出主题名。
    ⇒ 主题名绝不能等于频道码,必须哈希。见下一节。

三、主题名与密钥的派生(安全模型)

用户手上的那串东西叫频道码(至少 12 位,越长越安全)。它同时决定两件事:

上行主题名 = "dkn-" + hex(SHA256("topic|up|" + channel))[0:8]
下行主题名 = "dkn-" + hex(SHA256("topic|dn|" + channel))[0:8]
加密密钥   = SHA256("key|" + channel)              // 32 字节
  • 有前缀(topic| / key|)是必须的:否则同一个频道码在上面三处会派生出相关的值。
  • 主题名只取前 8 字节:够用,且短。
  • 上/下行主题必须分开:否则两端各自发出的消息会被自己收到,来回打架。
  • 频道码本身就是密码,靠"用户自己抄"完成分发。别做成明文主题名。

四、端到端加密(两端必须逐字节一致)

载荷结构:AES-256-GCM,nonce(12) ‖ 密文 ‖ tag(16),整个 blob 再 Base64 成一个字符串。

业务字段直接塞在明文的 JSON 里:

{"v":1,"e":false,"p":false,"n":"<唯一 tag>","t":"正文","ts":1758345600}
  • e = 收到后要不要回车;p = 要不要往当前窗口粘贴;n = 唯一 tag,用来"认领"自己发的那条。
  • JSON 序列化不要把中文转成反斜杠 u 四位十六进制那种转义形式。
    C# 用 UnsafeRelaxedJsonEscaping,Kotlin/Java 侧同理 ——
    否则一个汉字占 6 个字符,4KB 上限被吃掉一大半。

两端对齐怎么保证(最重要的一条经验)

光靠"我这边加密自己能解开"是测不出跨端兼容性的。
必须在自检台上放几条用另一端算出来的固定基准值:

断言:Crypt.topic("dkntest-...", "up") == "dkn-f12567bfe149aac8"
断言:Crypt.topic("dkntest-...", "dn") == "dkn-5d9eee68b5286a2b"

任何一边偷偷改了派生规则,这两条立刻炸。
基准值要从真正跑通了对端的那个实现里取,不能手算。

五、踩过的真 bug(照着查,省几小时)

  1. 二进制不能直接当正文发。
    ntfy 正文只接受文本。两端约定的密文编码是 Base64 文本,
    发送端必须先 Base64(blob).toByteArray(ASCII) 再写出去。
    直接塞原始密文字节,接收端 FromBase64String 必然失败,
    而且失败得毫无线索(只表现为"一条都解不开")。

  2. 订阅方向搞反是重灾区。
    电脑端订阅上行主题(手机发上来的),手机端读下行主题(电脑发下来的)。
    反了以后症状是"发出去成功、对方永远收不到",日志里一切正常。

  3. "认领"自己的消息。
    只靠 since 增量会把上一条消息当成本次结果。
    发的时候带一个唯一 tag,收的时候比对,不匹配就跳过。

  4. 本机剪贴板监听会跟写入抢。
    监听线程 250ms 读一次,会让 SetDataObject 抛"假失败"(其实写进去了)。
    写入要做 重试 3 次 + 读回核对。

  5. GCM 解密的判长要用 < 不是 <=。
    blob.size < 12 + 16 才算非法;写成 <= 会让"空明文"解不开。

六、自检台该怎么写

跑真连公共站的跨端联调(跳过 = 假绿,比失败更危险):

  • ① 电脑端加密 → 手机端解密
  • ② 手机端加密 → 电脑端解密 ← 这一条最容易暴露 base64 / 方向问题
  • ③ 大字(> 4KB,走附件通道)双向一字不差
  • ④ 别人用别的密钥发的消息,本端解不开(认证标签拦下,不是"解出来是乱码")
  • ⑤ 主题名里不含频道码本身

联调用的外部 exe / 脚本路径要按 user.dir 拼绝对路径,
用相对路径会因为工作目录不同而被静默跳过。加一句 println 把"跳过了"明确打出来。

每个联调频道用一次性随机码("dknjvm" + System.currentTimeMillis()),跑完就作废。

七、给用户怎么说(一句话版)

> 能,用免费的公共中转站,免注册、免 Key。
> 电脑端生成一串频道码 → 复制给手机 → 两端填同一个。
> 内容是 AES-256-GCM 端到端加密的,中转站只看到密文;
> 频道码就是密码,别外传。
> 公共站单个会时通时不通,所以两端各挂几个、同一份内容一起发(见第十二节)——
> 单个站挂掉不影响你。

> 两个已知限制:超过 4KB 自动走附件;中转站限速,所以用的是长连接。

八、和"自建内网穿透"的关系

两条路不冲突,默认可以并行:局域网通道照常工作(谁快谁先用上),
云端通道只在"远端"场景兜底。默认别做成二选一 —— 用户在家时并不想手动切。

自建中继的适用条件是"有一台有公网 IP 的机器",那是更可控的方案(TLS + 指纹固定 + 预共享密钥);
而免费公共中转的价值是 零运维、零成本、五分钟能跑通。


九、如果用户明确要"只用云端中转":该删什么

有用户会直接说「把局域网那套去掉,只用云端」。这时候别只加个开关把局域网关掉,
要把整条路整块删干净
—— 留着的死代码(端口常量、菜单项、权限、说明文字)
比缺功能更坑,用户下次会问"怎么还有 5000 端口的代码"。

按下面清单逐项过一遍(两端都要):

类别 要删的东西
监听 服务端监听 0.0.0.0:5000、手机端的 8080 接收服务
接口 GET /ping、POST /sendtext、POST /sendclip、回传 POST http://{对方IP}:8080/receiveclip
对端地址 "记住对方 IP"的文件 / 菜单项 / 输入框 / 持久化 key
发现 扫同网段、端口探测、连不上自动搜对端(这类文件改名 .unused 留着,不要真删)
防火墙 生成 netsh 命令的菜单项 + 常量(改成"不再需要放行"的说明)
手机权限 ACCESS_WIFI_STATE / ACCESS_NETWORK_STATE 只服务局域网直连,删掉;顺带 uses-feature android.hardware.wifi 也消失
依赖 电脑端 .csproj 从 Microsoft.NET.Sdk.Web 换回 Microsoft.NET.Sdk;删掉 using Microsoft.AspNetCore.*
文案 托盘菜单 / 状态条 / 启动气泡 / 使用说明里的 IP、端口、防火墙字样
自检项 反过来写:原来是"5000 在监听吗",现在是"5000 不应该有监听(有的话说明旧版没退干净)"

几个实操要点:

  • 删源码文件用改名 .unused,不要真删。构建脚本一般是 find … -name '*.kt',
    自动就排除了,随时能找回。
  • 精确字符串替换 + 命中数必须为 1,否则报错退出(脚本化,别手改大文件)。
    改完立刻反查残留:grep -n "8080\|5000\|receiveclip\|startServer\|sendclip\|sendtext"。
  • 反查 dex 用 Python,别用 strings(Git Bash 里常常没有 strings):
    zipfile.ZipFile(apk).read('classes.dex').count(b'关键词'),
    再把 re.findall(rb'Lcom/你的包/[A-Za-z0-9_]+;', dex) 打出来核对类名清单。
  • 纯逻辑自检台(脱离平台的那种)看不到平台层的缺失,
    所以减法改造的最后一步必须是真编译 / 真打包,不能只跑自检。
  • 没有频道码就等于整个程序什么都不做,所以第一次运行要自动生成频道码:
    写盘 + 写日志 + 放进剪贴板 + 弹个非模态气泡(别弹模态对话框)。
    气泡内容就是手机要填的那一行 host/频道码。
  • 安全上顺带更好:删掉监听端口之后,"无鉴权的开放端口"这个隐患一起没了。

十、频道码"按当天日期自动生成"(免手填,2026-09-21 落地为 v2.6 / v5.9)

用户常会接着提这个需求:「根据当天日期生成一个独一无二的频道码,日期不变它就不变,
程序启动时自动设定好;原来手动设定和随机生成的功能也保留」
。
这是把"频道码分发"这个人成本去掉的最省事做法 —— 两端各算各的,谁也不用抄给谁。

派生规则(和主题名一样,必须带域前缀)

频道码 = "dknd-" + hex(SHA256("dkn-daily-channel|v1|" + yyyy-MM-dd))[0:12]   // 共 29 字符
  • 前缀固定成 dknd-,和随机的 32 位十六进制码在肉眼上就能区分
    (用户一眼看出"这串是自动的",出问题时也好描述)。
  • 把 |v1| 放进哈希输入里是为了以后改算法不撞车。
  • 日期必须是公历 yyyy-MM-dd、用不随地区变的文化设置:
    C# 用 DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
    Kotlin/Java 用 SimpleDateFormat("yyyy-MM-dd", Locale.US)。
    ⚠️ 泰国等默认非公历的地区,Locale.getDefault() 会给你佛历年份(+543 年),
    两端就对不上了,而且症状是"日期看着都一样,就是不通"。

两端怎么落

做法
电脑端 配置里加 auto=1;启动时 EnsureChannel() 算出当天码 → 写盘 → 放剪贴板 → 弹气泡。先定码再 Reload(),否则会白连一次昨天/上一条的主题(日志里会出现两次订阅)
手机端 勾选框「按日期自动」(默认勾上);取值时现算、不落盘(dailyChannel()),免得跨天还读着旧的
手动模式 电脑端 auto=0、手机端取消勾选。切手动时把"当前算出来的那串"存进原来的字段,用户看着不突兀;日期变化不再覆盖它。两端都要有切回自动的入口
跨天 电脑端 30 秒轮询比对日期;手机端前台心跳每 3 秒顺手比一次,后台靠云端长连接的循环兜底

最容易漏的一条:把"频道码变了"加进长连接的断开条件

跨天时如果只在 UI 层把新码算出来、却不主动断开旧连接,长连接会一直听着昨天的主题 ——
表面一点错误都没有,就是不收消息(比报错难查得多)。
必须让订阅的 stopped 条件包含"当前地址 ≠ 建立连接时的地址":

Cloud.stream(addr) { !cloudRunning || Prefs.cloudAddr(this) != lastAddr }

电脑端同理(Reload() 里比对旧主题)。兜底周期就是 ntfy 的 45 秒 keepalive。

验证方式(照抄这一套)

  1. 写死几个跨端对拍的基准值:今天、明天、跨年(如 2026-09-21 / 2026-09-22 / 2027-01-01)
    的期望码,两边各断言一次。任何一边改了规则立刻炸。
  2. 纯 JVM 自检台里给日期留一个 override 开关(object Daily { var override: String? }),
    这样能一次跑完"同一天不变 / 跨天不同 / 跨月跨年不同 / 跨天密钥解不开"这些断言。
  3. 真跑对端的可执行文件对拍:电脑端加一个 CLI --daily-channel [日期] 只打印不做事,
    手机端自检台用 ProcessBuilder 调它比对;这样"两端算法一致"是机器证出来的。
  4. 收尾补一步:程序真跑一次,看日志里有没有
    按日期自动设定频道码(程序启动):2026-09-21 → dknd-xxxx,以及订阅只发生一次。

必须如实告知的安全代价

派生规则写在客户端里,只要知道日期就能算出频道码 ⇒
自动模式的安全性低于"用户自设的长随机码"。
要在代码注释 / UI 提示 / 使用说明 / README 四处都写清楚,
而不是只在内部嘀咕一句。想更安全就引导用户切手动模式填一串长的随机码。

自检台调外部 exe 的两个死坑

  • Process.waitFor() 没超时会永久吊死。探测用的旧版 exe 不认识新参数 → 它不打印也不退出,
    走的可能是"起托盘进程"分支,整个自检台卡几分钟。
    ⇒ 一律 waitFor(25, SECONDS) + 超时 destroyForcibly();
    输出不符合预期格式就返回 null 并打印"这个 exe 不支持某参数(多半是旧版本)",
    明确跳过,不要静默。
  • 候选路径要优先最新的产物(本项目 build/CloudClip-Win.exe 排最前),
    否则 bin/Release/net8.0-windows/ 里那个旧 exe 会被先选中,测的是老代码。
  • 探测完记得 tasklist | grep <exe名> 看一眼有没有被拉起来的残留进程,有就清掉。

十一、两端互问式"连通性测试"(2026-09-21 落地为电脑端 v2.6 / 手机端 v6.0)

场景:用户看着一条通道,界面上永远是同一个死样子(圆点红、内容不动),
但原因可能是三件完全不同的事(这一端没网 / 两端频道码不一致 / 对端根本没在跑)。
"发一条试试看"只能给出"不行",给不出"卡在哪一段"。
本节的做法是让两端各自以对方的名义说话,一次性把每一段都测出来。

地雷一:自己发、自己读 = 自言自语

用自己的身份发、再到自己该读的那个主题里找 —— 一定能找到,
于是报告"通了",但这条链路其实一步都没走出去。
⇒ 必须以对端的身份说话:这一端发出去的,要落在对端读的那个主题里。

落法(设 A=本端身份、B=对端身份,两端各有一个"读的主题"):

谁 用什么身份发 落到哪个主题 证明什么
甲 乙的身份 乙读的那个主题 甲能把东西送出去且乙侧会看到(甲→乙)
乙 甲的身份 甲读的那个主题 乙能把东西送回来(乙→甲)

自检台必须为这条单独写一节,断言四件事:① 用对端身份发得出去;
② 它落在对端的主题里;③ 用本端身份读的确实是本端那个主题
(所以不能拿它自测);④ 另一个主题里没有这条(证明身份真的换了)。
第 ④ 条最容易漏 —— 少了它,"身份没生效"的 bug 是测不出来的。

地雷二:认领要靠密文,不能靠名字

tag(如 hello-<rev> / pong-<rev>)是明文塞进载荷的:谁都能写一个一样的,
拿它当"这就是对端那条"的证据是假的。认加密之后的密文:
AES-GCM 每次都带随机 nonce ⇒ 密文逐字节相同 ⟺ 同一密钥 + 同一段明文。
两端各把自己那条的密文交给对方去比对(本项目叫 hx / ox),
"逐字节相同"才是铁证。

配套地雷(一定会踩):发送方必须把已经算好的那份密文原样发出去。
- Kotlin:publishTo(..., presetBlob: ByteArray? = null),发送用
presetBlob ?: Crypt.encrypt(...);测里那句 val blob = Crypt.encrypt(...)
一旦被重新执行,凭证就和主题里那条不同了,比对永远不命中。
- 自检台加一条实证断言:同明文加两次必须得到不同密文
(这条本身也说明"凭证只能复用、不能重算")。

地雷三:轮询时把自己发的那条剔掉

主题是追加式的,本进程刚发出去的探测还在里面。
如果认领逻辑留了"宽松分支"(比如"tag 前缀对得上就算"),
就会把自己的呼叫当成对端的回复 ⇒ 给出假"通"。
⇒ 按消息 id 去重(seen / HashSet<string>),并且认领逻辑先严后宽、
严格命中就立即 break。

地雷四:读原始密文要改数据结构

如果轮询函数只返回"解密后的正文"(t 字段),密文根本拿不到,上面这套全废。
⇒ 给消息记录多带一个字段(Blob),在解密成功的同时把
Convert.ToBase64String(blob) 一起带出去。

限流:连读两个主题,第二个必然 429

「同 IP 平均 5 秒 1 次」是按订阅/轮询请求算的。
一轮里先读主题①再读主题② ⇒ 第二次就 HTTP 429
(正文 {"code":42901,...},日志里表现为"HTTP fail + 0 条")。
注意:?poll=1 的长轮询不算(它就是"待到有消息",不是轮询),
别把 --cloud-selftest 里的 poll=1 当成同样的东西。
⇒ 轮询间隔 ≥2.5 秒;并且别把"读不到"当成"对端没回" ——
先看日志里是不是 HTTP fail,那可能纯粹是自己把额度用光了。

分工:谁有资格下结论

  • 发起端只负责把探测发出去,不判"成功/失败"(它看不到对端到底收到没有)。
  • 能同时看到两侧的那一端才出结论(本项目是手机:它既读自己的、也读电脑的)。
  • 另一端(电脑)只报它自己看到的那一半,另一段老实写
    "请看手机上的显示"。两端各说一套、用户不知道该信谁,比没有测试更糟。

交付形态(本项目)

  • 电脑端写一份结果文件(桌面 cloudclip-win-cir.txt,找不到桌面退到
    文档\手机目录转存\、再退到 文档\),里面含一行 CIRJSON {…};
    手机端点按钮时先试着读它,读不到就自己跑同机上的 exe
    (开发场景:手机与电脑同机,ProcessBuilder("…/CloudClip-Win.exe","--cir-json"))。
  • 手机把结论 + report JSON 再交回电脑端(--cir-report "<json>")补一段。
  • 注意两端 report 的字段名必须逐个对齐(本项目用极短名 v/ht/hx/ox/el),
    并且在两侧的测试里都断言这几个名字 —— 一边改名、另一边还按老名字解析,
    症状是"功能静默失效、但两边自检都绿"。

排查开关(强烈建议加)

--cir-report 这种"读一圈再判断"的命令,失败时只有一句"没找到记录",
根本分不清是主题是空的还是全解不开。加一个环境变量开关
(本项目 TRAYAPI_TRACE=1)把每轮、每条(tag / 是否命中 / 密文长度)打出来 ——
本节的三个地雷全是靠它才定位到的。

⚠️ 这个开关必须是进程级的全局静态初始化,不能只在某个 CLI 分支里赋值:
托盘常驻进程和 --cir-* 是两条路,只在后者里打开的话,
"手机发过来到底有没有到"在托盘模式下一点痕迹都没有。
另外:读到一行不是 NDJSON 时要显式记日志,
否则"消息没来"和"来了但没认出来"这两件相反的事会混成同一个静默。


十二、别把通道押在一个公共站上:多站冗余(2026-09-21 落地为电脑端 v2.8 / 手机端 v6.1)

定性:公共实例的可用性是"按小时"变的

同一台机器、同一天实测:

目标 18:21 19:06 ~ 19:40 20:1x
https://ntfy.sh/v1/health ✔ ✘ TCP 超时 ✔ 200
https://baidu.com ✔ ✔ ✔
https://ntfy.envs.net/v1/health — ✔ 1117 ms ✔
https://ntfy.mzte.de/v1/health — ✔ 992 ms ✔

用户机器上的托盘日志把这一小时记成了 29 条一模一样的
订阅断开:HttpRequestException … (ntfy.sh:443),N 秒后重连
(退避从 1 秒涨到 30 秒)。这就是"连不上"的全部真相 —— 代码一行没错。

关键在于:这个现象和"本机没网"在界面上长得一模一样。
两端只连一个站的时候,站一挂 ⇒ 两端同时红 ⇒ 用户只会说"连不上",永远查不出卡在哪。
所以修法不是"换个更靠谱的站"(没有这种站),而是不押在任何单个站上。

设计:发送端发多份,接收端多路

                    ┌── ntfy.envs.net ──┐
手机 --并行发同一份密文─┤                    ├─ 长连接订阅 ── 电脑
                    └── ntfy.mzte.de ───┘
环节 做法 理由
发送 并行发到所有可达站(同一份密文发 N 份) 挑一个发 = 两端必须挑到同一个;手机走 4G、电脑走宽带,封锁策略不一样 ⇒ 各挑各的 = "两边都显示正常但永远收不到对方",最难查的故障。发多份不需要两端商量
接收(电脑/常驻端) 每个可达站各开一条长连接,Task.WaitAll 一组 谁先到算谁的;某条断了只重连它自己
接收(手机/移动端) 挨个站试,第一个连上的就用 移动端吃电、可能在流量上;"能碰上"已由发送侧发多份保证
拉取 / 轮询 挨个站都拉一遍再合并 内容可能只存在于某一个站上
去重 按密文去重,不能按消息 id 每个实例给同一条消息生成自己的 id:同一份 blob 发到几个站就是几个不同 id。而密文只加密一次、发多份 ⇒ 逐字节相同,正好当键。AES-GCM 每次新随机 nonce,所以"用户真的复制了两次相同文字"不会被误吞
站数 2 个就够,硬上限写死 2(MaxFanout = 2) 别再挂第 3 个:只有两个站同时挂才会不通,第 3 个带来的是"每轮多一个请求 + 常驻端多一条长连接",可用性边际增益≈ 0。参见下面「站数别贪多」

可达性探测:并行 + 缓存 + "探不到就当能用"

  • 打 /v1/health(很轻,返回 {"healthy":true})。
  • 必须并行:串行几个站 × 5 秒超时 = 界面卡十几秒。
  • 缓存 ~45 秒(Forget() 给"配置变了 / 全挂了"用)。
  • 一个都探不到时返回全部配置的站:探测失败 ≠ 发送一定失败
    (探测走的是 HEAD/GET,发送是 PUT/POST,中间设备可能放一个拦一个)。

fallback 要默认开(这是个升级陷阱)

老版本的配置里写着 server=https://ntfy.sh。如果严格"只听配置",
升级完还是只连那个已经不通的站 —— 这次修了等于没修。
所以:server= 后面自动跟上内置备用站,用一个 fallback=1(默认开)控制。

server= 支持逗号 / 分号 / 空格 / 换行分隔,也支持 # 注释 ——
⚠️ 注释必须在切分之前剥掉,否则 # 这是注释 会被拆成两段、
第二段被补成 https://这是注释(这个 bug 真的写进去过,靠自检才发现)。

两个一定会踩的坑

  1. "只填频道码"不能让 host 列表变空。
    解析 abcdefghijkl 这种裸频道码时若返回空 hosts,而
    publish / fetch / ping 全是"遍历 hosts",结果就是
    配置看着填好了、按发送永远不动,报错还会骗人地说"频道码没填或太短"。
    ⇒ 裸频道码 = 自动挂上默认主站(+备用站)。
  2. HttpClient 要拆成两个。
    一个带超时(30 s,发送/轮询用,防止某个站卡住把整轮发送永远挂住),
    一个超时设 InfiniteTimeSpan(只给长连接订阅用,靠 CancellationToken 收尾)。

排障开关:--relay-probe

它把两件以前长得一模一样的事分开:

中转站体检:你填了 1 个,内置备用站 已开启,实际会用 2 个。
   · https://ntfy.envs.net
   · https://ntfy.mzte.de

逐个探测 /v1/health(每个最多等 5 秒):
  ✔ https://ntfy.envs.net 828 ms
  ✔ https://ntfy.mzte.de 2014 ms

—— 2/2 个可达,够用。

全部不通 = 本机网络/被墙;只有一个不通 = 那个实例挂了。

站数别贪多:2 个就够(3 个是纯浪费)

多站冗余是治「某个公共实例挂了整条通道就全死」,两个站就治住了 ——
只有它们同时挂才会不通,这个概率远低于单站。第 3 个站带来的不是可用性,而是:

  • 每轮发送多一个请求(含它失败时的 30 秒超时预算);
  • 常驻端多一条长连接、多一个重连循环;
  • 每次可达性探测多一份 5 秒超时预算。

更坑的是:如果那个站恰好在你这条网络里从来不通,它就是“默认带一个注定失败的站” ——
本项目 2026-09-21 就是这个情形(ntfy.sh 443 整段不通),
于是 v2.11 把它从内置列表删掉,回车到两个站(MaxFanout = 2)。

判断标准很简单:你自己跑 --relay-probe,全部达的就留下,长期红的就删。
删掉不等于封杀 —— 用户想加回来,在配置里手填一行就行(手填的站永远排第一)。

⚠️ 两端的默认站列表和 MaxFanout 必须一致 —— 不一致就是
“我发到 A、你只听 B”这种最难查的故障。客户端代码注释里要直接点名另一端的符号名。

自测要走生产那条路(否则自测在吓唬人)

改为多站之后,自测如果还只往主站发,主站被限流(HTTP 429)就报"失败",
而实际上另外两个站早收下了、对端照样能收到。
⇒ 自测也并行发到所有站,并逐个打出行结果:

发小载荷 134 字节密文 → 2/2 个中转站收到
  ✔ https://ntfy.envs.net
  ✔ https://ntfy.mzte.de

验收要点:--cloud-selftest 的"小载荷 / 大载荷(走附件)"两项都要看到
N/N 个中转站收到 且取回解密一字不差;托盘实机日志里要同时出现
「同时订阅 N 个中转站」+ N 条「已连上」,发一条后能看到
「同一份内容已经从别的中转站收到过了,跳过重复的一份」× N-1(去重生效的正面证据)。


十三、诊断消息必须跟内容分流(2026-09-21 落地为电脑端 v2.9 / 手机端 v6.2)

症状(用户截图原样)

连通性测试 · 手机回报(20:18:59)
20:25:17  电脑→手机  已写入手机剪贴板      ← 测试消息把人家正在用的内容冲掉了
连通性测试 · 手机回报(20:18:59)
20:25:20  电脑→手机  已写入手机剪贴板      ← 同一条,记了三遍

用户原话:「连通性测试和普通的消息我希望能够区分开来……它不要占用这个剪贴板」。

为什么会必然发生

连通性测试的消息和真内容走的是同一条通道、同一套载荷格式,
接收端只判断"这是不是本频道的消息" —— 是就照常「写剪贴板 + 弹通知」。
于是三个后果同时出现:

  1. 占剪贴板:测试话把用户剪贴板里的东西冲掉。电脑端也一样 ——
    跑一次 --cir,托盘就会把「连通性测试 · 电脑呼叫手机(…)」写进 Windows 剪贴板
    (在跑着托盘的时候实测抓到过:剪贴板里躺着 20:41:32 那句)。
    注意"冒充对端身份发"不是保险:那条消息打到的是对端读的主题,
    而本机(或另一台端)可能正好也订阅着它 —— 自己发的测试会打到自己身上。
  2. 看着像真消息:日志里是正常气泡 + 方向标,用户根本分不出。
  3. 同一条记三遍:见第十二节的"按密文去重",按消息 id 去重等于没去重。

改法:一个标记 + 三层识别 + 三种命运

  1. 载荷加标记:{"v":1,...,"d":true,"t":"连通性测试 · …","n":"hello-xxxx"}。
    两端都发、两端都认。
  2. 三层识别(Diag.isTest / IsTestMessage),从硬到软:
    d 标记 → tag 前缀(hello-/ping-/pong-/cir-)→ 正文前缀(「连通性测试」只认开头)。
    后两层是为了"只升级了一端"也不回到老毛病。只认开头这条别省:
    用户真复制一篇讲"连通性测试"的文章,正文不会以它开头。
  3. 三种命运(收件分流的唯一判据,界面/服务只照做,别在两处各写一套):
    - 自己刚发出去的那条(echo)→ 直接丢,连日志都不记(记了只是噪音);
    - 测试消息 → 只记一行日志:不写剪贴板、不弹通知、不注入按键;
    - 真内容 → 老行为。
  4. 界面也跟着分流:把"谁配显示成气泡"收口成一个函数
    (本项目 Source.isBubble:只有真的收发内容才是气泡,
    系统消息/诊断消息是无底色小灰字)。以前界面自己判断"是不是系统消息",
    加一类日志就漏一处 —— 这次的连通性测试就是那么漏成气泡的。
    判据写成白名单("是内容才给气泡")比黑名单("是系统消息就不给")抗新增。

用户能按的那个按钮要加冷却

15 秒足够。一轮测试要往云端发 1~2 条、还要在两个主题上各拉好几轮,
连点几下就是在公共站上刷「同 IP 平均 5 秒 1 次」的额度,而且把日志刷满。
按早了就提示"还差 N 秒"(cooldownLeft() 写成纯函数,自测直接喂时刻断言)。

验证套路(可复现的 A/B)

不要只查代码,要真跑 + 读剪贴板前后比对:

# 纯 ctypes 读写剪贴板(⚠️ 64 位下必须给 Win32 函数标 argtypes/restype,
# 不标就段错误):
#   save  -> 记下当前剪贴板原文,并**原样写回**(内容没变,监听端不会当成新内容推一遍)
#   check -> 与记下的原文逐字比对

流程:启动托盘(带 trace)→ 等「已连上」→ 等首轮回填落定 → save →
跑一次 --cir → 等 8 秒 → check。期望:剪贴板一字未变,
日志里只有「收到一条连通性测试消息(只记日志,不写剪贴板)」,且没有
只写剪贴板 / 已发送 Ctrl+V。旧版跑同一条命令后剪贴板里会躺着测试话 —— 这就是对照。

顺带一提:日志文案会跟着通道跑

本项目里手机的日志文本(如「云端订阅断开(第 1 次):…」)是当内容发出去的
(用户点了气泡复制,就被剪贴板监听推走了)。排查时看到"电脑收到了手机的报错文本"
不要慌,那不是 bug,是用户复制过它。真要更稳,可以让界面上的"点气泡复制"
顺手打上 JustSent 标记,免得多推一条。


十四、性能调优清单(2026-09-21 两端实测,照抄即可)

用户提「看看有哪些地方可以调优」时,先按这张单子过一遍 —— 每一条都是真花过代价的,
不是理论上的"可能更省"。

① 状态显示别另开一条轮询(最大的一笔)

反面教材:手机界面为了让顶上那个圆点变绿,每 3 秒 GET 一次中转站 ——
20 次/分钟,费电、费流量、还占着公共站的限流额度。更糟的是它测的是
"另一个请求通不通",跟"收消息的那条长连接活着吗"不是一回事:绿点不保证收得到东西。

正确做法:长连接本身就是最准的探针。
- 订阅函数加一个 onUp 回调(HTTP 200 那一刻回调一次);
- 服务侧维护 cloudUp / cloudUpAt / cloudDownAt 三个 volatile;
- 界面每 1 秒纯本地读变量重画(1 秒一次不花钱),断开头几秒给个宽限(正常换网会闪断);
- 掉线时把长连接自己报的错(超时 / 域名解析不了 / HTTP 4xx)当理由贴出来,比"心跳失败"具体得多。

顺带白捡一条能力:服务没在跑也看得出来。老的"另发一个探测请求"永远测不出这个 ——
服务死了,探测照样通,圆点绿着,人却收不到东西。

② 多站操作并行,但只读的必须"全等齐再合并"

发送(fanOut)只要一个成功就能收工;拉取/探测(fanAll)必须每个站都问到 ——
"这条内容在不在这个站上"是互相独立的事实。但顺序上一定要并行:
串行几个站、每个超时 3~8 秒,最坏就是逐个相加,连通性测试里好几个这种循环,
串起来能把一次测试拖到十几秒。并行后最坏 ≈ 一个超时。

③ Windows 剪贴板监听:先读序号,变了才读内容

Thread.Sleep(250) + Clipboard.ContainsText() + Clipboard.GetText() 是常见写法,
但它一秒四趟打开剪贴板、把整段内容读成新字符串:

[DllImport("user32.dll")] private static extern uint GetClipboardSequenceNumber();
var cur = GetClipboardSequenceNumber();     // 不打开剪贴板、不分配字符串
if (cur != 0 && cur == seq) continue;       // 没变就跳过(99% 的循环走这条)
seq = cur;
// 变了才去读

剪贴板是全局独占资源,一秒开合四趟还会跟别的程序抢(它们那边会弹"剪贴板被占用")。
拿不到序号(0)时退回老行为"每次都读"——功能优先,别为了省电变成聋子。

④ 常驻程序的日志要轮转

File.AppendAllText 只会越写越大:一天几十条 × 常年不管 = 几百 MB,
真出问题想翻的时候反而打不开。到 2MB 就 File.Move(path, path + ".1", overwrite: true)
(旧的 .1 直接丢),目录里最多两份。

实测:放一个 2.48 MB 的旧日志 → 启动后新日志 1025 字节、.1 保留 2600000 字节。

⑤ 探测/重连要"地板"

订阅循环每断一次都会 force 探一轮中转站;网络不好时就成了"断一次探一轮",
每个站各 5 秒超时地打 —— 探测本身把网络搞得更差。给 force 也加一个最小间隔(8 秒),
间隔内先用上一次的结论。

⑥ 别在 getCount() / getView() 里取数据

getCount() 里写 data = repo.all()(复制整表)是个经典坑:ListView 一帧要调好几次。
快照只在"数据真变了"的回调里取一次,其余方法都读缓存;行内 view 用 view.tag 存个 Holder。

⑦ 用户界面上的"测试"不要弹框

点一次弹一层说明框,挡住用户正在看的日志;而结论本来就是要写进日志的。
落成灰字日志 + 一句 toast 就够,点开日志能看全文、能复制。
(这一条是用户体验,不是性能,但用户提"优化"时通常指的就是它。)


十五、同一段内容被两条路各发了一次:记账要记在"动作那一刻"

症状(用户截图原话:「第一次发送消息给电脑的时候,气泡里面有两段文字是相同的内容」):
发送端日志里同一条内容出现两遍,中间隔几秒;接收端也真收到两遍。

怎么定性(一步到位,别猜):去看接收端日志里那条载荷的标志位。
这个项目的载荷带 e(要不要补回车)/ p(要不要粘贴),于是:

[22:04:15] 收到手机发来的 17 字:我已经很久没有玩狼人杀了 有点怀念
[22:04:16]   已发送 Ctrl+V + 回车          ← e=true p=true  → 来自「显式发送」按钮
[22:04:18] 收到手机发来的 17 字:我已经很久没有玩狼人杀了 有点怀念
[22:04:18]   只写剪贴板(没要求粘贴)        ← e=false p=false → 来自「剪贴板自动同步」

两条、标志不同 = 两条路各发了一次(不是重传、不是多站冗余去重失败 ——
多站冗余那份密文逐字节相同,早就被按密文去重挡掉了;这两条的密文不同,
因为它们是两次独立加密,nonce 不同)。

根因:发送端有一个"这段文字刚发出去,剪贴板监听别再推一遍"的记账,
但它写在网络回调里(发送成功后才记),而一次发送的往返实测 1~4 秒 ——
0.7 秒一轮的剪贴板监听只要在这几秒里探到同一段文字,就抢在前面又推一遍。
用户在发之前十有八九"先复制、再粘进输入框",所以这个撞车几乎必然发生,
而且只在第一句话上发生(后面几句是直接打的,剪贴板里没有)。

修法(两条一起上):

  1. 记账挪到"动作发起"那一刻,别等回调:按钮点击里、所有早退分支之后立刻 mark。
    kotlin val text = etCmd.text.toString().trim() ... JustSent.mark(text) // ← 就在这儿,不要挪进 publishAsync 的回调 Cloud.publishAsync(cloud, text, enter) { r -> ... }
  2. 日志去重窗口要盖住一次网络往返。这条最反直觉:去重窗口看起来是"两条日志之间
    的时间差",但日志是发送完成才写的 —— 两条路各写一次的时间差 = 各自往返之差,
    可以到 3~4 秒。按"两条日志几乎同时到"设 3 秒 = 必然漏。设 8 秒(≈ 最坏往返 + 余量),
    并跟"刚发出去"那个窗口对齐。

别做的事:
- 别让显式发送去让位给自动同步。自动同步只写剪贴板(p=false),
显式发送会粘贴+回车 —— 两者不可互换,压掉显式那条等于用户按了发送却没打进窗口。
要压就压自动那条。
- 别用"同一秒""1.5 秒内"这种假设当窗口值,理由同上(写日志的时刻 ≠ 动作的时刻)。

怎么写断言(纯 JVM 自检台里,两样都要有):

// 源码级:mark 的字节位置必须在 publish 之前
val iMark = mainSrc.indexOf("JustSent.mark(text)")
val iPub  = mainSrc.indexOf("Cloud.publishAsync(cloud, text, enter)")
check("按下发送就记下「这段刚发出去」", iMark in 1 until iPub)
// 行为级:照抄用户实测的时间差(4 秒),它必须仍然算重复
Source.phoneToPc(文本, "已发到云端", Dir.VOICE, 10_000L)
eq("4 秒后同内容 → 不再记第二条", Source.phoneToPc(文本, "已发到云端", Dir.TO_PC, 14_000L), null)

常见问题

5 问
答案由本站依据条目字段整理
免费公网消息中转怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_825f001d/ntfy-free-relay。安装方式为未标注。装完刷新技能列表或重开客户端。

免费公网消息中转支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

免费公网消息中转权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

免费公网消息中转收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

免费公网消息中转用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 未标注。