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
@@ -0,0 +1,168 @@
import type { CandidateBreakdown, TransferRecord } from "../../store";
import { t } from "../../i18n";
import { formatBytes } from "../../utils/format";
import s from "./TransferDetails.module.css";
export interface TransferDetailsProps
{
record: TransferRecord;
}
// 二级详情面板。常态下被折叠在 TransferRow 末端「详情」之后,仅工程视角访问。
// 排版克制:mono 字号、文本对齐为主,不做花哨可视化。
export function TransferDetails(props: TransferDetailsProps)
{
const { record: tr } = props;
const st = tr.iceStats;
return (
<div className={s.root}>
{/* —— 顶部一行 chip:模式 + 状态 + 文件大小 —— */}
<div className={s.headline}>
{tr.mode && (
<span className={[
s.modeBadge,
tr.mode === "p2p" ? s.modeP2P : s.modeRelay,
].join(" ")}>
{tr.mode === "p2p" ? t("transfer.mode.p2p") : t("transfer.mode.relay")}
</span>
)}
<KV k="state" v={tr.state} />
<KV k="size" v={formatBytes(tr.fileSize)} />
<KV k="started" v={isoSeconds(tr.startedAt)} />
</div>
{/* —— ICE 状态 —— */}
{st && (
<>
<Section title={t("transfer.debug.iceGathering") + " / " + t("transfer.debug.iceConnection")}>
<div className={s.kvRow}>
<span className={s.k}>{t("transfer.debug.iceGathering")}</span>
<span className={s.v}>{st.gathering}</span>
<span className={s.k}>{t("transfer.debug.iceConnection")}</span>
<span className={s.v}>{st.connection}</span>
</div>
</Section>
{/* —— 候选明细表格 —— */}
<Section title="候选">
<CandidateTable
local={st.candidates.local}
remote={st.candidates.remote}
/>
</Section>
{/* —— 选中候选对 —— */}
<Section title={t("transfer.debug.selectedPair")}>
<div className={s.pairRow}>
<span className={s.pairLabel}>pair</span>
{st.selectedPair ? (
<>
<span className={s.pairHas}>{st.selectedPair.local}</span>
<span className={s.pairArrow}></span>
<span className={s.pairHas}>{st.selectedPair.remote}</span>
</>
) : (
<span className={s.pairNone}>{t("transfer.debug.noPair")}</span>
)}
</div>
</Section>
{/* —— DataChannel —— */}
{st.dataChannel && (
<Section title={t("transfer.debug.dataChannel")}>
<div className={s.kvRow}>
<span className={s.k}>readyState</span>
<span className={s.v}>{st.dataChannel.readyState}</span>
<span className={s.k}>buffered</span>
<span className={s.v}>{formatBytes(st.dataChannel.bufferedAmount)}</span>
</div>
</Section>
)}
</>
)}
{/* —— phase 当前 —— */}
{tr.phase && (
<Section title="阶段">
<div className={s.kvRow}>
<span className={s.k}>phase</span>
<span className={s.v}>{tr.phase}</span>
{tr.lastProgressAt && (
<>
<span className={s.k}>last tick</span>
<span className={s.v}>{isoSeconds(Math.floor(tr.lastProgressAt / 1000))}</span>
</>
)}
</div>
</Section>
)}
</div>
);
}
function Section(props: { title: string; children: React.ReactNode })
{
return (
<div className={s.section}>
<div className={s.sectionTitle}>{props.title}</div>
{props.children}
</div>
);
}
function KV(props: { k: string; v: string | number })
{
return (
<span style={{ display: "inline-flex", gap: 4 }}>
<span className={s.k}>{props.k}</span>
<span className={s.v}>{props.v}</span>
</span>
);
}
interface CandidateTableProps
{
local: CandidateBreakdown;
remote: CandidateBreakdown;
}
function CandidateTable(props: CandidateTableProps)
{
const { local, remote } = props;
const cols = [ "host", "mdns", "srflx", "prflx", "relay" ] as const;
return (
<div className={s.candTable}>
<span className={s.candHead}></span>
{cols.map((c) => <span key={c} className={s.candHead}>{c}</span>)}
<span className={s.candCell}></span>
{cols.map((c) => (
<span
key={"l-" + c}
className={[ s.candCell, local[c] === 0 ? s.candZero : "" ].join(" ")}
>
{local[c]}
</span>
))}
<span className={s.candCell}></span>
{cols.map((c) => (
<span
key={"r-" + c}
className={[ s.candCell, remote[c] === 0 ? s.candZero : "" ].join(" ")}
>
{remote[c]}
</span>
))}
</div>
);
}
// 把 unix 秒转 "HH:MM:SS" 本地时间,更适合诊断面板对照浏览器日志时间戳。
function isoSeconds(secs: number): string
{
const d = new Date(secs * 1000);
if (Number.isNaN(d.getTime())) { return String(secs); }
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}