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
+314
View File
@@ -0,0 +1,314 @@
import { useMemo, useState } from "react";
import {
Anchor,
Container,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { LogOut, Trash2 } from "lucide-react";
import { logout } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api";
import { useAppStore, type DeviceInfo } from "../store";
import { t } from "../i18n";
import { formatRelative, isAsciiDeviceName } from "../utils/format";
import { Badge, Button, Panel, TextField } from "../ui/primitives";
import { StateDot } from "../ui/glyphs";
// deviceTypeLabel 把后端存的设备类型本地化展示(浏览器 / macOS 客户端 / …)。
// 容错匹配(与 DeviceStrip 的 mapKind 一致),未知类型回退原值。
function deviceTypeLabel(type: string): string
{
const k = type.toLowerCase();
if (k.includes("mac") || k === "darwin") { return t("deviceType.macos"); }
if (k.includes("win")) { return t("deviceType.windows"); }
if (k.includes("linux")) { return t("deviceType.linux"); }
if (k === "shortcut") { return t("deviceType.shortcut"); }
if (k.includes("ios") || k === "iphone" || k === "ipad") { return t("deviceType.ios"); }
if (k === "browser") { return t("deviceType.browser"); }
return type;
}
import { toast } from "../ui/feedback";
export const Route = createFileRoute("/settings")({
component: SettingsPage,
beforeLoad: () =>
{
const { user, selfDeviceName } = useAppStore.getState();
if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); }
if (!selfDeviceName) { throw redirect({ to: "/setup" }); }
},
});
function SettingsPage()
{
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const storeDevices = useAppStore((s) => s.devices);
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
const [ removing, setRemoving ] = useState<Set<string>>(new Set());
const [ renaming, setRenaming ] = useState(false);
const peerDevices = useMemo(
() => storeDevices.filter((d) => d.name !== selfDeviceName),
[ storeDevices, selfDeviceName ],
);
const trimmedName = pendingName.trim();
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
const handleRename = async () =>
{
if (!selfDeviceName) { return; }
if (!trimmedName) { toast.warn(t("settings.deviceName.empty")); return; }
if (!isAsciiDeviceName(trimmedName))
{
toast.warn(t("settings.deviceName.asciiOnly"));
return;
}
if (trimmedName === selfDeviceName)
{
toast.warn(t("settings.deviceName.unchanged"));
return;
}
setRenaming(true);
try
{
// 先在后端把旧设备记录删掉。Hub.Kick 会强制关闭旧的 SSE
// 紧接着 root effect 检测到 selfDeviceName 变化会以新名重新注册。
// 404(旧记录已被 sweeper 清理)也按成功处理。
const r = await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
if (!r.ok && r.status !== 404)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
toast.ok(t("settings.deviceName.success"));
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRenaming(false);
}
};
const handleRemovePeer = async (name: string) =>
{
if (!window.confirm(t("settings.unregister.peerConfirm", { name }))) { return; }
setRemoving((prev) =>
{
const next = new Set(prev);
next.add(name);
return next;
});
try
{
const r = await apiFetch(
`/api/devices/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
// 服务端的 publishPresence 会通过 SSE 推送新的设备列表,store
// 会随之更新,这里不需要手动 setDevices。
toast.ok("设备已移除", name);
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRemoving((prev) =>
{
const next = new Set(prev);
next.delete(name);
return next;
});
}
};
const handleUnregisterSelf = async () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
try
{
await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
}
catch
{
// 即便 DELETE 失败也按用户意图清理本地并跳转登录。
}
finally
{
setSelfDeviceName(null);
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
}
};
const handleSignOut = () =>
{
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
return (
<Container size="sm" py="lg">
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title>
<Anchor component={Link} to="/" size="sm">
{t("nav.backToHome")}
</Anchor>
</Group>
<Panel title={t("settings.currentDevice.title")}>
<Stack gap="sm">
<TextField
label={t("settings.deviceName.field")}
value={pendingName}
onChange={(e) => setPendingName(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && canSaveName)
{
e.preventDefault();
void handleRename();
}
}}
/>
<Group>
<Button
variant="primary"
onClick={handleRename}
disabled={!canSaveName}
loading={renaming}
>
{t("settings.deviceName.save")}
</Button>
</Group>
<div style={{ height: 1, background: "var(--divider)" }} />
<Text size="sm" c="dimmed">
{t("settings.unregister.currentHint")}
</Text>
<Group>
<Button
variant="danger"
leftIcon={<Trash2 size={14} />}
onClick={handleUnregisterSelf}
>
{t("settings.unregister.current")}
</Button>
</Group>
</Stack>
</Panel>
{isDesktop() && <DesktopSettings />}
<Panel title={t("settings.peers.title")}>
{peerDevices.length === 0 ? (
<Text size="sm" c="dimmed">{t("settings.peers.empty")}</Text>
) : (
<Stack gap="xs">
{peerDevices.map((d) => (
<PeerRow
key={d.name}
device={d}
removing={removing.has(d.name)}
onRemove={() => handleRemovePeer(d.name)}
/>
))}
</Stack>
)}
</Panel>
<Panel title={t("settings.account.title")}>
<Stack gap="sm">
{user && (
<Stack gap={2}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
{user.id !== user.name && (
<Text size="xs" c="dimmed" truncate>{user.id}</Text>
)}
</Stack>
)}
<Group>
<Button
variant="ghost"
leftIcon={<LogOut size={14} />}
onClick={handleSignOut}
>
{t("settings.account.signOut")}
</Button>
</Group>
</Stack>
</Panel>
</Stack>
</Container>
);
}
function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void })
{
const { device, removing, onRemove } = props;
const lastSeenText = device.lastSeen
? t("settings.lastSeen", { time: formatRelative(device.lastSeen) })
: t("settings.lastSeenNever");
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">
<StateDot tone={device.online ? "online" : "offline"} />
<Text fw={500} size="sm" truncate>{device.name}</Text>
<Badge tone={device.online ? "online" : "neutral"}>
{t(device.online ? "settings.online" : "settings.offline")}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>
{deviceTypeLabel(device.type)} · {lastSeenText}
</Text>
</Stack>
<Button
size="sm"
variant="ghost"
loading={removing}
onClick={onRemove}
style={{ color: "var(--state-error)" }}
>
{removing ? t("settings.peers.removing") : t("settings.peers.remove")}
</Button>
</Group>
</Panel>
);
}