Files
Commilitia-Drop/web/src/store/helpers.ts
T
admin f21fa5b5e8 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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

141 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 schememacOS 与浏览器无此注入、返回 ""(相对 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";
}