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
+368
View File
@@ -0,0 +1,368 @@
import {
AppShell,
Group,
Menu,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { Outlet, Link, createRootRoute, useLocation, useNavigate } from "@tanstack/react-router";
import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useAppStore, type ThemeMode } from "../store";
import { logout } from "../features/auth/auth";
import { uploadClipboard } from "../features/clipboard/clipboard";
import { onNativeClipboard } from "../net/desktop";
import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { t, type Locale } from "../i18n";
import { Callout, IconButton, Tooltip } from "../ui/primitives";
import { Wordmark } from "../ui/brand";
export const Route = createRootRoute({ component: RootLayout });
function RootLayout()
{
const user = useAppStore((s) => s.user);
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const sseConnected = useAppStore((s) => s.sseConnected);
const sseReconnecting = useAppStore((s) => s.sseReconnecting);
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
const navigate = useNavigate();
const location = useLocation();
// 登录 / 首次设置 / OAuth 回调使用全屏 AuthShell(由各路由自渲染),
// 这里只渲染 <Outlet/>,不包 AppShell,从而隐去顶栏。
const fullscreen = isAuthRoute(location.pathname);
// SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连
// (改名后会自动以新设备名重新注册)。
useEffect(() =>
{
if (!user || !selfDeviceName) { return; }
const ctrl = new AbortController();
startHub(ctrl.signal);
// 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。
void refreshICEServers();
// 桌面:本机复制 → 上传云端(复用 uploadClipboard 链路;浏览器里为 no-op)。
// 上传失败静默——不打断用户,下次复制再试。
const offNativeClipboard = onNativeClipboard((text) =>
{
void uploadClipboard(text).catch(() => { /* ignore */ });
});
return () =>
{
ctrl.abort();
stopICEServerRefresh();
offNativeClipboard();
};
}, [ user?.id, selfDeviceName ]);
const handleLogout = () =>
{
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
if (fullscreen) { return <Outlet />; }
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between" wrap="nowrap" gap="xs">
<Link
to="/"
style={{
textDecoration: "none",
color: "inherit",
minWidth: 0,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
}}
>
<Title
order={4}
fw={600}
style={{ whiteSpace: "nowrap", fontSize: "var(--fs-22)" }}
>
<Wordmark />
</Title>
</Link>
{user && (
<Group gap={8} wrap="nowrap">
<ConnectionIcon connected={sseConnected} />
<ThemeQuickToggle theme={theme} onChange={setTheme} />
<Menu position="bottom-end" width={240} withArrow shadow="md">
<Menu.Target>
<UnstyledButton
aria-label={t("nav.accountMenu")}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 6px",
borderRadius: 6,
}}
>
<UserAvatar
name={user.name}
avatarUrl={user.avatar}
size={28}
/>
<Text size="sm" visibleFrom="sm" truncate maw={140}>
{user.name}
</Text>
<ChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Stack gap={2} px="sm" pt="xs" pb={6}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
<Text size="xs" c="dimmed">
{t("nav.thisDeviceLabel")}
<Text span fw={500}>{selfDeviceName}</Text>
</Text>
<Text
size="xs"
style={{ color: sseConnected ? "var(--state-online)" : "var(--text-muted)" }}
>
{sseConnected
? `${t("app.connected")}`
: `${t("app.connecting")}`}
</Text>
</Stack>
<Menu.Divider />
<Menu.Label>{t("nav.theme")}</Menu.Label>
{/* System 列于最上 — 默认跟随系统,最常用。 */}
<ThemeItem
mode="system"
label={t("nav.theme.system")}
icon={<Monitor size={14} />}
current={theme}
onSelect={setTheme}
/>
<ThemeItem
mode="light"
label={t("nav.theme.light")}
icon={<Sun size={14} />}
current={theme}
onSelect={setTheme}
/>
<ThemeItem
mode="dark"
label={t("nav.theme.dark")}
icon={<Moon size={14} />}
current={theme}
onSelect={setTheme}
/>
<Menu.Divider />
<Menu.Item
leftSection={<Settings size={14} />}
onClick={() => navigate({ to: "/settings" })}
>
{t("nav.settings")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("nav.language")}</Menu.Label>
<LocaleItem
locale="zh-CN"
label="简体中文"
current={locale}
onSelect={setLocale}
/>
<LocaleItem
locale="zh-TW"
label="繁體中文"
current={locale}
onSelect={setLocale}
/>
<LocaleItem
locale="en-US"
label="English"
current={locale}
onSelect={setLocale}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LogOut size={14} />}
onClick={handleLogout}
>
{t("nav.signOut")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
)}
</Group>
</AppShell.Header>
<AppShell.Main>
{sseReconnecting && (
<div style={{ marginBottom: "var(--space-4)" }}>
<Callout
tone="error"
icon={<WifiOff size={16} />}
title={t("app.disconnectedTitle")}
>
{t("app.disconnectedHint")}
</Callout>
</div>
)}
<Outlet />
</AppShell.Main>
</AppShell>
);
}
// Header 上的快捷按钮:单击在三态间循环 system → light → dark → system。
// 图标随当前模式切换;hover 显示 tooltip 提示下一步会切到什么。
function ThemeQuickToggle(props: { theme: ThemeMode; onChange: (m: ThemeMode) => void })
{
const { theme, onChange } = props;
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
const icon = theme === "light" ? <Sun size={16} />
: theme === "dark" ? <Moon size={16} />
: <Monitor size={16} />;
const label = `${t("nav.themeToggle")}${t(`nav.theme.${theme}` as const)}${t(`nav.theme.${next}` as const)}`;
return (
<Tooltip label={label}>
<IconButton
aria-label={label}
size="sm"
onClick={() => onChange(next)}
>
{icon}
</IconButton>
</Tooltip>
);
}
function ThemeItem(props: {
mode: ThemeMode;
label: string;
icon: ReactNode;
current: ThemeMode;
onSelect: (m: ThemeMode) => void;
})
{
const { mode, label, icon, current, onSelect } = props;
const active = mode === current;
return (
<Menu.Item
leftSection={active
? <Check size={14} />
: <span style={{ display: "inline-block", width: 14 }} />}
rightSection={icon}
onClick={() => { if (!active) { onSelect(mode); } }}
>
{label}
</Menu.Item>
);
}
function LocaleItem(props: {
locale: Locale;
label: string;
current: Locale;
onSelect: (l: Locale) => void;
})
{
const { locale, label, current, onSelect } = props;
const active = locale === current;
return (
<Menu.Item
leftSection={active
? <Check size={14} />
: <span style={{ display: "inline-block", width: 14 }} />}
onClick={() => { if (!active) { onSelect(locale); } }}
>
{label}
</Menu.Item>
);
}
function ConnectionIcon(props: { connected: boolean })
{
const { connected } = props;
const color = connected ? "var(--state-online)" : "var(--text-muted)";
const label = connected ? t("app.connected") : t("app.connecting");
return (
<span
aria-label={label}
title={label}
style={{ display: "inline-flex", alignItems: "center" }}
>
{connected
? <Wifi size={16} color={color} />
: <WifiOff size={16} color={color} />}
</span>
);
}
// 这些路由自己撑满 viewportroot layout 不再套 AppShell。
function isAuthRoute(pathname: string): boolean
{
return pathname === "/login"
|| pathname === "/setup"
|| pathname.startsWith("/oauth/");
}
function initial(name: string): string
{
const trimmed = name.trim();
if (!trimmed) { return "?"; }
// 取首个码点(兼容 emoji / 多字节中文姓名)。
const first = Array.from(trimmed)[0];
return first.toUpperCase();
}
// 头像:优先 Casdoor 提供的图(user.avatar)。URL 缺失或加载失败时回退到
// 字母方块 —— 浅色主题白底黑字、深色主题黑底白字(与 Dracula 调色对比最强)。
function UserAvatar(props: { name: string; avatarUrl?: string; size?: number })
{
const { name, avatarUrl, size = 28 } = props;
const [ broken, setBroken ] = useState(false);
const fontSize = Math.round(size * 0.42);
const baseStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
flexShrink: 0,
userSelect: "none",
};
if (avatarUrl && !broken)
{
return (
<img
src={avatarUrl}
alt=""
referrerPolicy="no-referrer"
onError={() => setBroken(true)}
style={{ ...baseStyle, objectFit: "cover" }}
/>
);
}
// 字母方块:颜色由语义令牌驱动,主题切换时自然反转。
const letterStyle: React.CSSProperties = {
...baseStyle,
background: "var(--avatar-bg)",
color: "var(--avatar-fg)",
fontFamily: "var(--font-sans)",
fontWeight: 600,
fontSize,
lineHeight: 1,
letterSpacing: 0,
};
return <span style={letterStyle} aria-label={name}>{initial(name)}</span>;
}