cdrop — 跨 OS 剪贴板与文件传输服务

Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
/*
* CJK 排版 —— 薄壳。核心由聚珍(Juzhencjk-autospace submodule 2.x)提供,
* 本档只负责 cdrop 的实例配置与 React 生命周期下的增量观察。
*
* 上游 libgit.commilitia.net/admin/cjk-autospace.git(聚珍 / Juzhen
* APIcreateJuzhen(options).render(root) / .revert(root)
* 细节:lib/cjk-autospace/{README.md,ARCHITECTURE.md}CSS 必配(视觉尺寸
* 全在 dist/juzhen.css,由 styles/index.css @import 引入)。
*
* 功能分级(聚珍 §6.5):功能分两级,按元素分配。
* 文本级(任意文本片段适用):
* - spacing 中西间隙(CJK ↔ 西文边界插 copy-clean 的 jz-hws
* - jiya 标点挤压(charify 为 jz-char;本项目全角式 → 句内点号不挤,
* 仅承载 justify 单份间距与行端收缩)
* 段落级(依赖行/段布局,仅作用于段落级元素):
* - jinze 禁则避头尾(闭类/点号绑前字、开类绑后字,亦为 justify 载体)
* - orphan 垂悬字避免(段末行保留 ≥2 个 CJK 实义字;上游新功能、默认开)
* - longWord 长英文段 <span lang="en"> 包装(配 CSS hyphens 原生断词)
*
* 分级分配(level.text + data-jz-level):cdrop 是 UI 重的工具,绝大多数是 chrome
* (按钮/徽章/设备名/标签,单行短文,段落级功能有害无益)。故全树默认文本级
* level.text ".juzhen" 命中作用域根),仅真正的流动正文用 data-jz-level="paragraph"
* opt-in 回段落级——消息正文、剪贴板内容、Callout/Toast 正文、Field hint/error、
* 登录说明,均已带全局 .justify 类。失败模式良性:漏标的 prose 只少了段落功能,
* 不会让 chrome 被强加 orphan/jinze。(hanging 标点悬挂已被上游移除:纯负 margin
* 机制级死路。)
*
* 政策:全角式(quanjiao)—— lang.style 全局覆盖聚珍内置逐语言预设(默认
* 简体走开明式)。无 lang 祖先时回退 zh-Hans。html langzh-CN/zh-TW/en-US
* 由聚珍 classify 归类(zh-cn→zh-Hans、zh-tw→zh-Hant、en-us→other)。
*
* 作用域:scope.include ".juzhen" —— 仅处理带 .juzhen 类的子树(#root 已加),
* normalize 与处理均收敛于此域,body 上的非 .juzhen 节点(若有 portal)不受扰。
*
* 增量更新:MutationObserver(childList + subtree) 监听 React 重渲染产生的新
* 节点;render 幂等(聚珍对各 pass 产物打标记,重跑跳过),处理期间临时断开
* observer 避免自反馈循环。
*/
import { createJuzhen } from "cjk-autospace";
// 行内元素三分类(聚珍 §4.1)——按元素的「语义本质」划分,而非看当前内容:
// 透明(styleInlines,如 <strong> / <em>):纯样式,遍历穿越、run 延续、可切分。
// 隔离(isolateSelector):视觉文本块——自带视觉容器(背景/边框)、但内部仍是要
// 正常排版的文字。两端补边界间距、断 run,内部照常处理(spacingjiya)。Badge
// 经全局类 .cjk-isolate 加入;<mark> 高亮同属此类(含在默认 "mark" 里)。
// 整体(pillSelector):不希望样式被改动的部分——code / kbd / 公式,内部对一切 pass
// 不可见、禁则整体绑定(改内部间距会破坏代码/公式语义)。
const jz = createJuzhen({
lang: { default: "zh-Hans", style: "quanjiao" },
scope: { include: ".juzhen" },
// 全树默认文本级(命中作用域根 .juzhen);prose 各自 data-jz-level="paragraph"
// 覆盖回段落级。见上「分级分配」。
level: { text: ".juzhen" },
pillSelector: "code, kbd",
isolateSelector: "mark, .cjk-isolate",
});
let observer: MutationObserver | null = null;
let pending = false;
function runShim(): void
{
observer?.disconnect();
try { jz.render(document.body); }
finally
{
observer?.observe(document.body, { childList: true, subtree: true });
}
}
function schedule(): void
{
if (pending) { return; }
pending = true;
queueMicrotask(() =>
{
pending = false;
runShim();
});
}
export function startCjkAutospace(): () => void
{
runShim();
observer = new MutationObserver(schedule);
observer.observe(document.body, { childList: true, subtree: true });
return () =>
{
observer?.disconnect();
observer = null;
};
}
+45
View File
@@ -0,0 +1,45 @@
import { t } from "../i18n";
// 字节数→可读字符串(语言无关,单位是国际通用 SI 简写)。
export function formatBytes(bytes: number): string
{
if (bytes < 1024) { return `${bytes} B`; }
if (bytes < 1024 * 1024) { return `${( bytes / 1024 ).toFixed(1)} KB`; }
if (bytes < 1024 * 1024 * 1024) { return `${( bytes / 1024 / 1024 ).toFixed(1)} MB`; }
return `${( bytes / 1024 / 1024 / 1024 ).toFixed(2)} GB`;
}
// 设备名称全局限制为 ASCII:名字要塞进 X-Device-Name 这个 HTTP 头,非 ASCII 在
// 浏览器 fetch 里会直接抛错、也无法可靠传输,故输入处校验、传输前兜底剥离。
const ASCII_DEVICE_NAME = /^[\x20-\x7E]*$/;
// isAsciiDeviceName 判断名字是否仅由可打印 ASCII 组成(输入校验用)。
export function isAsciiDeviceName(name: string): boolean
{
return ASCII_DEVICE_NAME.test(name);
}
// toAsciiDeviceName 剥掉非可打印 ASCII 并 trim,与后端 sanitizeDeviceName 对齐——
// 给旧的非 ASCII 名字兜底,避免设进 X-Device-Name 头时 fetch 抛错。
export function toAsciiDeviceName(name: string): string
{
let out = "";
for (const ch of name)
{
const c = ch.codePointAt(0) ?? 0;
if (c >= 0x20 && c <= 0x7e) { out += ch; }
}
return out.trim();
}
// 相对时间("刚刚" / "5 分钟前")。> 1 天回退到本地化的绝对日期时间。
export function formatRelative(epochSec: number): string
{
if (!epochSec) { return ""; }
const sec = Math.floor(Date.now() / 1000) - epochSec;
if (sec < 5) { return t("time.justNow"); }
if (sec < 60) { return t("time.secondsAgo", { n: sec }); }
if (sec < 3600) { return t("time.minutesAgo", { n: Math.floor(sec / 60) }); }
if (sec < 86400) { return t("time.hoursAgo", { n: Math.floor(sec / 3600) }); }
return new Date(epochSec * 1000).toLocaleString();
}