局域网剪贴板。在电脑上按一次 ⌘V,东西就已经在手机上了。
界面照着 Raycast 的 Clipboard History 做:顶部一个常驻焦点的搜索框、左边分组列表、右边预览、底部一直告诉你能按什么键。支持文本和图片、搜索、Pin,不用管理——没 Pin 的东西会自己过期消失。
┌──────────────────────────────────────────────────────────────────────────┐
│ netclip [ ▣ 在手机上打开 ] │
├──────────────────────────────────────────────────────────────────────────┤
│ ⌕ 搜索 — 或按 ⌘V 发送剪贴板 ✕ │
├────────────────────────────────┬─────────────────────────────────────────┤
│ 已固定 │ shot.png · 1440 × 900 │
│ 📌 wifi 密码 · Kx7#mq… │ ┌───────────────────────────────────┐ │
│ 📌 ssh pi@nas.local │ │ │ │
│ │ │ [ 图片预览 ] │ │
│ 今天 │ │ │ │
│▸🖼 shot.png 14:32 │ └───────────────────────────────────┘ │
│ 🔗 github.com/…/pull/42 14:20 │ │
│ ¶ docker run -d -p 32… 13:58 │ 类型 PNG · 842 KB · 1440 × 900 │
│ │ 添加于 今天 14:32 · 来自 Mac · Chrome │
│ 昨天 │ 过期 2 天后 按 ⌥P 永久保留 │
│ 🔗 192.168.1.42:3210 21:17 │ │
├────────────────────────────────┴─────────────────────────────────────────┤
│ ● 2 台设备 复制 ↵ 固定 ⌥P 二维码 ⌘G 删除 ⌘⌫ 更多 ⌘K │
└──────────────────────────────────────────────────────────────────────────┘
docker compose up -d然后在电脑上打开 http://<这台机器的内网IP>:3210,点右上角 在手机上打开,用手机扫码。配对只需要这一次。
· 如果这台机器的 docker socket 是 root-only,前面加
sudo。 · compose 里用的是network_mode: host,这样服务端能看到真实网卡、自己算出手机该连哪个地址。这只在 Linux 上有效;Docker Desktop(macOS/Windows)上把那行删掉、换回ports: ["3210:3210"]即可,只要你用内网 IP 而不是 localhost 打开页面,二维码一样是对的。
电脑 → 手机。 在页面上任意位置按 ⌘V / Ctrl+V,剪贴板里的文字或截图立刻成为一条记录,手机上正开着的页面同一瞬间就刷新了。把文件拖到窗口任意位置也一样。不需要点任何按钮,没有对话框。
手机上拿东西。
| 内容 | 点一下会 |
|---|---|
| 链接 | 直接打开(次要按钮是复制) |
| 文本 | 复制,卡片本身闪一下绿色确认 |
| 图片 | 什么都不会发生 —— 长按图片,用系统菜单里的「存储到照片」或「拷贝」 |
底部三个键:📌 只看已固定 · + 从手机发送 · 🔍 搜索(搜索框在底部,紧挨着键盘)。
左滑删除、右滑固定,删除后有 6 秒撤销。
因为在 http://192.168.x.x 下做不到,而给一个按了没反应的按钮比不给更糟。
浏览器把 navigator.clipboard、ClipboardItem、navigator.share 全部限制在 secure context(HTTPS 或 localhost)。内网 IP 不在白名单里,这些 API 在页面里根本不存在。文本还有退路——老的 document.execCommand('copy') 没有这个限制,所有现代浏览器都还支持,netclip 用的就是它;但它只接受字符串,没有任何办法把图片字节写进剪贴板。
而 iOS Safari 长按图片的「存储到照片」和 Android Chrome 的「复制图片 / 分享图片」不需要任何 API,效果还更好(<a download> 在 iOS 上永远只能存进「文件」App,进不了相册)。所以 netclip 的做法是:把一张原图的 <img> 放在你拇指底下,然后让开。
这也意味着代码里有一条硬约束:内容区的图片上面不能出现 -webkit-touch-callout: none 或 user-select: none,不能给图片套 <a>,不能盖透明层,不能用 CSS 背景图。 违反了不会报错,只会让 iOS 上的图片彻底没法复制。npm run build 里有一个 check-css 会扫描构建产物,违反就直接构建失败。
如果你确实配了 HTTPS(见下),netclip 会自动探测到,「复制图片」按钮就会出现,长按提示同时消失。
搜索框永远是焦点,直接打字就是过滤。
| 键 | 作用 |
|---|---|
| 任意字符 | 过滤列表 |
⌘V / Ctrl+V |
发送剪贴板(在页面任何地方,不管焦点在哪) |
↑ ↓ |
移动选中项(打字时也能用) |
↵ |
复制选中项;如果搜索没结果,则变成"把搜索词作为新内容发送"(底部会先告诉你) |
⌘↵ |
无条件发送输入框里的文字 |
⌥P |
固定 / 取消固定 |
⌘G |
显示这条内容的二维码(手机扫了直接跳到它) |
⌘O |
在浏览器打开链接 |
⌘S |
下载 |
⌘⌫ |
删除(6 秒内 ⌘Z 撤销) |
⌘K |
操作菜单 |
⌘F |
聚焦并全选搜索框 |
Esc |
清空搜索;已经空了就跳回最新一条 |
Tab |
把焦点移出搜索框(一路 Tab 到预览区就能手动 ⌘A ⌘C,这是保底路径) |
没有"清理"这件事需要你去做。
- 每条未固定的内容都持有一个租约:文本 7 天、图片 3 天(图片更占磁盘)。
- 租约在三种情况下会续期:新建、再次复制同样的内容(此时它会回到列表顶部)、以及你从历史里复制它一次。所以你真正在用的东西不会过期。
- 固定会直接去掉租约,永不过期,也永远不会被容量上限清掉。
- 取消固定会重新发一个完整的租约,而不是恢复那个早就过期的旧的——否则取消固定一条半年前的内容会让它在几毫秒后消失,那看起来就像"这 App 把我数据吃了"。
- 两条兜底:超过 500 条时清最老的未固定项;总大小超过 2 GB 时清最老的未固定图片(清文本几乎不释放磁盘,只会把历史删光)。刚到的东西 5 分钟内绝不会被清。
列表里未固定的行会随着租约倒数缓慢变淡(下限 0.72,保证还看得清)——过期是一种你余光能感觉到的东西,而不是一个需要读的倒计时。
| 变量 | 默认 | 说明 |
|---|---|---|
PORT |
3210 |
|
NETCLIP_BIND |
0.0.0.0 |
|
NETCLIP_DATA_DIR |
容器内 /data,直接 npm start 时是 ./data |
SQLite + 图片都在这里 |
NETCLIP_RETENTION_DAYS |
7 |
文本保留天数 |
NETCLIP_IMAGE_RETENTION_DAYS |
3 |
图片保留天数 |
NETCLIP_MAX_ITEMS |
500 |
|
NETCLIP_MAX_TOTAL_MB |
2048 |
|
NETCLIP_MAX_UPLOAD_MB |
25 |
单个文件上限 |
NETCLIP_MAX_TEXT_KB |
1024 |
单条文本上限 |
NETCLIP_SWEEP_MINUTES |
5 |
清理频率 |
界面语言按浏览器语言自动选中英文;想固定的话在控制台执行 localStorage.setItem('nc.lang','en') 再刷新。
# 把剪贴板发过去(macOS)
nclip() { pbpaste | curl -sT- http://192.168.1.42:3210/api/items; }
# 发一个文件的内容
curl -sT- http://192.168.1.42:3210/api/items < notes.md
# 发一张图
curl -X POST http://192.168.1.42:3210/api/items/file \
-H 'content-type: image/png' -H 'x-filename: shot.png' \
--data-binary @shot.png完整 API:
| 方法 | 路径 | 说明 |
|---|---|---|
GET |
/api/items?q= |
列表(小文本直接内联返回,见下) |
GET |
/api/items/:id |
单条,一定带完整 content |
POST PUT |
/api/items |
{"text":"…"} 或直接 text/plain 原文(收 PUT 是为了让 curl -T 能用) |
POST PUT |
/api/items/file |
原始字节,Content-Type 即 mime,可带 X-Filename |
PATCH |
/api/items/:id |
{"pinned":true} |
POST |
/api/items/:id/copy |
记一次使用并续租约 |
DELETE |
/api/items/:id |
删不存在的 id 也返回 204 |
DELETE |
/api/items |
清空所有未固定项 |
GET |
/api/items/:id/raw ?download=1 |
原图 |
GET |
/api/items/:id/thumb |
320px webp 缩略图 |
GET |
/api/events |
SSE 实时推送 |
GET |
/api/info |
配置、内网地址、在线设备数 |
没有鉴权。 这是刻意的——它跑在你信任的内网里,登录框只会成为每次用它都要付的成本。别把 3210 端口暴露到公网。
列表接口会把小于 32 KB 的文本内容直接内联在响应里(整个响应最多内联 1 MB,超出的按新到旧截断)。
这不是性能优化,是功能能否成立的前提:iOS Safari 只在用户手势里同步执行 execCommand('copy'),任何 await 都会让手势失效。如果点一下还要先发一个请求拿正文再复制,在 iPhone 上就会静默地什么都不发生——而且恰好是在长文本(日志、token、带参数的 URL)这些最需要复制的东西上失败。所以正文必须在你点之前就已经在内存里。
超过 32 KB 的少数条目会走显式的两步:第一下拉取正文(按钮转圈),第二下才复制,按钮文案也会变成「点这里复制」。宁可多一下点击,也不要一次静默失败。
npm install
npm run dev # API :3210 + Vite :3211,会打印手机能访问的地址
npm run build # 构建到 server/public/,并跑 check-css 守卫
npm run test:serve # 另开一个终端:起一个一次性实例(:3299,数据在 /tmp)
npm test # 53 项服务端 + 57 项 UI + 21 项布局测试会清空目标实例,包括已固定项。 所以它们默认只打 :3299(不是真实部署的 :3210),并且发现目标非空时会直接拒绝运行——想强行覆盖得显式加 --force。这两道保险是有代价换来的:我在开发过程中两次把测试打到了正在使用的实例上,删掉了真实数据。
测试分三层:
npm run test:server—— 打真实 HTTP 接口,覆盖搜索折叠、脏数据清洗、去重、租约语义、EXIF 方向、SVG 拒绝、内联阈值、并发去重、流中断不泄漏 fd。npm run test:ui—— 用 jsdom 挂载真实构建产物并操作它:键盘导航、复制(断言execCommand真的被调用)、CJK 搜索、固定、删除撤销、粘贴入库,以及移动端那条图片契约(图片是<img>、没被<a>包住、src指向原图而非缩略图)。npm run test:layout—— 真实 Chromium,iPhone 视口 390×844 + 桌面 1440×900,测量真实盒子:卡片没有塌陷、feed 真的能滚、无横向溢出、长串不撑破卡片、点击目标 ≥44px、搜索框 ≥16px(否则 iOS 会缩放整页)、底栏不遮挡最后一张卡片。
第三层是补出来的:jsdom 不做布局计算,所以它看不见"卡片被压成 4px 高"这类问题——而这恰好在 iPhone 上真实发生过一次。装浏览器:npx playwright install chromium(只装本体,不需要系统依赖)。
技术栈刻意压得很薄:服务端只有 sharp 一个依赖(SQLite 用 Node 内置的 node:sqlite,没有原生编译),前端只有 React + 一个二维码库,样式是手写 CSS 变量。镜像 247 MB。
server/src/
index.js HTTP 入口、优雅退出
routes.js 全部 API
items.js 入库逻辑:去重、清洗、预览截断
db.js schema 和全部 SQL
blobs.js 内容寻址存储、缩略图、孤儿回收
cleanup.js 租约过期 + 容量兜底
events.js SSE 广播
web/src/
App.jsx 外壳、全局粘贴/拖放、深链接、各种浮层
components/ Desktop / Mobile / Preview / Overlays / Icons
lib/clipboard.js ⭐ 所有复制相关的坑都写在这个文件的注释里
lib/store.js 数据层:SSE 合并、乐观更新、延迟删除
styles.css 设计 token + 那份图片 CSS 契约
scripts/
dev.mjs 同时起 API 和 Vite,打印手机可访问的地址
check-css.mjs 图片 CSS 契约的构建期守卫(Docker 构建里也会跑)
test-server.mjs 服务端接口测试
smoke.mjs jsdom 挂载真实构建产物做交互测试
recover-wal.mjs 从 SQLite WAL / 空闲页里捞回被删的记录
restore-rows.mjs 把捞回的记录只插不覆盖地写回库
最后两个是灾难恢复工具:DELETE 提交后,数据在 checkpoint 之前仍然留在 WAL 的页镜像里。反过来说,如果你希望"删了就真的没了",这点值得知道。
配上证书后(NETCLIP_TLS_CERT / NETCLIP_TLS_KEY 目前未实现,最简单的是前面放一个 Caddy 或用 tailscale serve),页面就进入 secure context,「复制图片」按钮会自动出现,还能真正安装成 PWA。
但不建议为此折腾:自签证书要在 iOS 上装描述文件再去「关于本机 → 证书信任设置」里单独打开,大部分人不会走完;而给内网 IP 签 Let's Encrypt 证书又会被路由器的 DNS rebinding 保护挡住。长按存图已经够用了。
永远不要把前端放在公网 HTTPS 域名上去请求内网的 http API——Chrome 142+ 的 Local Network Access 会用一个权限弹窗拦住它,用户拒绝后是静默失败。netclip 永远保持单一 origin。