f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
import type { TransferRecord, ThemeMode, User } from "./types";
|
||
|
||
// DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免
|
||
// history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归
|
||
// 一:保留断点位置便于诊断,phase 字段告诉用户"卡在哪个阶段失败"。
|
||
export function normalizeTerminal(cur: TransferRecord, finalState: string): TransferRecord
|
||
{
|
||
const result: TransferRecord = { ...cur, state: finalState };
|
||
if (finalState !== "DONE") { return result; }
|
||
result.phase = undefined;
|
||
result.bytesTransferred = cur.fileSize;
|
||
if (cur.iceStats?.dataChannel)
|
||
{
|
||
result.iceStats = {
|
||
...cur.iceStats,
|
||
dataChannel: { ...cur.iceStats.dataChannel, bufferedAmount: 0 },
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ---- storage keys & readers ----
|
||
|
||
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
|
||
export const USER_KEY = "cdrop.user";
|
||
export const SELF_DEVICE_KEY = "cdrop.self_device";
|
||
export const THEME_KEY = "cdrop.theme";
|
||
|
||
// 桌面壳的跨重启会话恢复:Go 侧在启动时从持久化文件读出 session,注入到
|
||
// index.html 的 window.__CDROP_SESSION__,因为 wails:// 自定义 scheme 下 WKWebView
|
||
// 的 localStorage / sessionStorage 都不跨重启存活(custom-scheme origin 无持久化
|
||
// 存储)。注入的全局在 bundle 执行前就绪,store 初始化即可同步水合。浏览器无此
|
||
// 注入,回落到 sessionStorage(刷新存活、关标签即清;refresh_token 仅在内存)。
|
||
// 注入的 session 不含 refresh_token——refresh_token 是长寿命密钥,只在 Go 进程里,
|
||
// 绝不进入 WebView(凭据策略见 desktop/platform/session.go)。
|
||
interface InjectedSession
|
||
{
|
||
access_token: string;
|
||
user: { id: string; name: string; avatar?: string };
|
||
}
|
||
|
||
interface InjectedBoot
|
||
{
|
||
session?: InjectedSession | null;
|
||
device_name?: string;
|
||
api_base?: string;
|
||
device_type?: string;
|
||
}
|
||
|
||
function injectedBoot(): InjectedBoot | null
|
||
{
|
||
if (typeof window === "undefined") { return null; }
|
||
return (window as unknown as { __CDROP_BOOT__?: InjectedBoot }).__CDROP_BOOT__ ?? null;
|
||
}
|
||
|
||
function injectedSession(): InjectedSession | null
|
||
{
|
||
const s = injectedBoot()?.session;
|
||
return s && typeof s.access_token === "string" && s.access_token ? s : null;
|
||
}
|
||
|
||
// readInjectedDeviceName 在桌面壳里返回启动注入的设备名,让重启后路由守卫不再跳
|
||
// /setup(设备名同样存在 Go 侧、随 session 一并注入);浏览器无注入返回 null。
|
||
export function readInjectedDeviceName(): string | null
|
||
{
|
||
const name = injectedBoot()?.device_name;
|
||
return name && name.length > 0 ? name : null;
|
||
}
|
||
|
||
// readInjectedApiBase 返回 /api 请求要前缀的来源。Windows 桌面壳注入 loopback
|
||
// HTTP 代理地址(http://127.0.0.1:PORT),让 SSE 走 WebView2 正常网络栈而非会缓冲
|
||
// 的 custom scheme;macOS 与浏览器无此注入、返回 ""(相对 URL、同源)。
|
||
export function readInjectedApiBase(): string
|
||
{
|
||
const base = injectedBoot()?.api_base;
|
||
return base && base.length > 0 ? base : "";
|
||
}
|
||
|
||
// readInjectedDeviceType 返回桌面壳注入的客户端类型(macos / windows / linux),
|
||
// 前端经 X-Device-Type 透传给后端,使设备列表不再把桌面端都标成 browser;浏览器
|
||
// 无注入返回 "browser"。
|
||
export function readInjectedDeviceType(): string
|
||
{
|
||
const t = injectedBoot()?.device_type;
|
||
return t && t.length > 0 ? t : "browser";
|
||
}
|
||
|
||
export function readSessionAccess(): string | null
|
||
{
|
||
const inj = injectedSession();
|
||
if (inj) { return inj.access_token; }
|
||
if (typeof window === "undefined") { return null; }
|
||
return window.sessionStorage.getItem(ACCESS_TOKEN_KEY);
|
||
}
|
||
|
||
// user.id / user.name 不是机密(公开显示在 UI、JWT 里可解码),随 access_token
|
||
// 一并持久化,才能让刷新 / 重启后路由守卫不再误跳 /login。
|
||
export function readSessionUser(): User | null
|
||
{
|
||
const inj = injectedSession();
|
||
if (inj?.user && typeof inj.user.id === "string" && typeof inj.user.name === "string")
|
||
{
|
||
return { id: inj.user.id, name: inj.user.name, avatar: inj.user.avatar };
|
||
}
|
||
if (typeof window === "undefined") { return null; }
|
||
const raw = window.sessionStorage.getItem(USER_KEY);
|
||
if (!raw) { return null; }
|
||
try
|
||
{
|
||
const parsed = JSON.parse(raw) as unknown;
|
||
if (
|
||
parsed
|
||
&& typeof parsed === "object"
|
||
&& typeof (parsed as User).id === "string"
|
||
&& typeof (parsed as User).name === "string"
|
||
)
|
||
{
|
||
return parsed as User;
|
||
}
|
||
return null;
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function readSelfDevice(): string | null
|
||
{
|
||
if (typeof window === "undefined") { return null; }
|
||
return window.localStorage.getItem(SELF_DEVICE_KEY);
|
||
}
|
||
|
||
export function readTheme(): ThemeMode
|
||
{
|
||
if (typeof window === "undefined") { return "system"; }
|
||
const raw = window.localStorage.getItem(THEME_KEY);
|
||
if (raw === "light" || raw === "dark" || raw === "system") { return raw; }
|
||
return "system";
|
||
}
|