Skip to content
silentirisPublic

About

LAN clipboard — Raycast-style clipboard history shared across your devices

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

netclip

局域网剪贴板。在电脑上按一次 ⌘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 的页镜像里。反过来说,如果你希望"删了就真的没了",这点值得知道。


可选:HTTPS

配上证书后(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。

About

LAN clipboard — Raycast-style clipboard history shared across your devices

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages