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
+140
View File
@@ -0,0 +1,140 @@
import type { TransferRecord, ThemeMode, User } from "./types";
// DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免
// history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归
// 一:保留断点位置便于诊断,phase 字段告诉用户"卡在哪个阶段失败"。
export function normalizeTerminal(cur: TransferRecord, finalState: string): TransferRecord
{
const result: TransferRecord = { ...cur, state: finalState };
if (finalState !== "DONE") { return result; }
result.phase = undefined;
result.bytesTransferred = cur.fileSize;
if (cur.iceStats?.dataChannel)
{
result.iceStats = {
...cur.iceStats,
dataChannel: { ...cur.iceStats.dataChannel, bufferedAmount: 0 },
};
}
return result;
}
// ---- storage keys & readers ----
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
export const USER_KEY = "cdrop.user";
export const SELF_DEVICE_KEY = "cdrop.self_device";
export const THEME_KEY = "cdrop.theme";
// 桌面壳的跨重启会话恢复:Go 侧在启动时从持久化文件读出 session,注入到
// index.html 的 window.__CDROP_SESSION__,因为 wails:// 自定义 scheme 下 WKWebView
// 的 localStorage / sessionStorage 都不跨重启存活(custom-scheme origin 无持久化
// 存储)。注入的全局在 bundle 执行前就绪,store 初始化即可同步水合。浏览器无此
// 注入,回落到 sessionStorage(刷新存活、关标签即清;refresh_token 仅在内存)。
// 注入的 session 不含 refresh_token——refresh_token 是长寿命密钥,只在 Go 进程里,
// 绝不进入 WebView(凭据策略见 desktop/platform/session.go)。
interface InjectedSession
{
access_token: string;
user: { id: string; name: string; avatar?: string };
}
interface InjectedBoot
{
session?: InjectedSession | null;
device_name?: string;
api_base?: string;
device_type?: string;
}
function injectedBoot(): InjectedBoot | null
{
if (typeof window === "undefined") { return null; }
return (window as unknown as { __CDROP_BOOT__?: InjectedBoot }).__CDROP_BOOT__ ?? null;
}
function injectedSession(): InjectedSession | null
{
const s = injectedBoot()?.session;
return s && typeof s.access_token === "string" && s.access_token ? s : null;
}
// readInjectedDeviceName 在桌面壳里返回启动注入的设备名,让重启后路由守卫不再跳
// /setup(设备名同样存在 Go 侧、随 session 一并注入);浏览器无注入返回 null。
export function readInjectedDeviceName(): string | null
{
const name = injectedBoot()?.device_name;
return name && name.length > 0 ? name : null;
}
// readInjectedApiBase 返回 /api 请求要前缀的来源。Windows 桌面壳注入 loopback
// HTTP 代理地址(http://127.0.0.1:PORT),让 SSE 走 WebView2 正常网络栈而非会缓冲
// 的 custom schememacOS 与浏览器无此注入、返回 ""(相对 URL、同源)。
export function readInjectedApiBase(): string
{
const base = injectedBoot()?.api_base;
return base && base.length > 0 ? base : "";
}
// readInjectedDeviceType 返回桌面壳注入的客户端类型(macos / windows / linux),
// 前端经 X-Device-Type 透传给后端,使设备列表不再把桌面端都标成 browser;浏览器
// 无注入返回 "browser"。
export function readInjectedDeviceType(): string
{
const t = injectedBoot()?.device_type;
return t && t.length > 0 ? t : "browser";
}
export function readSessionAccess(): string | null
{
const inj = injectedSession();
if (inj) { return inj.access_token; }
if (typeof window === "undefined") { return null; }
return window.sessionStorage.getItem(ACCESS_TOKEN_KEY);
}
// user.id / user.name 不是机密(公开显示在 UI、JWT 里可解码),随 access_token
// 一并持久化,才能让刷新 / 重启后路由守卫不再误跳 /login。
export function readSessionUser(): User | null
{
const inj = injectedSession();
if (inj?.user && typeof inj.user.id === "string" && typeof inj.user.name === "string")
{
return { id: inj.user.id, name: inj.user.name, avatar: inj.user.avatar };
}
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(USER_KEY);
if (!raw) { return null; }
try
{
const parsed = JSON.parse(raw) as unknown;
if (
parsed
&& typeof parsed === "object"
&& typeof (parsed as User).id === "string"
&& typeof (parsed as User).name === "string"
)
{
return parsed as User;
}
return null;
}
catch
{
return null;
}
}
export function readSelfDevice(): string | null
{
if (typeof window === "undefined") { return null; }
return window.localStorage.getItem(SELF_DEVICE_KEY);
}
export function readTheme(): ThemeMode
{
if (typeof window === "undefined") { return "system"; }
const raw = window.localStorage.getItem(THEME_KEY);
if (raw === "light" || raw === "dark" || raw === "system") { return raw; }
return "system";
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from "zustand";
import type { AppState } from "./types";
import { createAuthSlice } from "./slices/auth";
import { createDevicesSlice } from "./slices/devices";
import { createTransferSlice } from "./slices/transfer";
import { createMessagesSlice } from "./slices/messages";
import { createClipboardSlice } from "./slices/clipboard";
import { createUiSlice } from "./slices/ui";
import { createThemeSlice } from "./slices/theme";
import { createLocaleSlice } from "./slices/locale";
export const useAppStore = create<AppState>()((...a) =>
({
...createAuthSlice(...a),
...createDevicesSlice(...a),
...createTransferSlice(...a),
...createMessagesSlice(...a),
...createClipboardSlice(...a),
...createUiSlice(...a),
...createThemeSlice(...a),
...createLocaleSlice(...a),
}));
// 重新导出所有 type 与 helper,保证现有 `from "./store"` import 仍可解析。
export type {
AppState,
AuthMode,
ThemeMode,
User,
DeviceInfo,
TransferPhase,
CandidateBreakdown,
IceStats,
TransferRecord,
MessageRecord,
ClipboardState,
} from "./types";
+52
View File
@@ -0,0 +1,52 @@
import type { StateCreator } from "zustand";
import type { AppState, AuthMode } from "../types";
import {
ACCESS_TOKEN_KEY,
USER_KEY,
readSessionAccess,
readSessionUser,
} from "../helpers";
export type AuthSlice = Pick<
AppState,
"authMode" | "accessToken" | "refreshToken" | "user" | "setAuth" | "clearAuth"
>;
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
// 跨重启的持久化由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责,
// 因为 wails:// scheme 下 WebView 存储不存活。sessionStorage 仅用于浏览器同 tab
// 刷新;桌面在单次运行内的连续性由内存中的 store 保证。
// refreshToken 在 JS 里恒为 null:浏览器只在内存持有(不落盘),桌面则完全不持有
// refresh 走 Go 内部,refresh_token 只在 Go 进程)——见凭据策略。
export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set) =>
({
authMode: initialAuthMode,
accessToken: readSessionAccess(),
refreshToken: null,
user: readSessionUser(),
setAuth: (a) =>
{
if (typeof window !== "undefined")
{
window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken);
window.sessionStorage.setItem(USER_KEY, JSON.stringify(a.user));
}
set({
accessToken: a.accessToken,
refreshToken: a.refreshToken ?? null,
user: a.user,
});
},
clearAuth: () =>
{
if (typeof window !== "undefined")
{
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
window.sessionStorage.removeItem(USER_KEY);
}
set({ accessToken: null, refreshToken: null, user: null });
},
});
+10
View File
@@ -0,0 +1,10 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
export type ClipboardSlice = Pick<AppState, "clipboard" | "setClipboard">;
export const createClipboardSlice: StateCreator<AppState, [], [], ClipboardSlice> = (set) =>
({
clipboard: null,
setClipboard: (c) => set({ clipboard: c }),
});
+35
View File
@@ -0,0 +1,35 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { SELF_DEVICE_KEY, readSelfDevice, readInjectedDeviceName } from "../helpers";
// 这一片把"设备"语义全部合到一起:本机设备名 + Hub(SSE 连接 / 在线列表)。
// 它们都围绕"我有哪些设备、谁在线"这一问题。
export type DevicesSlice = Pick<
AppState,
"selfDeviceName" | "setSelfDeviceName"
| "sseConnected" | "sseReconnecting" | "devices"
| "setSseConnected" | "setSseReconnecting" | "setDevices"
>;
export const createDevicesSlice: StateCreator<AppState, [], [], DevicesSlice> = (set) =>
({
// 桌面壳优先用启动注入的设备名(localStorage 在 wails:// 下不跨重启存活);
// 浏览器回落到 localStorage。
selfDeviceName: readInjectedDeviceName() ?? readSelfDevice(),
setSelfDeviceName: (name) =>
{
if (typeof window !== "undefined")
{
if (name) { window.localStorage.setItem(SELF_DEVICE_KEY, name); }
else { window.localStorage.removeItem(SELF_DEVICE_KEY); }
}
set({ selfDeviceName: name });
},
sseConnected: false,
sseReconnecting: false,
devices: [],
setSseConnected: (v) => set({ sseConnected: v }),
setSseReconnecting: (v) => set({ sseReconnecting: v }),
setDevices: (d) => set({ devices: d }),
});
+17
View File
@@ -0,0 +1,17 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { getLocale, setLocale as i18nSetLocale } from "../../i18n";
export type LocaleSlice = Pick<AppState, "locale" | "setLocale">;
// 初始值取自 i18n 模块(已从 localStorage 还原);setLocale 同步到 i18n
// 模块(更新 currentLocale + 写 localStorage)。
export const createLocaleSlice: StateCreator<AppState, [], [], LocaleSlice> = (set) =>
({
locale: getLocale(),
setLocale: (l) =>
{
i18nSetLocale(l);
set({ locale: l });
},
});
+27
View File
@@ -0,0 +1,27 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
export type MessagesSlice = Pick<
AppState,
"messages" | "addMessage" | "removeMessage" | "clearMessages"
>;
export const createMessagesSlice: StateCreator<AppState, [], [], MessagesSlice> = (set) =>
({
messages: [],
addMessage: (m) =>
{
set((s) => ({ messages: [ m, ...s.messages ].slice(0, 200) }));
},
removeMessage: (id) =>
{
set((s) => ({ messages: s.messages.filter((mm) => mm.id !== id) }));
},
clearMessages: () =>
{
set({ messages: [] });
},
});
+18
View File
@@ -0,0 +1,18 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { THEME_KEY, readTheme } from "../helpers";
export type ThemeSlice = Pick<AppState, "theme" | "setTheme">;
export const createThemeSlice: StateCreator<AppState, [], [], ThemeSlice> = (set) =>
({
theme: readTheme(),
setTheme: (t) =>
{
if (typeof window !== "undefined")
{
window.localStorage.setItem(THEME_KEY, t);
}
set({ theme: t });
},
});
+49
View File
@@ -0,0 +1,49 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
import { normalizeTerminal } from "../helpers";
export type TransferSlice = Pick<
AppState,
"activeTransfers" | "history" | "upsertTransfer" | "completeTransfer" | "removeTransfer"
>;
export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice> = (set) =>
({
activeTransfers: {},
history: [],
upsertTransfer: (t) =>
{
set((s) =>
({
activeTransfers: { ...s.activeTransfers, [t.sessionId]: t },
}));
},
completeTransfer: (sessionId, finalState) =>
{
set((s) =>
{
const cur = s.activeTransfers[sessionId];
if (!cur) { return s; }
const finalRec = normalizeTerminal(cur, finalState);
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,
history: [ finalRec, ...s.history ].slice(0, 20),
});
});
},
removeTransfer: (sessionId) =>
{
set((s) =>
{
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,
history: s.history.filter((h) => h.sessionId !== sessionId),
});
});
},
});
+17
View File
@@ -0,0 +1,17 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../types";
export type UiSlice = Pick<
AppState,
"selectedDevice" | "selectedFile" | "setSelectedDevice" | "setSelectedFile"
>;
// 当前 UI slice 仅承载首页 composer 所需的"选中设备"与"选中文件"。后续若
// 需要更多 UI 临时态(开关、临时表单、拖拽态),都收拢在此处。
export const createUiSlice: StateCreator<AppState, [], [], UiSlice> = (set) =>
({
selectedDevice: null,
selectedFile: null,
setSelectedDevice: (n) => set({ selectedDevice: n }),
setSelectedFile: (f) => set({ selectedFile: f }),
});
+169
View File
@@ -0,0 +1,169 @@
import type { Locale } from "../i18n";
// Auth shape — see FRONTEND_DESIGN.md §6.
// access_token lives in sessionStorage so a tab refresh keeps the user logged in
// (PROJECT_BRIEF.md §2). refresh_token stays in the store only — never in storage —
// to keep XSS exposure as small as possible.
export interface User
{
id: string;
name: string;
/** Avatar URL from OIDC `picture` / Casdoor `avatar` claim. 空 / 加载失败时
UI 回退到字母方块。 */
avatar?: string;
}
export interface DeviceInfo
{
name: string;
type: string;
online: boolean;
lastSeen: number;
}
export type AuthMode = "dev" | "prod";
export type ThemeMode = "light" | "dark" | "system";
// 客户端侧细颗粒度阶段,与服务端的 statePENDING/ACCEPTED/...)正交;
// 用来把"等待中/传输中"展开成具体正在做什么,配合 lastProgressAt 区分卡住。
export type TransferPhase =
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
| "waiting_accept" // sender 等 /acceptreceiver 等自己 /accept 回执
| "ice_gathering" // RTCPeerConnection 正在收集候选
| "ice_checking" // 候选已交换,连通性检查中
| "ice_connected" // ICE 通过,DC 即将打开
| "dc_open" // DataChannel 打开但还没开始传字节
| "transferring" // 字节正在流动
| "completing" // sender 已 send "done" / receiver 收到 "done",等终态
| "fallback_pending" // 30s ICE 超时,已 POST /fallback,等 RELAY_ACTIVE 信号
| "relay_uploading" // 走 Relay 路径,sender 上传 chunk
| "relay_downloading"; // 走 Relay 路径,receiver 流式 GET
// ICE 候选分类,用来回答"为什么 P2P 没建好"
// host = 本机真实地址;mdns = 浏览器隐私混淆的 .local 主机名(同内网必须能解析);
// srflx = STUN 反射的公网地址;prflx = peer-reflexiverelay = 通过 TURN 中转。
export interface CandidateBreakdown
{
host: number;
mdns: number;
srflx: number;
prflx: number;
relay: number;
}
export interface IceStats
{
gathering: RTCIceGatheringState;
connection: RTCIceConnectionState;
candidates: { local: CandidateBreakdown; remote: CandidateBreakdown };
selectedPair?: { local: string; remote: string }; // 形如 "host/udp" "relay/tcp"
/** DataChannel 即时状态:诊断 sender 卡在 bufferedamountlow 用 */
dataChannel?: {
readyState: RTCDataChannelState;
bufferedAmount: number;
};
}
export interface TransferRecord
{
sessionId: string;
direction: "outgoing" | "incoming";
fileName: string;
fileSize: number;
state: string;
/**
* "p2p" | "relay" | undefined.
* Server emits this on the P2P_ACTIVE / RELAY_ACTIVE transition; the
* client carries it forward into terminal states for history display.
*/
mode?: string;
peerName: string;
startedAt: number;
// ---- 客户端实时遥测 ------------------------------------------------------
phase?: TransferPhase;
/** 本端已传字节:outgoing 是 sender 已 sendincoming 是 receiver 已收 */
bytesTransferred?: number;
/** 上一次 bytesTransferred 增长的时刻(ms epoch),UI 据此判 stalled */
lastProgressAt?: number;
iceStats?: IceStats;
}
// In-memory only — cleared on tab close, or via UI clear.
export interface MessageRecord
{
id: string;
direction: "incoming" | "outgoing";
peerName: string;
text: string;
sentAt: number;
}
export interface ClipboardState
{
content: string;
contentType: string;
sourceDevice: string;
updatedAt: number;
// version 是服务端每用户单调递增的版本号;条件 GET 的 ETag 用它(而非墙钟
// updated_at),故同秒两写也能正确判变更。
version: number;
}
export interface AppState
{
// ---- auth slice ----
authMode: AuthMode;
accessToken: string | null;
refreshToken: string | null;
user: User | null;
setAuth: (a: { accessToken: string; refreshToken?: string; user: User }) => void;
clearAuth: () => void;
// ---- device slice ----
selfDeviceName: string | null;
setSelfDeviceName: (name: string | null) => void;
// ---- hub slice ----
sseConnected: boolean;
sseReconnecting: boolean; // several straight SSE failures; UI shows a "reconnecting" banner — the loop keeps retrying, never gives up
devices: DeviceInfo[];
setSseConnected: (v: boolean) => void;
setSseReconnecting: (v: boolean) => void;
setDevices: (d: DeviceInfo[]) => void;
// ---- transfer slice ----
activeTransfers: Record<string, TransferRecord>;
history: TransferRecord[];
upsertTransfer: (t: TransferRecord) => void;
completeTransfer: (sessionId: string, finalState: string) => void;
/** 从列表移除一条记录(删除已完成的历史项 / 兜底清掉残留 active 项)。 */
removeTransfer: (sessionId: string) => void;
// ---- messages slice ----
messages: MessageRecord[];
addMessage: (m: MessageRecord) => void;
removeMessage: (id: string) => void;
clearMessages: () => void;
// ---- clipboard slice ----
clipboard: ClipboardState | null;
setClipboard: (c: ClipboardState | null) => void;
// ---- ui slice ----
selectedDevice: string | null;
selectedFile: File | null;
setSelectedDevice: (n: string | null) => void;
setSelectedFile: (f: File | null) => void;
// ---- theme slice ----
// 'system' 时移除 data-theme 让 @media (prefers-color-scheme) 接管;
// 'light' / 'dark' 时写死 attribute。持久化到 localStorage。
theme: ThemeMode;
setTheme: (t: ThemeMode) => void;
// ---- locale slice ----
locale: Locale;
setLocale: (l: Locale) => void;
}