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:
@@ -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;
|
||||
}
|
||||
@@ -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 为 base64:Wails 把 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 只在 Go,JS 永不持有。
|
||||
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 只在 Go,JS 不传也不持有)。
|
||||
// Go 用自己的 refresh_token 换新 access_token(public-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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user