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:
@@ -0,0 +1,140 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from "zustand";
|
||||
import type { AppState } from "./types";
|
||||
import { createAuthSlice } from "./slices/auth";
|
||||
import { createDevicesSlice } from "./slices/devices";
|
||||
import { createTransferSlice } from "./slices/transfer";
|
||||
import { createMessagesSlice } from "./slices/messages";
|
||||
import { createClipboardSlice } from "./slices/clipboard";
|
||||
import { createUiSlice } from "./slices/ui";
|
||||
import { createThemeSlice } from "./slices/theme";
|
||||
import { createLocaleSlice } from "./slices/locale";
|
||||
|
||||
export const useAppStore = create<AppState>()((...a) =>
|
||||
({
|
||||
...createAuthSlice(...a),
|
||||
...createDevicesSlice(...a),
|
||||
...createTransferSlice(...a),
|
||||
...createMessagesSlice(...a),
|
||||
...createClipboardSlice(...a),
|
||||
...createUiSlice(...a),
|
||||
...createThemeSlice(...a),
|
||||
...createLocaleSlice(...a),
|
||||
}));
|
||||
|
||||
// 重新导出所有 type 与 helper,保证现有 `from "./store"` import 仍可解析。
|
||||
export type {
|
||||
AppState,
|
||||
AuthMode,
|
||||
ThemeMode,
|
||||
User,
|
||||
DeviceInfo,
|
||||
TransferPhase,
|
||||
CandidateBreakdown,
|
||||
IceStats,
|
||||
TransferRecord,
|
||||
MessageRecord,
|
||||
ClipboardState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState, AuthMode } from "../types";
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
USER_KEY,
|
||||
readSessionAccess,
|
||||
readSessionUser,
|
||||
} from "../helpers";
|
||||
|
||||
export type AuthSlice = Pick<
|
||||
AppState,
|
||||
"authMode" | "accessToken" | "refreshToken" | "user" | "setAuth" | "clearAuth"
|
||||
>;
|
||||
|
||||
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
|
||||
|
||||
// 跨重启的持久化由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责,
|
||||
// 因为 wails:// scheme 下 WebView 存储不存活。sessionStorage 仅用于浏览器同 tab
|
||||
// 刷新;桌面在单次运行内的连续性由内存中的 store 保证。
|
||||
// refreshToken 在 JS 里恒为 null:浏览器只在内存持有(不落盘),桌面则完全不持有
|
||||
// (refresh 走 Go 内部,refresh_token 只在 Go 进程)——见凭据策略。
|
||||
export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set) =>
|
||||
({
|
||||
authMode: initialAuthMode,
|
||||
accessToken: readSessionAccess(),
|
||||
refreshToken: null,
|
||||
user: readSessionUser(),
|
||||
|
||||
setAuth: (a) =>
|
||||
{
|
||||
if (typeof window !== "undefined")
|
||||
{
|
||||
window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken);
|
||||
window.sessionStorage.setItem(USER_KEY, JSON.stringify(a.user));
|
||||
}
|
||||
set({
|
||||
accessToken: a.accessToken,
|
||||
refreshToken: a.refreshToken ?? null,
|
||||
user: a.user,
|
||||
});
|
||||
},
|
||||
|
||||
clearAuth: () =>
|
||||
{
|
||||
if (typeof window !== "undefined")
|
||||
{
|
||||
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
window.sessionStorage.removeItem(USER_KEY);
|
||||
}
|
||||
set({ accessToken: null, refreshToken: null, user: null });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export type ClipboardSlice = Pick<AppState, "clipboard" | "setClipboard">;
|
||||
|
||||
export const createClipboardSlice: StateCreator<AppState, [], [], ClipboardSlice> = (set) =>
|
||||
({
|
||||
clipboard: null,
|
||||
setClipboard: (c) => set({ clipboard: c }),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
import { SELF_DEVICE_KEY, readSelfDevice, readInjectedDeviceName } from "../helpers";
|
||||
|
||||
// 这一片把"设备"语义全部合到一起:本机设备名 + Hub(SSE 连接 / 在线列表)。
|
||||
// 它们都围绕"我有哪些设备、谁在线"这一问题。
|
||||
export type DevicesSlice = Pick<
|
||||
AppState,
|
||||
"selfDeviceName" | "setSelfDeviceName"
|
||||
| "sseConnected" | "sseReconnecting" | "devices"
|
||||
| "setSseConnected" | "setSseReconnecting" | "setDevices"
|
||||
>;
|
||||
|
||||
export const createDevicesSlice: StateCreator<AppState, [], [], DevicesSlice> = (set) =>
|
||||
({
|
||||
// 桌面壳优先用启动注入的设备名(localStorage 在 wails:// 下不跨重启存活);
|
||||
// 浏览器回落到 localStorage。
|
||||
selfDeviceName: readInjectedDeviceName() ?? readSelfDevice(),
|
||||
setSelfDeviceName: (name) =>
|
||||
{
|
||||
if (typeof window !== "undefined")
|
||||
{
|
||||
if (name) { window.localStorage.setItem(SELF_DEVICE_KEY, name); }
|
||||
else { window.localStorage.removeItem(SELF_DEVICE_KEY); }
|
||||
}
|
||||
set({ selfDeviceName: name });
|
||||
},
|
||||
|
||||
sseConnected: false,
|
||||
sseReconnecting: false,
|
||||
devices: [],
|
||||
setSseConnected: (v) => set({ sseConnected: v }),
|
||||
setSseReconnecting: (v) => set({ sseReconnecting: v }),
|
||||
setDevices: (d) => set({ devices: d }),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
import { getLocale, setLocale as i18nSetLocale } from "../../i18n";
|
||||
|
||||
export type LocaleSlice = Pick<AppState, "locale" | "setLocale">;
|
||||
|
||||
// 初始值取自 i18n 模块(已从 localStorage 还原);setLocale 同步到 i18n
|
||||
// 模块(更新 currentLocale + 写 localStorage)。
|
||||
export const createLocaleSlice: StateCreator<AppState, [], [], LocaleSlice> = (set) =>
|
||||
({
|
||||
locale: getLocale(),
|
||||
setLocale: (l) =>
|
||||
{
|
||||
i18nSetLocale(l);
|
||||
set({ locale: l });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export type MessagesSlice = Pick<
|
||||
AppState,
|
||||
"messages" | "addMessage" | "removeMessage" | "clearMessages"
|
||||
>;
|
||||
|
||||
export const createMessagesSlice: StateCreator<AppState, [], [], MessagesSlice> = (set) =>
|
||||
({
|
||||
messages: [],
|
||||
|
||||
addMessage: (m) =>
|
||||
{
|
||||
set((s) => ({ messages: [ m, ...s.messages ].slice(0, 200) }));
|
||||
},
|
||||
|
||||
removeMessage: (id) =>
|
||||
{
|
||||
set((s) => ({ messages: s.messages.filter((mm) => mm.id !== id) }));
|
||||
},
|
||||
|
||||
clearMessages: () =>
|
||||
{
|
||||
set({ messages: [] });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
import { THEME_KEY, readTheme } from "../helpers";
|
||||
|
||||
export type ThemeSlice = Pick<AppState, "theme" | "setTheme">;
|
||||
|
||||
export const createThemeSlice: StateCreator<AppState, [], [], ThemeSlice> = (set) =>
|
||||
({
|
||||
theme: readTheme(),
|
||||
setTheme: (t) =>
|
||||
{
|
||||
if (typeof window !== "undefined")
|
||||
{
|
||||
window.localStorage.setItem(THEME_KEY, t);
|
||||
}
|
||||
set({ theme: t });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
import { normalizeTerminal } from "../helpers";
|
||||
|
||||
export type TransferSlice = Pick<
|
||||
AppState,
|
||||
"activeTransfers" | "history" | "upsertTransfer" | "completeTransfer" | "removeTransfer"
|
||||
>;
|
||||
|
||||
export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice> = (set) =>
|
||||
({
|
||||
activeTransfers: {},
|
||||
history: [],
|
||||
|
||||
upsertTransfer: (t) =>
|
||||
{
|
||||
set((s) =>
|
||||
({
|
||||
activeTransfers: { ...s.activeTransfers, [t.sessionId]: t },
|
||||
}));
|
||||
},
|
||||
|
||||
completeTransfer: (sessionId, finalState) =>
|
||||
{
|
||||
set((s) =>
|
||||
{
|
||||
const cur = s.activeTransfers[sessionId];
|
||||
if (!cur) { return s; }
|
||||
const finalRec = normalizeTerminal(cur, finalState);
|
||||
const { [sessionId]: _, ...rest } = s.activeTransfers;
|
||||
return ({
|
||||
activeTransfers: rest,
|
||||
history: [ finalRec, ...s.history ].slice(0, 20),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeTransfer: (sessionId) =>
|
||||
{
|
||||
set((s) =>
|
||||
{
|
||||
const { [sessionId]: _, ...rest } = s.activeTransfers;
|
||||
return ({
|
||||
activeTransfers: rest,
|
||||
history: s.history.filter((h) => h.sessionId !== sessionId),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export type UiSlice = Pick<
|
||||
AppState,
|
||||
"selectedDevice" | "selectedFile" | "setSelectedDevice" | "setSelectedFile"
|
||||
>;
|
||||
|
||||
// 当前 UI slice 仅承载首页 composer 所需的"选中设备"与"选中文件"。后续若
|
||||
// 需要更多 UI 临时态(开关、临时表单、拖拽态),都收拢在此处。
|
||||
export const createUiSlice: StateCreator<AppState, [], [], UiSlice> = (set) =>
|
||||
({
|
||||
selectedDevice: null,
|
||||
selectedFile: null,
|
||||
setSelectedDevice: (n) => set({ selectedDevice: n }),
|
||||
setSelectedFile: (f) => set({ selectedFile: f }),
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Locale } from "../i18n";
|
||||
|
||||
// Auth shape — see FRONTEND_DESIGN.md §6.
|
||||
// access_token lives in sessionStorage so a tab refresh keeps the user logged in
|
||||
// (PROJECT_BRIEF.md §2). refresh_token stays in the store only — never in storage —
|
||||
// to keep XSS exposure as small as possible.
|
||||
export interface User
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
/** Avatar URL from OIDC `picture` / Casdoor `avatar` claim. 空 / 加载失败时
|
||||
UI 回退到字母方块。 */
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface DeviceInfo
|
||||
{
|
||||
name: string;
|
||||
type: string;
|
||||
online: boolean;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export type AuthMode = "dev" | "prod";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
// 客户端侧细颗粒度阶段,与服务端的 state(PENDING/ACCEPTED/...)正交;
|
||||
// 用来把"等待中/传输中"展开成具体正在做什么,配合 lastProgressAt 区分卡住。
|
||||
export type TransferPhase =
|
||||
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
|
||||
| "waiting_accept" // sender 等 /accept,receiver 等自己 /accept 回执
|
||||
| "ice_gathering" // RTCPeerConnection 正在收集候选
|
||||
| "ice_checking" // 候选已交换,连通性检查中
|
||||
| "ice_connected" // ICE 通过,DC 即将打开
|
||||
| "dc_open" // DataChannel 打开但还没开始传字节
|
||||
| "transferring" // 字节正在流动
|
||||
| "completing" // sender 已 send "done" / receiver 收到 "done",等终态
|
||||
| "fallback_pending" // 30s ICE 超时,已 POST /fallback,等 RELAY_ACTIVE 信号
|
||||
| "relay_uploading" // 走 Relay 路径,sender 上传 chunk
|
||||
| "relay_downloading"; // 走 Relay 路径,receiver 流式 GET
|
||||
|
||||
// ICE 候选分类,用来回答"为什么 P2P 没建好":
|
||||
// host = 本机真实地址;mdns = 浏览器隐私混淆的 .local 主机名(同内网必须能解析);
|
||||
// srflx = STUN 反射的公网地址;prflx = peer-reflexive;relay = 通过 TURN 中转。
|
||||
export interface CandidateBreakdown
|
||||
{
|
||||
host: number;
|
||||
mdns: number;
|
||||
srflx: number;
|
||||
prflx: number;
|
||||
relay: number;
|
||||
}
|
||||
|
||||
export interface IceStats
|
||||
{
|
||||
gathering: RTCIceGatheringState;
|
||||
connection: RTCIceConnectionState;
|
||||
candidates: { local: CandidateBreakdown; remote: CandidateBreakdown };
|
||||
selectedPair?: { local: string; remote: string }; // 形如 "host/udp" "relay/tcp"
|
||||
/** DataChannel 即时状态:诊断 sender 卡在 bufferedamountlow 用 */
|
||||
dataChannel?: {
|
||||
readyState: RTCDataChannelState;
|
||||
bufferedAmount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransferRecord
|
||||
{
|
||||
sessionId: string;
|
||||
direction: "outgoing" | "incoming";
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
state: string;
|
||||
/**
|
||||
* "p2p" | "relay" | undefined.
|
||||
* Server emits this on the P2P_ACTIVE / RELAY_ACTIVE transition; the
|
||||
* client carries it forward into terminal states for history display.
|
||||
*/
|
||||
mode?: string;
|
||||
peerName: string;
|
||||
startedAt: number;
|
||||
|
||||
// ---- 客户端实时遥测 ------------------------------------------------------
|
||||
phase?: TransferPhase;
|
||||
/** 本端已传字节:outgoing 是 sender 已 send,incoming 是 receiver 已收 */
|
||||
bytesTransferred?: number;
|
||||
/** 上一次 bytesTransferred 增长的时刻(ms epoch),UI 据此判 stalled */
|
||||
lastProgressAt?: number;
|
||||
iceStats?: IceStats;
|
||||
}
|
||||
|
||||
// In-memory only — cleared on tab close, or via UI clear.
|
||||
export interface MessageRecord
|
||||
{
|
||||
id: string;
|
||||
direction: "incoming" | "outgoing";
|
||||
peerName: string;
|
||||
text: string;
|
||||
sentAt: number;
|
||||
}
|
||||
|
||||
export interface ClipboardState
|
||||
{
|
||||
content: string;
|
||||
contentType: string;
|
||||
sourceDevice: string;
|
||||
updatedAt: number;
|
||||
// version 是服务端每用户单调递增的版本号;条件 GET 的 ETag 用它(而非墙钟
|
||||
// updated_at),故同秒两写也能正确判变更。
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface AppState
|
||||
{
|
||||
// ---- auth slice ----
|
||||
authMode: AuthMode;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
setAuth: (a: { accessToken: string; refreshToken?: string; user: User }) => void;
|
||||
clearAuth: () => void;
|
||||
|
||||
// ---- device slice ----
|
||||
selfDeviceName: string | null;
|
||||
setSelfDeviceName: (name: string | null) => void;
|
||||
|
||||
// ---- hub slice ----
|
||||
sseConnected: boolean;
|
||||
sseReconnecting: boolean; // several straight SSE failures; UI shows a "reconnecting" banner — the loop keeps retrying, never gives up
|
||||
devices: DeviceInfo[];
|
||||
setSseConnected: (v: boolean) => void;
|
||||
setSseReconnecting: (v: boolean) => void;
|
||||
setDevices: (d: DeviceInfo[]) => void;
|
||||
|
||||
// ---- transfer slice ----
|
||||
activeTransfers: Record<string, TransferRecord>;
|
||||
history: TransferRecord[];
|
||||
upsertTransfer: (t: TransferRecord) => void;
|
||||
completeTransfer: (sessionId: string, finalState: string) => void;
|
||||
/** 从列表移除一条记录(删除已完成的历史项 / 兜底清掉残留 active 项)。 */
|
||||
removeTransfer: (sessionId: string) => void;
|
||||
|
||||
// ---- messages slice ----
|
||||
messages: MessageRecord[];
|
||||
addMessage: (m: MessageRecord) => void;
|
||||
removeMessage: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
|
||||
// ---- clipboard slice ----
|
||||
clipboard: ClipboardState | null;
|
||||
setClipboard: (c: ClipboardState | null) => void;
|
||||
|
||||
// ---- ui slice ----
|
||||
selectedDevice: string | null;
|
||||
selectedFile: File | null;
|
||||
setSelectedDevice: (n: string | null) => void;
|
||||
setSelectedFile: (f: File | null) => void;
|
||||
|
||||
// ---- theme slice ----
|
||||
// 'system' 时移除 data-theme 让 @media (prefers-color-scheme) 接管;
|
||||
// 'light' / 'dark' 时写死 attribute。持久化到 localStorage。
|
||||
theme: ThemeMode;
|
||||
setTheme: (t: ThemeMode) => void;
|
||||
|
||||
// ---- locale slice ----
|
||||
locale: Locale;
|
||||
setLocale: (l: Locale) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user