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,336 @@
import { useEffect, useState } from "react";
import { Group, Stack, Text } from "@mantine/core";
import { Copy, KeyRound, Plus } from "lucide-react";
import { apiJSON } from "../../net/api";
import { t } from "../../i18n";
import { formatRelative } from "../../utils/format";
import { Badge, Button, Callout, Panel, TextField } from "../../ui/primitives";
import { toast } from "../../ui/feedback";
type ShortcutTokenView = {
jti: string;
label: string;
scopes: string[];
created_at: number;
expires_at: number;
last_used_at: number | null;
revoked: boolean;
};
type IssueResp = {
token: string;
jti: string;
label: string;
scopes: string[];
expires_at: number;
};
const listTokens = () => apiJSON<{ tokens: ShortcutTokenView[] }>("/api/shortcut");
const issueToken = (label: string) =>
apiJSON<IssueResp>("/api/shortcut/issue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label }),
});
const revokeToken = (jti: string) =>
apiJSON<void>(`/api/shortcut/${encodeURIComponent(jti)}`, { method: "DELETE" });
// baseURL 是本服务器的对外地址,快捷指令请求剪贴板端点时用它拼 URL。桌面注入了
// loopback 代理地址,但快捷指令跑在 iPhone 上,应当用真正的对外 origin。
function baseURL(): string
{
return window.location.origin;
}
async function copyText(value: string): Promise<void>
{
try
{
await navigator.clipboard.writeText(value);
toast.ok(t("settings.shortcut.copied"));
}
catch
{
toast.error(t("settings.shortcut.copyFailed"));
}
}
// ShortcutTokens 管理 iOS 快捷指令的长效、仅剪贴板、可吊销令牌:列表 / 签发
// (仅展示一次)/ 吊销 + 安装指引。挂在设置页,需登录会话(用令牌本身不能管理令牌)。
export function ShortcutTokens()
{
const [ tokens, setTokens ] = useState<ShortcutTokenView[] | null>(null);
const [ loadError, setLoadError ] = useState(false);
const [ label, setLabel ] = useState("");
const [ issuing, setIssuing ] = useState(false);
const [ revoking, setRevoking ] = useState<Set<string>>(new Set());
const [ issued, setIssued ] = useState<IssueResp | null>(null);
const reload = async () =>
{
try
{
const r = await listTokens();
setTokens(r.tokens ?? []);
setLoadError(false);
}
catch
{
// 网络波动不该让整页报错;显示重试入口即可(容错要求)。
setLoadError(true);
}
};
useEffect(() =>
{
void reload();
}, []);
const handleIssue = async () =>
{
setIssuing(true);
try
{
const res = await issueToken(label.trim());
setIssued(res); // 内联展示一次性令牌 + 指引
setLabel("");
await reload();
}
catch (e)
{
const msg = e instanceof Error ? e.message : String(e);
if (msg.startsWith("503")) { toast.error(t("settings.shortcut.unavailable")); }
else if (msg.startsWith("409")) { toast.error(t("settings.shortcut.tooMany")); }
else { toast.error(t("settings.shortcut.issueError", { message: msg })); }
}
finally
{
setIssuing(false);
}
};
const handleRevoke = async (token: ShortcutTokenView) =>
{
if (!window.confirm(t("settings.shortcut.revokeConfirm"))) { return; }
setRevoking((prev) => new Set(prev).add(token.jti));
try
{
await revokeToken(token.jti);
toast.ok(t("settings.shortcut.revokedToast"));
await reload();
}
catch (e)
{
toast.error(t("settings.shortcut.issueError", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRevoking((prev) =>
{
const next = new Set(prev);
next.delete(token.jti);
return next;
});
}
};
return (
<Panel title={t("settings.shortcut.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed">{t("settings.shortcut.intro")}</Text>
<Group align="flex-end" gap="sm" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<TextField
label={t("settings.shortcut.labelField")}
placeholder={t("settings.shortcut.labelPlaceholder")}
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && !issuing)
{
e.preventDefault();
void handleIssue();
}
}}
/>
</div>
<Button
variant="primary"
leftIcon={<Plus size={14} />}
onClick={handleIssue}
loading={issuing}
>
{t("settings.shortcut.issue")}
</Button>
</Group>
{issued && (
<IssuedReveal
issued={issued}
base={baseURL()}
onDismiss={() => setIssued(null)}
/>
)}
{loadError && (
<Group gap="sm">
<Text size="sm" c="dimmed">{t("settings.shortcut.loadError")}</Text>
<Button size="sm" variant="ghost" onClick={() => void reload()}>
{t("settings.shortcut.retry")}
</Button>
</Group>
)}
{tokens && tokens.length === 0 && !loadError && (
<Text size="sm" c="dimmed">{t("settings.shortcut.empty")}</Text>
)}
{tokens && tokens.length > 0 && (
<Stack gap="xs">
{tokens.map((tok) => (
<TokenRow
key={tok.jti}
token={tok}
revoking={revoking.has(tok.jti)}
onRevoke={() => handleRevoke(tok)}
/>
))}
</Stack>
)}
</Stack>
</Panel>
);
}
function TokenRow(props: { token: ShortcutTokenView; revoking: boolean; onRevoke: () => void })
{
const { token, revoking, onRevoke } = props;
const expired = token.expires_at * 1000 < Date.now();
const lastUsed = token.last_used_at
? t("settings.shortcut.lastUsed", { time: formatRelative(token.last_used_at) })
: t("settings.shortcut.neverUsed");
return (
<Panel variant="sunken" padding="tight">
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap">
<Text fw={500} size="sm" truncate>{token.label}</Text>
{token.revoked && <Badge tone="neutral">{t("settings.shortcut.revoked")}</Badge>}
{!token.revoked && expired && (
<Badge tone="neutral">{t("settings.shortcut.expired")}</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{t("settings.shortcut.expiresAt", {
date: new Date(token.expires_at * 1000).toLocaleDateString(),
})}
{" · "}
{lastUsed}
</Text>
</Stack>
{!token.revoked && (
<Button
size="sm"
variant="ghost"
loading={revoking}
onClick={onRevoke}
style={{ color: "var(--state-error)" }}
>
{revoking ? t("settings.shortcut.revoking") : t("settings.shortcut.revoke")}
</Button>
)}
</Group>
</Panel>
);
}
// IssuedReveal 内联展示刚签发的令牌(仅此一次)+ 服务器地址 + 手动搭建指引。
// 沿用设置页一贯的「下沉面板 + Callout + Button」语汇,不引入弹窗。
function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () => void })
{
const { issued, base, onDismiss } = props;
return (
<Panel variant="sunken" padding="tight">
<Stack gap="sm">
<Callout
tone="warn"
icon={<KeyRound size={16} />}
title={t("settings.shortcut.issuedTitle")}
>
{t("settings.shortcut.onceWarning")}
</Callout>
<Field label={t("settings.shortcut.tokenLabel")} value={issued.token} mono />
<Field label={t("settings.shortcut.baseLabel")} value={base} />
<Stack gap={6}>
<Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text>
<Text size="sm" c="dimmed">{t("settings.shortcut.guideIntro")}</Text>
<Text size="sm" c="dimmed">{t("settings.shortcut.guideDeviceName")}</Text>
<Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text>
<Stack gap={2}>
<Text size="sm">1. {t("settings.shortcut.guidePull1")}</Text>
<Text size="sm">2. {t("settings.shortcut.guidePull2")}</Text>
<Text size="sm">3. {t("settings.shortcut.guidePull3")}</Text>
</Stack>
<Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text>
<Stack gap={2}>
<Text size="sm">1. {t("settings.shortcut.guidePush1")}</Text>
<Text size="sm">2. {t("settings.shortcut.guidePush2")}</Text>
<Text size="sm">3. {t("settings.shortcut.guidePush3")}</Text>
</Stack>
<Text size="xs" c="dimmed">{t("settings.shortcut.guideToleranceNote")}</Text>
</Stack>
<Group justify="flex-end">
<Button variant="ghost" onClick={onDismiss}>
{t("settings.shortcut.done")}
</Button>
</Group>
</Stack>
</Panel>
);
}
// Field 是一行「标签 + 单行只读值 + 复制按钮」,值用等宽字体、可断行。
function Field(props: { label: string; value: string; mono?: boolean })
{
const { label, value, mono } = props;
return (
<Stack gap={4}>
<Text size="xs" c="dimmed">{label}</Text>
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text
size="sm"
style={{
flex: 1,
minWidth: 0,
wordBreak: "break-all",
fontFamily: mono ? "var(--font-mono)" : undefined,
}}
>
{value}
</Text>
<Button
size="sm"
variant="ghost"
leftIcon={<Copy size={14} />}
onClick={() => void copyText(value)}
>
{t("settings.shortcut.copy")}
</Button>
</Group>
</Stack>
);
}