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
+95
View File
@@ -0,0 +1,95 @@
import { useAppStore } from "../store";
import { refreshTokens } from "../features/auth/auth";
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
import { toAsciiDeviceName } from "../utils/format";
import { t } from "../i18n";
// /api 请求的来源前缀:桌面 Windows 注入 loopback 代理地址(绕开会缓冲 SSE 的
// custom scheme),macOS / 浏览器为空(同源相对 URL)。读一次注入值即定。
const API_BASE = readInjectedApiBase();
// 客户端类型(browser / macos / windows / linux),随每个请求发给后端登记设备。
const DEVICE_TYPE = readInjectedDeviceType();
function withBase(input: RequestInfo): RequestInfo
{
if (typeof input === "string" && API_BASE && input.startsWith("/"))
{
return API_BASE + input;
}
return input;
}
// Centralised fetch wrapper:
// - in dev, attaches Authorization: Bearer <VITE_CDROP_DEV_TOKEN> + X-Dev-User
// - in prod, attaches Authorization: Bearer <accessToken from store>
// - always sets X-Device-Name from the store
// - in prod, on a 401 response transparently refreshes the access token via
// /api/auth/refresh and retries once
//
// Returns the fetch Response untouched so callers can drive streaming bodies
// (SSE, relay GET stream) themselves.
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
let r = await doFetch(input, init);
if (r.status === 401 && useAppStore.getState().authMode === "prod")
{
const ok = await refreshTokens();
if (ok)
{
r = await doFetch(input, init);
}
}
return r;
}
async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
const state = useAppStore.getState();
const headers = new Headers(init?.headers ?? {});
if (state.authMode === "dev")
{
const devToken = import.meta.env.VITE_CDROP_DEV_TOKEN;
if (!devToken)
{
throw new Error(t("errors.devTokenMissing"));
}
headers.set("Authorization", `Bearer ${devToken}`);
if (state.user?.id)
{
headers.set("X-Dev-User", state.user.id);
}
}
else
{
if (!state.accessToken)
{
throw new Error(t("errors.noAccessToken"));
}
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
if (state.selfDeviceName)
{
// Device names are ASCII-only; coerce here so a legacy non-ASCII name
// can't make Headers.set throw (the server sanitises identically).
const safeName = toAsciiDeviceName(state.selfDeviceName);
if (safeName) { headers.set("X-Device-Name", safeName); }
}
headers.set("X-Device-Type", DEVICE_TYPE);
return fetch(withBase(input), { ...init, headers });
}
export async function apiJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T>
{
const r = await apiFetch(input, init);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
if (r.status === 204) { return undefined as T; }
return ( await r.json() ) as T;
}
+289
View File
@@ -0,0 +1,289 @@
// Wails 桌面壳的桥接层:仅在桌面 WebView 中可用。浏览器里 isDesktop() 返回
// false,所有调用点都回退到既有的 Web 行为,因此本模块对纯 Web 构建零影响
// ——同一份代码同时部署到浏览器与桌面。
import { useAppStore, type User } from "../store";
// 桌面会话视图:不含 refresh_token——长寿命密钥只在 Go 进程,绝不进 JS(凭据策略
// 见 desktop/platform/session.go)。登录 emit、刷新返回、启动注入都用这个形状。
interface DesktopSession
{
access_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
}
// Wails v2 注入的全局运行时:事件订阅在此。
interface WailsRuntime
{
EventsOn: (event: string, cb: (...data: unknown[]) => void) => () => void;
}
// 桌面本地配置(~/Library/Application Support/cdrop/config.json)。字段与 Go
// platform.DesktopConfig 的 json tag 一致。
export interface DesktopConfig
{
clipboard_sync_enabled: boolean;
launch_at_login: boolean;
download_dir: string; // 空=系统下载目录(Go 侧 ResolveDownloadDir 解析)
}
// Go 侧 App 结构体导出的方法(window.go.main.App.*)。
interface DesktopBridge
{
StartLogin: () => void;
Refresh: () => Promise<DesktopSession>;
DeviceName: () => Promise<string>;
ApplyRemoteClipboard: (content: string, sourceDevice: string) => Promise<void>;
GetSettings: () => Promise<DesktopConfig>;
SaveSettings: (cfg: DesktopConfig) => Promise<void>;
ClearSession: () => Promise<void>;
SetDeviceName: (name: string) => Promise<void>;
// data 为 base64Wails 把 JS 字符串作为 JSON 字符串编码,Go 端 []byte 参数按
// base64 解码。返回实际写入的绝对路径。
SaveDownload: (name: string, data: string) => Promise<string>;
ChooseDownloadDir: (title: string) => Promise<string>;
EffectiveDownloadDir: () => Promise<string>;
}
// 桌面剪贴板自动同步开关,默认开;由设置页通过 setClipboardSyncEnabled 调整。
// 同时门控上行(原生复制 → 上传)与下行(云端更新 → 写回原生),关闭即两端静默。
let clipboardSyncEnabled = true;
export function setClipboardSyncEnabled(enabled: boolean): void
{
clipboardSyncEnabled = enabled;
}
export function isClipboardSyncEnabled(): boolean
{
return clipboardSyncEnabled;
}
function runtime(): WailsRuntime | null
{
const rt = (window as unknown as { runtime?: WailsRuntime }).runtime;
return rt ?? null;
}
function bridge(): DesktopBridge | null
{
const go = (window as unknown as { go?: { main?: { App?: DesktopBridge } } }).go;
return go?.main?.App ?? null;
}
// isDesktop 同时要求运行时与绑定都在场,避免半初始化状态下误判。
export function isDesktop(): boolean
{
return typeof window !== "undefined" && runtime() !== null && bridge() !== null;
}
function toUser(u: DesktopSession["user"]): User
{
return { id: u.id, name: u.name, avatar: u.avatar || undefined };
}
// startDesktopLogin 触发 Go 侧 loopback PKCE 流程,等待 oauth:success / oauth:error
// 事件;成功后把 token + user 写进 store,并以主机名补齐设备名(跳过 /setup)。
export async function startDesktopLogin(): Promise<void>
{
const rt = runtime();
const app = bridge();
if (!rt || !app) { throw new Error("desktop bridge unavailable"); }
await new Promise<void>((resolve, reject) =>
{
let offOk: (() => void) | null = null;
let offErr: (() => void) | null = null;
const cleanup = () =>
{
if (offOk) { offOk(); }
if (offErr) { offErr(); }
};
offOk = rt.EventsOn("oauth:success", (...data) =>
{
cleanup();
const res = data[0] as DesktopSession;
// 不带 refreshToken:桌面的 refresh_token 只在 GoJS 永不持有。
useAppStore.getState().setAuth({
accessToken: res.access_token,
user: toUser(res.user),
});
resolve();
});
offErr = rt.EventsOn("oauth:error", (...data) =>
{
cleanup();
const detail = (data[0] as { detail?: string })?.detail ?? "登录失败";
reject(new Error(detail));
});
app.StartLogin();
});
await ensureDeviceName();
}
// ensureDeviceName 在桌面端用主机名补齐 selfDeviceName(仅当尚未命名),让路由
// 守卫不再跳转 /setup。失败静默——用户仍可在设置里手动命名。
export async function ensureDeviceName(): Promise<void>
{
const app = bridge();
if (!app) { return; }
if (useAppStore.getState().selfDeviceName) { return; }
try
{
const name = await app.DeviceName();
if (name) { useAppStore.getState().setSelfDeviceName(name); }
}
catch { /* 保留为空,回退到 /setup 手动命名 */ }
}
// initDesktopMenuBridge 把 Go 菜单栏发来的事件接到前端(如「设置」→ 路由跳转)。
// 仅桌面端生效;浏览器里 isDesktop()=false 直接返回,对 Web 零影响。
export function initDesktopMenuBridge(onSettings: () => void): void
{
const rt = runtime();
if (!isDesktop() || !rt) { return; }
rt.EventsOn("menu:settings", () => onSettings());
}
// onNativeClipboard 订阅 Go 的本机剪贴板复制事件(上行)。回调里上层决定是否
// 上传(既有 uploadClipboard 链路:鉴权 + 去重 + store)。同步关闭时不回调。
// 返回取消订阅函数;浏览器里为 no-op。
export function onNativeClipboard(handler: (text: string) => void): () => void
{
const rt = runtime();
if (!isDesktop() || !rt) { return () => { /* no-op */ }; }
return rt.EventsOn("clipboard:native", (...data) =>
{
if (!clipboardSyncEnabled) { return; }
const text = typeof data[0] === "string" ? data[0] : "";
if (text) { handler(text); }
});
}
// writeNativeClipboard 把来自其他设备的云端更新写回本机原生剪贴板(下行)。
// 仅桌面 + 同步开启 + 非空时触发;自身来源由上层与 Go 侧 Monitor 双重过滤。
export function writeNativeClipboard(content: string, sourceDevice: string): void
{
const app = bridge();
if (!isDesktop() || !app || !clipboardSyncEnabled || !content) { return; }
void app.ApplyRemoteClipboard(content, sourceDevice);
}
// loadDesktopSettings 读取本地配置并把剪贴板同步开关应用到本模块的门控。
// 返回配置供设置页初始化;浏览器 / 失败时返回 null。应在应用启动时调用一次。
export async function loadDesktopSettings(): Promise<DesktopConfig | null>
{
const app = bridge();
if (!app) { return null; }
try
{
const cfg = await app.GetSettings();
setClipboardSyncEnabled(cfg.clipboard_sync_enabled);
return cfg;
}
catch
{
return null;
}
}
// saveDesktopSettings 持久化配置并立即应用同步开关(launch-at-login 的副作用在
// Go 侧落地)。抛错交由设置页提示。
export async function saveDesktopSettings(cfg: DesktopConfig): Promise<void>
{
const app = bridge();
if (!app) { return; }
setClipboardSyncEnabled(cfg.clipboard_sync_enabled);
await app.SaveSettings(cfg);
}
// clearDesktopSession 登出时让 Go 删除持久化的 session 文件,使下次启动不再注入
// 已登录态(fire-and-forget)。浏览器里 bridge() 为 null,直接返回。
export function clearDesktopSession(): void
{
const app = bridge();
if (!app) { return; }
void app.ClearSession();
}
// persistDesktopDeviceName 让 Go 持久化重命名后的设备名(写入本地配置),使其跨
// 重启存活并随启动注入;浏览器为 no-op。fire-and-forget。
export function persistDesktopDeviceName(name: string): void
{
const app = bridge();
if (!app) { return; }
void app.SetDeviceName(name);
}
// desktopRefresh 触发 Go 内部刷新(无参——refresh_token 只在 GoJS 不传也不持有)。
// Go 用自己的 refresh_token 换新 access_tokenpublic-client,不带 secret),返回
// 不含 refresh_token 的会话视图;失败返回 null 让上层照旧清登录态。
export async function desktopRefresh(): Promise<DesktopSession | null>
{
const app = bridge();
if (!app) { return null; }
try
{
return await app.Refresh();
}
catch
{
return null;
}
}
// bytesToBase64 分块走 btoa,避免一次 String.fromCharCode(...大数组) 触发调用栈溢出。
function bytesToBase64(bytes: Uint8Array): string
{
let binary = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK)
{
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
// saveIncomingFileDesktop 把接收到的文件交给 Go 写入配置的下载目录(绕开 WebView
// 默认下载,使路径可配置)。返回实际写入的绝对路径;桥接缺失或写盘失败时抛错,
// 由调用方回退到浏览器下载。整文件经 base64 过桥——大小折中见落盘机制选型。
export async function saveIncomingFileDesktop(name: string, blob: Blob): Promise<string>
{
const app = bridge();
if (!app) { throw new Error("desktop bridge unavailable"); }
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
return app.SaveDownload(name, b64);
}
// chooseDownloadDir 打开原生目录选择框(标题本地化由调用方传入),返回所选目录;
// 用户取消返回空串。浏览器里 bridge() 为 null,返回空串。
export async function chooseDownloadDir(title: string): Promise<string>
{
const app = bridge();
if (!app) { return ""; }
try
{
return await app.ChooseDownloadDir(title);
}
catch
{
return "";
}
}
// effectiveDownloadDir 返回当前实际落盘目录(配置覆盖或系统默认),供设置页显示。
export async function effectiveDownloadDir(): Promise<string>
{
const app = bridge();
if (!app) { return ""; }
try
{
return await app.EffectiveDownloadDir();
}
catch
{
return "";
}
}
+113
View File
@@ -0,0 +1,113 @@
import { apiFetch } from "./api";
export interface SseEvent
{
type: string;
data: unknown;
}
export type SseHandler = (event: SseEvent) => void;
// connectSSE establishes a long-lived /api/hub/events stream and pumps every
// parsed frame to onEvent. Resolves only when the body ends or signal aborts.
//
// Why not EventSource: native EventSource can't attach Authorization or
// X-Device-Name headers, both of which are mandatory (PROJECT_BRIEF.md §2 / §5).
// We use fetch + ReadableStream + TextDecoder({stream:true}) + '\n\n' framing,
// matching FRONTEND_DESIGN.md §8.
export async function connectSSE(
url: string,
onEvent: SseHandler,
signal?: AbortSignal,
onOpen?: () => void,
): Promise<void>
{
const resp = await apiFetch(url, {
method: "GET",
cache: "no-store",
signal,
});
if (!resp.ok)
{
throw new Error(`SSE ${resp.status}: ${resp.statusText}`);
}
if (!resp.body)
{
throw new Error("SSE: response has no body");
}
// The stream is established (status + body ready). Signal "connected" now,
// before any frame: a freshly-connected client may not receive its first
// event for a while (it only gets `presence` when the device set changes),
// and on Windows the first frame can sit in a downstream buffer. The
// connection itself is what the indicator should reflect.
onOpen?.();
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buf = "";
try
{
while (true)
{
const { value, done } = await reader.read();
if (done) { break; }
buf += decoder.decode(value, { stream: true });
let sep = buf.indexOf("\n\n");
while (sep !== -1)
{
const raw = buf.substring(0, sep);
buf = buf.substring(sep + 2);
const ev = parseFrame(raw);
if (ev) { onEvent(ev); }
sep = buf.indexOf("\n\n");
}
}
}
finally
{
try { reader.cancel(); } catch { /* fine */ }
}
}
// parseFrame interprets one '\n\n'-terminated SSE block.
// Lines starting with ':' are comments (server's `: hi` probe lands here).
// Multiple `data:` lines are concatenated with '\n' per the SSE spec.
// JSON `data` is auto-decoded; anything else is passed through as a string.
function parseFrame(raw: string): SseEvent | null
{
let event = "message";
let data = "";
for (const line of raw.split("\n"))
{
if (line.length === 0 || line.startsWith(":")) { continue; }
const colon = line.indexOf(":");
if (colon === -1) { continue; }
const field = line.substring(0, colon);
const value = line.substring(colon + 1).trimStart();
if (field === "event") { event = value; }
else if (field === "data")
{
data = data.length === 0 ? value : `${data}\n${value}`;
}
}
if (event === "message" && data.length === 0)
{
return null;
}
let parsed: unknown = data;
if (data.length > 0 && (data[0] === "{" || data[0] === "["))
{
try { parsed = JSON.parse(data); }
catch { /* leave as string */ }
}
return { type: event, data: parsed };
}