鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移
后端(委托 Auth Broker,路径 A): - 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest() - internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等 统一会话模型“委派设备会话”(Delegated Device Sessions): - 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表 - 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备 - 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定 - 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行 Web 前端: - 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh - 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环 - 去 OIDC PKCE / step-up(删 oauth.callback / stepUp) 桌面客户端(Wails): - loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置 iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥): - 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库) - 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId 实时 / 健壮性: - presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线) - Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热) 配置 / 删旧栈: - config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效) - 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传) - 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- cdrop 无头传输引擎页(arch A,见 ios/PLAN.md §2)。无 UI / 字体 / 图标——只承载
|
||||
传输引擎。仅由原生 iOS 壳的离屏 WKWebView 从安全源(https)加载;原生须在本
|
||||
模块执行前经 WKUserScript 注入 window.__CDROP_BOOT__(session / device_name /
|
||||
api_base / device_type:"ios"),见 src/engine/main.ts。 -->
|
||||
<title>cdrop engine</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/engine/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { build } from "esbuild";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// 把 src/i18n/locales/*.ts 的扁平字典产出为 JSON,供 iOS 原生侧读取(强约束②:i18n 单一
|
||||
// 真源=web catalog,见 ios/PLAN.md §5)。locale 变更后重跑:node web/scripts/emit-i18n.mjs
|
||||
// 各 locale 产出完整字典(zh-TW / en-US 缺失键回退 zh-CN),故 iOS loader 无需再做回退。
|
||||
const webDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const repoRoot = join(webDir, "..");
|
||||
const outDir = join(repoRoot, "ios", "CDrop", "Sources", "Resources", "i18n");
|
||||
const cacheDir = join(webDir, "node_modules", ".cache");
|
||||
const tmp = join(cacheDir, "cdrop-i18n-emit.mjs");
|
||||
|
||||
const entry = [
|
||||
'import { zhCN } from "./src/i18n/locales/zh-CN";',
|
||||
'import { zhTW } from "./src/i18n/locales/zh-TW";',
|
||||
'import { enUS } from "./src/i18n/locales/en-US";',
|
||||
"export const all = {",
|
||||
' "zh-CN": zhCN,',
|
||||
' "zh-TW": { ...zhCN, ...zhTW },',
|
||||
' "en-US": { ...zhCN, ...enUS },',
|
||||
"};",
|
||||
].join("\n");
|
||||
|
||||
const res = await build({
|
||||
stdin: { contents: entry, resolveDir: webDir, loader: "ts" },
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
write: false,
|
||||
platform: "neutral",
|
||||
});
|
||||
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(tmp, res.outputFiles[0].text);
|
||||
const mod = await import(pathToFileURL(tmp).href);
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
for (const [loc, dict] of Object.entries(mod.all))
|
||||
{
|
||||
await writeFile(join(outDir, `${loc}.json`), `${JSON.stringify(dict, null, 2)}\n`);
|
||||
}
|
||||
console.log("emitted i18n:", Object.keys(mod.all).join(", "), "->", outDir);
|
||||
@@ -0,0 +1,357 @@
|
||||
// cdrop 无头传输引擎入口(arch A,见 ios/PLAN.md §2 / §6)。
|
||||
//
|
||||
// 不加载 React / 路由 / UI——只复用 web 的传输引擎(P2P + relay + 信令 + 会话),由原生
|
||||
// iOS 壳经桥(net/ios.ts)驱动。原生在加载本 bundle 前经 WKUserScript 注入
|
||||
// window.__CDROP_BOOT__(session / device_name / api_base / device_type:"ios"),store 在
|
||||
// import 时即从中水合(见 store/helpers.ts)。本页须从 https 安全源加载——WebRTC 的
|
||||
// RTCPeerConnection 在非安全 origin 下可能被禁(见 ios/PLAN.md R-iOS-3)。
|
||||
//
|
||||
// 命令(原生 → 引擎,经 onNativeEvent):
|
||||
// sendFile { target, url, name, size, type? } —— 取回 url 指向的待发文件并发起传输
|
||||
// cancelTransfer { sessionId }
|
||||
// switchToRelay { sessionId } —— 手动切中继
|
||||
// session { access_token, refresh_token?, user } —— 原生重登 / 续期后推新令牌对
|
||||
// shutdown —— 断开 hub、停 ICE 刷新
|
||||
// 通知(引擎 → 原生,经 notifyNative):
|
||||
// ready { device } / presence { devices[] } / transfers { active[] } / transferDone {...}
|
||||
// / sendStarted {...} / error { stage, message }
|
||||
// sessionRotated { access_token, refresh_token } —— 引擎自刷致 broker 轮换后,回报原生
|
||||
// 更新 Keychain(重启免用失效旧 refresh)
|
||||
// authExpired {} —— 续期被 401 拒(refresh 过期 / 吊销),原生清 Keychain 回登录页
|
||||
|
||||
import { refreshSessionScope } from "../features/auth/auth";
|
||||
import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard";
|
||||
import { apiFetch } from "../net/api";
|
||||
import { startHub } from "../features/transfer/hub";
|
||||
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
|
||||
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
|
||||
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
|
||||
import { useAppStore } from "../store";
|
||||
import type { TransferRecord } from "../store/types";
|
||||
|
||||
interface SendFilePayload
|
||||
{
|
||||
target: string;
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
// 推给原生的精简传输视图:去掉 iceStats 大对象,只留原生 UI 需要的字段 + 一条压缩的
|
||||
// ICE 摘要(连接态 + 选中候选对),供详情页诊断 P2P 是否走 TURN 中继(pair 含 "relay"
|
||||
// 即中继路径,解释慢速)。
|
||||
function toWire(r: TransferRecord)
|
||||
{
|
||||
const ice = r.iceStats;
|
||||
return {
|
||||
sessionId: r.sessionId,
|
||||
direction: r.direction,
|
||||
fileName: r.fileName,
|
||||
fileSize: r.fileSize,
|
||||
state: r.state,
|
||||
mode: r.mode,
|
||||
peerName: r.peerName,
|
||||
phase: r.phase,
|
||||
bytesTransferred: r.bytesTransferred,
|
||||
bytesPerSec: r.bytesPerSec,
|
||||
ice: ice
|
||||
? {
|
||||
conn: ice.connection,
|
||||
local: ice.selectedPair?.local,
|
||||
remote: ice.selectedPair?.remote,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// hub 的生命周期句柄;shutdown 命令据此断开 SSE。
|
||||
const hubCtrl = new AbortController();
|
||||
|
||||
// 活跃传输快照推送节流:接收端每个 64KB chunk 都会更新 store(emitProgress),若每次都
|
||||
// 过桥推原生,会(1)让速度数字 ~10Hz 闪烁失去参考价值、(2)主线程被 postMessage/序列化抖
|
||||
// 动堆满拖慢 dc.onmessage(见 p2p.ts 收方看门狗注释)→ 反噬 P2P 吞吐。故合并到 ~3Hz
|
||||
// 尾沿推送:进度条仍流畅、速度可读,主线程负载大幅下降。终态另由 transferDone 即时推。
|
||||
const TRANSFERS_MIN_INTERVAL_MS = 350;
|
||||
let transfersTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingActive: unknown[] | null = null;
|
||||
let lastTransfersAt = 0;
|
||||
|
||||
function pushTransfersThrottled(active: unknown[]): void
|
||||
{
|
||||
pendingActive = active;
|
||||
if (transfersTimer !== null) { return; }
|
||||
const wait = Math.max(0, TRANSFERS_MIN_INTERVAL_MS - (Date.now() - lastTransfersAt));
|
||||
transfersTimer = setTimeout(() =>
|
||||
{
|
||||
transfersTimer = null;
|
||||
lastTransfersAt = Date.now();
|
||||
notifyNative("transfers", { active: pendingActive });
|
||||
pendingActive = null;
|
||||
}, wait);
|
||||
}
|
||||
|
||||
// 订阅 store:activeTransfers 引用变化推快照、history 头部新增推完成事件、devices 引用
|
||||
// 变化推在线列表。原生据 sessionId / name 幂等更新自己的 UI。setDevices / setActive 每次
|
||||
// 都换数组 / 对象引用,故 !== 即可判变更。
|
||||
function subscribeStore(): void
|
||||
{
|
||||
const st0 = useAppStore.getState();
|
||||
let prevActive = st0.activeTransfers;
|
||||
let prevDoneId = st0.history[0]?.sessionId;
|
||||
let prevDevices = st0.devices;
|
||||
let prevSse = st0.sseConnected;
|
||||
let prevAuthed = st0.accessToken !== null;
|
||||
let prevRefresh = st0.refreshToken;
|
||||
let presenceCount = 0;
|
||||
|
||||
// 把 SSE 连接态 + 收到的 presence 计数推给原生,供设置页诊断「设备空」到底卡在哪:
|
||||
// SSE 未连接=鉴权/连接问题;已连接但 presence=0=后端没发;presence>0 但列表空=原生侧
|
||||
// (已用本地隔离证伪——原生侧 OK)。
|
||||
const pushHubState = (s: ReturnType<typeof useAppStore.getState>): void =>
|
||||
{
|
||||
notifyNative("hubState", {
|
||||
connected: s.sseConnected,
|
||||
reconnecting: s.sseReconnecting,
|
||||
presenceCount,
|
||||
devices: s.devices.length,
|
||||
});
|
||||
};
|
||||
|
||||
useAppStore.subscribe((s) =>
|
||||
{
|
||||
if (s.activeTransfers !== prevActive)
|
||||
{
|
||||
prevActive = s.activeTransfers;
|
||||
pushTransfersThrottled(Object.values(s.activeTransfers).map(toWire));
|
||||
}
|
||||
const head = s.history[0];
|
||||
if (head && head.sessionId !== prevDoneId)
|
||||
{
|
||||
prevDoneId = head.sessionId;
|
||||
notifyNative("transferDone", toWire(head));
|
||||
}
|
||||
if (s.devices !== prevDevices)
|
||||
{
|
||||
prevDevices = s.devices;
|
||||
presenceCount += 1;
|
||||
notifyNative("presence", { devices: s.devices });
|
||||
pushHubState(s);
|
||||
}
|
||||
if (s.sseConnected !== prevSse)
|
||||
{
|
||||
prevSse = s.sseConnected;
|
||||
pushHubState(s);
|
||||
}
|
||||
// 引擎自刷(apiFetch 401 → refreshTokens 用注入的 refresh_token 经 /api/auth/refresh
|
||||
// 续期)后 broker 轮换了 refresh_token。把新令牌对回报原生更新 Keychain,使重启后不再
|
||||
// 用已轮换失效的旧 refresh。
|
||||
if (s.refreshToken !== prevRefresh && s.refreshToken)
|
||||
{
|
||||
prevRefresh = s.refreshToken;
|
||||
notifyNative("sessionRotated", {
|
||||
access_token: s.accessToken,
|
||||
refresh_token: s.refreshToken,
|
||||
});
|
||||
}
|
||||
// 续期被服务端以 401 拒绝(refresh_token 已过期 / 被吊销)后 forceLogout 清空登录态
|
||||
// → 通知原生清 Keychain 回登录页,避免「貌似已登录但拉不到任何数据」的死态。仅在确证
|
||||
// 失效(非瞬时)时触发。
|
||||
const authed = s.accessToken !== null;
|
||||
if (prevAuthed && !authed)
|
||||
{
|
||||
notifyNative("authExpired", {});
|
||||
}
|
||||
prevAuthed = authed;
|
||||
});
|
||||
}
|
||||
|
||||
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为
|
||||
// File 交给引擎发起传输。
|
||||
//
|
||||
// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按
|
||||
// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。
|
||||
async function handleSendFile(p: SendFilePayload): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
const resp = await fetch(p.url);
|
||||
if (!resp.ok) { throw new Error(`fetch staged file failed: ${resp.status}`); }
|
||||
const blob = await resp.blob();
|
||||
const file = new File([ blob ], p.name, {
|
||||
type: p.type || blob.type || "application/octet-stream",
|
||||
});
|
||||
const sessionId = await startOutgoingTransfer(p.target, file);
|
||||
notifyNative("sendStarted", { sessionId, name: p.name });
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
notifyNative("error", { stage: "send", message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// bindCommands:把原生命令接到引擎函数。
|
||||
function bindCommands(): void
|
||||
{
|
||||
onNativeEvent("sendFile", (payload) =>
|
||||
{
|
||||
void handleSendFile(payload as SendFilePayload);
|
||||
});
|
||||
onNativeEvent("cancelTransfer", (payload) =>
|
||||
{
|
||||
void cancelTransfer((payload as { sessionId: string }).sessionId);
|
||||
});
|
||||
onNativeEvent("switchToRelay", (payload) =>
|
||||
{
|
||||
void skipWaitRelay((payload as { sessionId: string }).sessionId);
|
||||
});
|
||||
// 原生 Keychain 续期后推来的新会话令牌 → 写回 store,让 apiFetch 用新令牌(iOS 无
|
||||
// 浏览器 cookie / Go 进程,token 续期由原生侧持有 refresh_token 后驱动)。
|
||||
onNativeEvent("session", (payload) =>
|
||||
{
|
||||
const p = payload as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
user?: { id: string; name: string; avatar?: string };
|
||||
};
|
||||
if (p.access_token && p.user)
|
||||
{
|
||||
useAppStore.getState().setAuth({
|
||||
accessToken: p.access_token,
|
||||
refreshToken: p.refresh_token ?? null,
|
||||
user: p.user,
|
||||
});
|
||||
}
|
||||
});
|
||||
// 剪贴板上行:原生读 UIPasteboard 后把文本送来,经云剪贴板上传(PUT /api/clipboard)。
|
||||
onNativeEvent("clipboardUpload", (payload) =>
|
||||
{
|
||||
const content = (payload as { content?: string }).content ?? "";
|
||||
if (!content) { return; }
|
||||
void uploadClipboard(content)
|
||||
.then(() => notifyNative("clipboardUploaded", {}))
|
||||
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
|
||||
});
|
||||
// 剪贴板下行:拉一次云剪贴板,把最新内容推回原生写入 UIPasteboard。
|
||||
onNativeEvent("clipboardPull", () =>
|
||||
{
|
||||
void fetchClipboard()
|
||||
.then(() =>
|
||||
{
|
||||
const c = useAppStore.getState().clipboard;
|
||||
notifyNative("clipboard", { content: c?.content ?? "", sourceDevice: c?.sourceDevice ?? "" });
|
||||
})
|
||||
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
|
||||
});
|
||||
// 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求
|
||||
// step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。
|
||||
onNativeEvent("revokeDevice", (payload) =>
|
||||
{
|
||||
const name = (payload as { name?: string }).name ?? "";
|
||||
if (!name) { return; }
|
||||
void revokeDevice(name);
|
||||
});
|
||||
onNativeEvent("shutdown", () =>
|
||||
{
|
||||
hubCtrl.abort();
|
||||
stopICEServerRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeDevice(name: string): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
const r = await apiFetch(`/api/devices/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
if (r.status === 403)
|
||||
{
|
||||
notifyNative("error", { stage: "revoke", message: "step_up_required" });
|
||||
return;
|
||||
}
|
||||
if (!r.ok)
|
||||
{
|
||||
notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` });
|
||||
return;
|
||||
}
|
||||
notifyNative("deviceRevoked", { name });
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
notifyNative("error", { stage: "revoke", message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败
|
||||
// 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。
|
||||
function installLogBridge(): void
|
||||
{
|
||||
const safeStr = (a: unknown): string =>
|
||||
a instanceof Error
|
||||
? `${a.name}: ${a.message}`
|
||||
: typeof a === "object" && a !== null
|
||||
? (() => { try { return JSON.stringify(a); } catch { return String(a); } })()
|
||||
: String(a);
|
||||
const wrap = (level: string, orig: (...a: unknown[]) => void) =>
|
||||
(...args: unknown[]): void =>
|
||||
{
|
||||
try { notifyNative("log", { level, msg: args.map(safeStr).join(" ") }); }
|
||||
catch { /* 永不让日志桥拖垮引擎 */ }
|
||||
orig(...args);
|
||||
};
|
||||
console.warn = wrap("warn", console.warn.bind(console));
|
||||
console.error = wrap("error", console.error.bind(console));
|
||||
}
|
||||
|
||||
// boot:原生壳内启动引擎。store 已于 import 时从 __CDROP_BOOT__ 水合,故此处同步读取
|
||||
// 注入的登录态;缺失即报错(原生须在加载本 bundle 前注入)。
|
||||
function boot(): void
|
||||
{
|
||||
if (!isIOSShell())
|
||||
{
|
||||
// 非 iOS 壳(误加载 engine.html)——保持惰性,不触发任何网络 / 引擎行为。
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("cdrop engine: not running inside the iOS shell, idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const { user, selfDeviceName } = useAppStore.getState();
|
||||
if (!user || !selfDeviceName)
|
||||
{
|
||||
notifyNative("error", {
|
||||
stage: "boot",
|
||||
message: "missing injected session / device_name (window.__CDROP_BOOT__)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
installLogBridge(); // 把引擎 console.warn/error 过桥给原生,设置页显示,便于诊断 SSE 失败原因
|
||||
bindCommands();
|
||||
subscribeStore();
|
||||
|
||||
void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令
|
||||
void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用
|
||||
void refreshSessionScope(); // 校正 /api/me 的权限级别
|
||||
|
||||
// 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳
|
||||
// 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。
|
||||
const init = useAppStore.getState();
|
||||
notifyNative("ready", { device: selfDeviceName });
|
||||
notifyNative("presence", { devices: init.devices });
|
||||
notifyNative("transfers", { active: Object.values(init.activeTransfers).map(toWire) });
|
||||
|
||||
// 诊断隔离(仅 CDROP_DEBUG_SESSION 下、prod 用户无此 flag):经真 postMessage 桥推一
|
||||
// 条假 presence,验证「桥 → 原生解析 → UI 渲染」整条链路是否工作(不依赖真 SSE/鉴权,
|
||||
// 因 debug token 必 401 拿不到真 presence)。若原生「设备」据此显示出来 → 链路 OK,空
|
||||
// 列表的真因在上游(SSE 没连 / 后端空);若仍不显示 → 原生侧 bug。
|
||||
const boot = (window as unknown as { __CDROP_BOOT__?: { debug?: boolean } }).__CDROP_BOOT__;
|
||||
if (boot?.debug)
|
||||
{
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
notifyNative("presence", { devices: [
|
||||
{ name: "Debug-MacBook", type: "macos", online: true, lastSeen: nowSec },
|
||||
{ name: "Debug-PC", type: "windows", online: false, lastSeen: nowSec - 600 },
|
||||
] });
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
+258
-304
@@ -3,15 +3,21 @@ import { t } from "../../i18n";
|
||||
import { apiFetch } from "../../net/api";
|
||||
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
|
||||
import { fetchMeScope } from "../../net/sessions";
|
||||
import { stashStepUpPending } from "../qr/stepUp";
|
||||
import { toast } from "../../ui/feedback";
|
||||
|
||||
// Auth, post Auth Broker migration (path A). cdrop no longer runs OIDC itself:
|
||||
// - a brand-new browser logs in via the broker's global SSO (loginRedirect → cdrop
|
||||
// 302s to the broker login page; on return the edge injects X-Auth from the broker
|
||||
// domain cookie, so this browser holds NO token — the cookie carries identity);
|
||||
// - a QR-paired device holds the broker's access + refresh pair directly and renews
|
||||
// them via /api/auth/refresh (a thin same-origin proxy to the broker).
|
||||
|
||||
// ---- dev mode -------------------------------------------------------------
|
||||
|
||||
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage),
|
||||
// stamp the store with a synthetic User and the build-time dev token.
|
||||
// This bypasses Casdoor entirely. The middleware on the backend accepts
|
||||
// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN.
|
||||
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage), stamp the
|
||||
// store with a synthetic User and the build-time dev token. Bypasses the broker; the
|
||||
// backend dev middleware accepts any X-Dev-User as long as the bearer matches
|
||||
// CDROP_DEV_TOKEN.
|
||||
export function loginDev(searchParams: URLSearchParams): User
|
||||
{
|
||||
const devUser = searchParams.get("dev_user")
|
||||
@@ -26,292 +32,108 @@ export function loginDev(searchParams: URLSearchParams): User
|
||||
}
|
||||
|
||||
const user: User = { id: devUser, name: devUser };
|
||||
useAppStore.getState().setAuth({ accessToken: token, user });
|
||||
useAppStore.getState().setAuth({ accessToken: token, refreshToken: null, user });
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---- prod login (broker global SSO) ---------------------------------------
|
||||
|
||||
// loginRedirect sends the browser to the broker's global-SSO login via cdrop's
|
||||
// server-side 302 (which holds the broker's public URL). After login the browser
|
||||
// returns here with a broker domain cookie the edge turns into X-Auth.
|
||||
export function loginRedirect(): void
|
||||
{
|
||||
const rd = encodeURIComponent(window.location.href);
|
||||
window.location.href = `/api/auth/login?rd=${rd}`;
|
||||
}
|
||||
|
||||
// ---- logout ---------------------------------------------------------------
|
||||
|
||||
export function logout()
|
||||
{
|
||||
// Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端
|
||||
// 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出),
|
||||
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
|
||||
// ——只是体验降级回宽限期路径,不影响登出本身。
|
||||
// Fire-and-forget: tell the backend to kick this device from the Hub immediately
|
||||
// (so peers don't wait out the 30s grace). apiFetch reads the access token before
|
||||
// yielding, so the subsequent clearAuth can't strip this request's Authorization.
|
||||
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
|
||||
// 浏览器端:删除服务端 session 并清除 HttpOnly cookie(cookie 自动随同源请求),
|
||||
// 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。
|
||||
if (!isDesktop() && useAppStore.getState().authMode === "prod")
|
||||
// Revoke this device's own broker session server-side (self-service logout), so it
|
||||
// can't be refreshed back. For a global-SSO browser this is a no-op (nothing to
|
||||
// revoke). Best-effort.
|
||||
if (useAppStore.getState().authMode === "prod")
|
||||
{
|
||||
void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
|
||||
void apiFetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
|
||||
}
|
||||
useAppStore.getState().clearAuth();
|
||||
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
|
||||
// Desktop: drop the Go-side persisted session, else the next launch re-injects it.
|
||||
if (isDesktop()) { clearDesktopSession(); }
|
||||
}
|
||||
|
||||
// forceLogout 在「服务端确证会话已失效」时清掉本地登录态——触发点仅一处:
|
||||
// access token 401 后续期也被服务端以 401 拒绝(refreshTokens 返回 "invalid",
|
||||
// 即被远程吊销或已过期)。短期连接失败(网络抖动 / 5xx)绝不走到这里(见
|
||||
// net/api.ts)。与 logout() 不同,它不发任何服务端请求(会话已死,调用只会再次
|
||||
// 401),只就地清空 + 提示,让 __root 的反应式守卫把界面送回登录页
|
||||
// (离线设备「下一次使用即得知」)。
|
||||
//
|
||||
// 幂等:靠「已登出即跳过」实现——首个 401 同步清空 user/accessToken 后,并发涌入
|
||||
// 的其余 401 看到已为空便直接返回,不会重复弹 toast、不重复清桌面 session。
|
||||
// forceLogout clears local auth state when the server authoritatively confirms the
|
||||
// session is gone (refresh → 401). It sends no request (the session is dead) and just
|
||||
// clears + toasts; the __root guard then returns the UI to the login page. Idempotent:
|
||||
// concurrent 401s after the first clear see an empty state and return early.
|
||||
export function forceLogout(): void
|
||||
{
|
||||
const st = useAppStore.getState();
|
||||
if (!st.user && !st.accessToken)
|
||||
{
|
||||
// 已被并发的某个 401 清空,或本就处于登出态:不重复处理(避免 toast 刷屏)。
|
||||
return;
|
||||
}
|
||||
st.clearAuth();
|
||||
// 桌面端:删除 Go 侧持久化 session,否则下次启动仍注入这枚已失效的登录态。
|
||||
if (isDesktop()) { clearDesktopSession(); }
|
||||
toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body"));
|
||||
}
|
||||
|
||||
// ---- prod (OIDC PKCE) -----------------------------------------------------
|
||||
// ---- token refresh (broker proxy) -----------------------------------------
|
||||
|
||||
interface AuthConfig
|
||||
{
|
||||
auth_mode: "dev" | "prod";
|
||||
authorize_url: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
let cachedConfig: AuthConfig | null = null;
|
||||
|
||||
export async function fetchAuthConfig(): Promise<AuthConfig>
|
||||
{
|
||||
if (cachedConfig) { return cachedConfig; }
|
||||
const r = await fetch("/api/auth/config");
|
||||
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
|
||||
cachedConfig = await r.json() as AuthConfig;
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
|
||||
const OAUTH_STATE_KEY = "cdrop.oauth_state";
|
||||
|
||||
// step-up 再认证用一对独立的 state / verifier 句柄,绝不复用上面那对常规登录键——
|
||||
// 否则 /oauth/callback 在 step-up 往返里会被当成普通登录、误调 /api/auth/exchange。
|
||||
// state 走 sessionStorage(仅本次往返、关标签即清,与 step-up 暂存物同生命周期);
|
||||
// verifier 不单独落键,而是塞进 stepUp.ts 的 PENDING({verifier, returnTo})。
|
||||
const STEPUP_STATE_KEY = "cdrop.qr.stepup_state";
|
||||
|
||||
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them
|
||||
// in localStorage, navigate the browser to the provider's /authorize endpoint.
|
||||
// The callback page completes the flow.
|
||||
//
|
||||
// localStorage (not sessionStorage): a standalone PWA on iOS may run the
|
||||
// provider round-trip in a separate context and relaunch the PWA on the redirect
|
||||
// back, which wipes sessionStorage and breaks login. localStorage survives that.
|
||||
// The verifier is consumed + removed immediately on callback and is useless
|
||||
// without the matching auth code, so persisting it for the round-trip is fine.
|
||||
export async function loginProd(): Promise<void>
|
||||
{
|
||||
const cfg = await fetchAuthConfig();
|
||||
if (cfg.auth_mode !== "prod")
|
||||
{
|
||||
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
|
||||
}
|
||||
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
|
||||
{
|
||||
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
|
||||
}
|
||||
|
||||
const verifier = generateRandomBase64url(32);
|
||||
const challenge = await sha256Base64url(verifier);
|
||||
const state = generateRandomBase64url(16);
|
||||
|
||||
localStorage.setItem(PKCE_VERIFIER_KEY, verifier);
|
||||
localStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: cfg.client_id,
|
||||
redirect_uri: cfg.redirect_uri,
|
||||
response_type: "code",
|
||||
scope: cfg.scopes || "openid profile email",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
window.location.href = `${cfg.authorize_url}?${params}`;
|
||||
}
|
||||
|
||||
// startStepUpReauth 发起批准页 step-up 再认证:一次新鲜 PKCE 往返,但追加
|
||||
// prompt=login + max_age=0 强制 provider 现场重证(2FA 即在此处过)。复用既有
|
||||
// /oauth/callback 作 redirect_uri;跳转前用 sessionStorage 记下「待办」标记 +
|
||||
// 这次的 verifier + 要回到的批准页 URL(returnTo 走 stepUp.ts 的同款 open-redirect
|
||||
// 校验,仅允许 /link)。回调检测到「待办」即分叉,不消费 code(见 oauth.callback)。
|
||||
//
|
||||
// 注意:这里不写常规 PKCE_VERIFIER_KEY / OAUTH_STATE_KEY,否则普通登录回调会误判。
|
||||
// verifier 进 PENDING、state 进 STEPUP_STATE_KEY,二者均会在回调与批准页里清掉。
|
||||
export async function startStepUpReauth(returnTo: string): Promise<void>
|
||||
{
|
||||
const cfg = await fetchAuthConfig();
|
||||
if (cfg.auth_mode !== "prod")
|
||||
{
|
||||
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
|
||||
}
|
||||
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
|
||||
{
|
||||
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
|
||||
}
|
||||
|
||||
const verifier = generateRandomBase64url(32);
|
||||
const challenge = await sha256Base64url(verifier);
|
||||
const state = generateRandomBase64url(16);
|
||||
|
||||
// 先把「待办」落定(含 returnTo 的 open-redirect 校验);非法即拒,不发起跳转。
|
||||
if (!stashStepUpPending({ verifier, returnTo }))
|
||||
{
|
||||
throw new Error("invalid step-up return path");
|
||||
}
|
||||
sessionStorage.setItem(STEPUP_STATE_KEY, state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: cfg.client_id,
|
||||
redirect_uri: cfg.redirect_uri,
|
||||
response_type: "code",
|
||||
scope: cfg.scopes || "openid profile email",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
prompt: "login",
|
||||
max_age: "0",
|
||||
});
|
||||
window.location.href = `${cfg.authorize_url}?${params}`;
|
||||
}
|
||||
|
||||
// verifyStepUpState 在 /oauth/callback 的 step-up 分叉里校 state(防 CSRF,与常规
|
||||
// 登录回调同口径)。匹配即消费掉 STEPUP_STATE_KEY 并返回 true;不匹配返回 false。
|
||||
export function verifyStepUpState(state: string): boolean
|
||||
{
|
||||
const expected = sessionStorage.getItem(STEPUP_STATE_KEY);
|
||||
sessionStorage.removeItem(STEPUP_STATE_KEY);
|
||||
return !!expected && state === expected;
|
||||
}
|
||||
|
||||
// clearStepUpState 兜底清掉 step-up state(异常路径,避免残留)。
|
||||
export function clearStepUpState(): void
|
||||
{
|
||||
sessionStorage.removeItem(STEPUP_STATE_KEY);
|
||||
}
|
||||
|
||||
interface ExchangeResp
|
||||
interface BrokerRefreshResp
|
||||
{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: { id: string; name: string; avatar?: string };
|
||||
// refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚
|
||||
// HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。
|
||||
}
|
||||
|
||||
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
|
||||
// Verifies state (CSRF), trades code+verifier for tokens via the backend
|
||||
// proxy, and stamps the store with the user identity.
|
||||
//
|
||||
// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React
|
||||
// 渲染管线中重入,首次已成功 setAuth + 消耗 localStorage,二次入场不能再被
|
||||
// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接
|
||||
// resolve,由上层 navigate 接管。
|
||||
export async function completeOAuthLogin(code: string, state: string): Promise<void>
|
||||
{
|
||||
const cur = useAppStore.getState();
|
||||
if (cur.user && cur.accessToken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedState = localStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (!expectedState || state !== expectedState)
|
||||
{
|
||||
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
|
||||
}
|
||||
const verifier = localStorage.getItem(PKCE_VERIFIER_KEY);
|
||||
if (!verifier)
|
||||
{
|
||||
throw new Error("PKCE verifier missing — restart the login flow");
|
||||
}
|
||||
|
||||
const r = await fetch("/api/auth/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier }),
|
||||
});
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`token exchange ${r.status}: ${text}`);
|
||||
}
|
||||
const data = await r.json() as ExchangeResp;
|
||||
|
||||
localStorage.removeItem(PKCE_VERIFIER_KEY);
|
||||
localStorage.removeItem(OAUTH_STATE_KEY);
|
||||
|
||||
useAppStore.getState().setAuth({
|
||||
accessToken: data.access_token,
|
||||
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
||||
});
|
||||
}
|
||||
|
||||
interface CookieRefreshResp
|
||||
{
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
user: { id: string; name: string; avatar?: string };
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
// CookieRefreshResult 区分「服务端确证会话失效」与「短期失败」,使上层只在前者
|
||||
// 清空登录态:
|
||||
// ok —— 换到新 access token
|
||||
// invalid —— /api/auth/refresh 返回 401:服务端查证会话已失效 / 被吊销 / 过期
|
||||
// (其处理器同时清掉 cookie),凭据确凿不可恢复
|
||||
// transient —— 5xx / 同源校验 403 / 响应残缺等非确证失败(网络层抛错则根本不进此
|
||||
// 函数、照常向上抛)——一律保留登录态,按短期错误处理
|
||||
type CookieRefreshResult =
|
||||
| { kind: "ok"; accessToken: string; user: User; deviceName: string }
|
||||
type BrokerRefreshResult =
|
||||
| { kind: "ok"; accessToken: string; refreshToken: string }
|
||||
| { kind: "invalid" }
|
||||
| { kind: "transient" };
|
||||
|
||||
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
|
||||
// cookie 自动随同源请求发出(Path=/api/auth)。返回见 CookieRefreshResult:只有
|
||||
// 服务端明确以 401 拒绝才算 invalid,短期失败一律 transient,不误伤登录态。
|
||||
async function cookieRefresh(): Promise<CookieRefreshResult>
|
||||
// brokerRefresh exchanges the stored refresh token for a fresh access + rotated
|
||||
// refresh via cdrop's same-origin proxy to the broker. 401 → invalid (expired /
|
||||
// rotated / revoked); other non-OK / malformed → transient (keep the session).
|
||||
async function brokerRefresh(refreshToken: string): Promise<BrokerRefreshResult>
|
||||
{
|
||||
const r = await fetch("/api/auth/refresh", { method: "POST" });
|
||||
if (!r.ok)
|
||||
let r: Response;
|
||||
try
|
||||
{
|
||||
return { kind: r.status === 401 ? "invalid" : "transient" };
|
||||
r = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
}
|
||||
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
|
||||
if (!data?.access_token || !data.user?.id) { return { kind: "transient" }; }
|
||||
return {
|
||||
kind: "ok",
|
||||
accessToken: data.access_token,
|
||||
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
||||
deviceName: data.device_name ?? "",
|
||||
};
|
||||
catch
|
||||
{
|
||||
return { kind: "transient" };
|
||||
}
|
||||
if (!r.ok) { return { kind: r.status === 401 ? "invalid" : "transient" }; }
|
||||
const data = await r.json().catch(() => null) as BrokerRefreshResp | null;
|
||||
if (!data?.access_token || !data.refresh_token) { return { kind: "transient" }; }
|
||||
return { kind: "ok", accessToken: data.access_token, refreshToken: data.refresh_token };
|
||||
}
|
||||
|
||||
// RefreshOutcome 是续期对上层的三态结论;只有 "invalid"(服务端确证失效)才允许
|
||||
// 上层 forceLogout,"transient" 必须保留登录态(短期连接失败不清状态)。
|
||||
// RefreshOutcome is the three-state conclusion refresh reports upward; only "invalid"
|
||||
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
|
||||
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
|
||||
|
||||
// refreshTokens 换新 access_token;api.ts 在请求拿到 401 时惰性调用。
|
||||
// refreshTokens renews the access token; api.ts calls it lazily on a 401.
|
||||
export async function refreshTokens(): Promise<RefreshOutcome>
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return "transient"; }
|
||||
|
||||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),
|
||||
// 直接无参调用。Go 侧暂不区分「被吊销」与「短期失败」,故失败一律按 transient
|
||||
// 处理(不清空登录态)——桌面端即时吊销留后续(AUTH.md §3.1)。
|
||||
// Desktop: refresh happens entirely inside Go (the refresh token lives in the Go
|
||||
// process, never in JS). Go does not yet distinguish revoked from transient, so a
|
||||
// failure is treated as transient (keeps state).
|
||||
if (isDesktop())
|
||||
{
|
||||
const res = await desktopRefresh();
|
||||
@@ -322,78 +144,210 @@ export async function refreshTokens(): Promise<RefreshOutcome>
|
||||
return "refreshed";
|
||||
}
|
||||
|
||||
// 浏览器端:走 HttpOnly cookie session(refresh_token 在服务端,JS 不持有)。
|
||||
const res = await cookieRefresh();
|
||||
if (res.kind !== "ok") { return res.kind; } // "invalid" | "transient",透传给上层
|
||||
// After 代铸 a browser holds a device-session refresh token and renews via the broker
|
||||
// proxy below. A browser still on cookie-only identity (代铸 not yet run / failed) holds
|
||||
// none; a 401 then means the broker cookie is gone too: invalid → re-login.
|
||||
const refresh = store.refreshToken;
|
||||
if (!refresh) { return "invalid"; }
|
||||
|
||||
const res = await brokerRefresh(refresh);
|
||||
if (res.kind !== "ok") { return res.kind; }
|
||||
const cur = useAppStore.getState();
|
||||
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
||||
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
|
||||
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
||||
if (!cur.user) { return "transient"; }
|
||||
cur.setAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, user: cur.user });
|
||||
return "refreshed";
|
||||
}
|
||||
|
||||
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token,
|
||||
// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名),
|
||||
// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。
|
||||
export async function bootstrapAuth(): Promise<void>
|
||||
{
|
||||
if (isDesktop()) { return; }
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return; }
|
||||
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
|
||||
// ---- session bootstrap (/api/me) ------------------------------------------
|
||||
|
||||
const res = await cookieRefresh();
|
||||
if (res.kind !== "ok") { return; } // 开机水合失败(含 invalid / transient)→ 照常落登录页
|
||||
const cur = useAppStore.getState();
|
||||
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
||||
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
||||
interface MeResp
|
||||
{
|
||||
user_id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
device_name?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
// refreshSessionScope 拉一次 /api/me 校正本会话权限级别(full / guest)并写进
|
||||
// store。在登录成功 / 开机水合后调用一次,使受限访客的账号管理入口被隐藏。失败
|
||||
// 静默回退 "full"——只多展示入口,后端仍 403 兜底。dev 模式无 guest 概念,跳过。
|
||||
// fetchMe confirms the session with the server: a QR device sends its stored Bearer, a
|
||||
// global-SSO browser relies on the broker cookie (no header). Returns null on any
|
||||
// non-200 / error, so the caller falls to the login page.
|
||||
async function fetchMe(): Promise<MeResp | null>
|
||||
{
|
||||
const token = useAppStore.getState().accessToken;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) { headers.Authorization = `Bearer ${token}`; }
|
||||
let r: Response;
|
||||
try { r = await fetch("/api/me", { headers }); }
|
||||
catch { return null; }
|
||||
if (!r.ok) { return null; }
|
||||
return await r.json().catch(() => null) as MeResp | null;
|
||||
}
|
||||
|
||||
// bootstrapAuth runs before the app mounts: it confirms the session via /api/me and
|
||||
// hydrates the identity (a stored device token is preserved; a global-SSO browser has
|
||||
// none). Failure leaves the app logged out → login page. Desktop hydrates from boot
|
||||
// injection and dev has no broker session; both skip.
|
||||
export async function bootstrapAuth(): Promise<void>
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return; }
|
||||
|
||||
if (isDesktop())
|
||||
{
|
||||
// Desktop identity (name + avatar) comes from the Go boot injection, which heals a stale
|
||||
// persisted identity from /api/me — refreshing the token if needed and re-persisting —
|
||||
// BEFORE injecting it (see desktop HealIdentity). Doing it Go-side, at the source, is why
|
||||
// it no longer depends on a fragile per-launch WebView /api/me. Nothing to do here.
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await fetchMe();
|
||||
if (!me?.user_id) { return; }
|
||||
const cur = useAppStore.getState();
|
||||
// Use /api/me's name only when it is a real display name. The server falls back to the
|
||||
// subject UUID when X-Auth-Name is absent; in that case keep an already-known name (e.g.
|
||||
// rehydrated from a prior login) rather than clobbering it with the UUID.
|
||||
const verifiedName = me.name && me.name !== me.user_id ? me.name : "";
|
||||
cur.setAuth({
|
||||
accessToken: cur.accessToken, // preserve a stored device token, or null (cookie)
|
||||
user: {
|
||||
id: me.user_id,
|
||||
name: verifiedName || cur.user?.name || me.user_id,
|
||||
avatar: me.avatar || cur.user?.avatar,
|
||||
},
|
||||
});
|
||||
cur.setSessionScope(me.scope === "guest" ? "guest" : "full");
|
||||
if (me.device_name && !cur.selfDeviceName) { cur.setSelfDeviceName(me.device_name); }
|
||||
// Upgrade a global-SSO browser into a cdrop-managed device session (代铸) so it joins
|
||||
// the unified device list and rides a Bearer token like a QR-paired device. No-op if it
|
||||
// already holds a token or has no device name yet (first-time setup mints it on naming).
|
||||
await ensureDeviceSession();
|
||||
}
|
||||
|
||||
// ---- 代铸 (managed device session) ----------------------------------------
|
||||
|
||||
// DEVICE_ID_KEY persists this browser's stable cdrop device_id (the broker `meta` /
|
||||
// session<->device join key). It survives sessionStorage token loss so a re-login rotates
|
||||
// the same session (broker R2 idempotency) instead of spawning a duplicate device.
|
||||
const DEVICE_ID_KEY = "cdrop.device_id";
|
||||
|
||||
interface DeviceSessionResp
|
||||
{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ensureDeviceSession turns a global-SSO browser (cookie identity, no token) into a
|
||||
// cdrop-managed device session: the backend 代铸s a session bound to this browser's stable
|
||||
// device_id, so the browser appears in the unified device list exactly like a QR-paired
|
||||
// device and rides a Bearer token thereafter. Idempotent and best-effort:
|
||||
// - dev / desktop / not-logged-in → skip;
|
||||
// - already holds a token (QR device or a prior 代铸) → skip;
|
||||
// - no device name yet → skip (first-time setup calls this again after naming);
|
||||
// - on any failure the browser keeps working via the broker cookie (degraded: it just
|
||||
// won't show in the device list until a later 代铸 succeeds).
|
||||
export async function ensureDeviceSession(): Promise<void>
|
||||
{
|
||||
const st = useAppStore.getState();
|
||||
if (st.authMode !== "prod" || isDesktop()) { return; }
|
||||
if (!st.user) { return; }
|
||||
if (st.accessToken) { return; }
|
||||
if (!st.selfDeviceName) { return; }
|
||||
|
||||
// Serialize 代铸 across all same-origin tabs. Without this, two tabs (or a reload before the
|
||||
// first mint persists device_id) each 代铸 with an EMPTY device_id, and the server mints a
|
||||
// fresh id for each → duplicate phantom devices (the very regression this rework fixes). The
|
||||
// first lock holder mints and persists device_id; later holders read the seeded id and the
|
||||
// broker R2-rotates the one session. Web Locks coordinate across tabs; absent the API, fall
|
||||
// back to a best-effort single call.
|
||||
// Wrap in an arrow so the LockManager callback's Lock argument is not passed on as the
|
||||
// mintDeviceSession `force` flag.
|
||||
if (typeof navigator !== "undefined" && navigator.locks)
|
||||
{
|
||||
await navigator.locks.request("cdrop.device-session", () => mintDeviceSession());
|
||||
}
|
||||
else
|
||||
{
|
||||
await mintDeviceSession();
|
||||
}
|
||||
}
|
||||
|
||||
// renameDeviceSession re-代铸s with the (already-updated) selfDeviceName and the same stable
|
||||
// device_id, so the broker label — hence the unified device list and presence — reflects the
|
||||
// new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens
|
||||
// replace the browser's current ones. No-op unless this browser already holds a device session
|
||||
// (a cookie-only browser adopts the name at its first 代铸; desktop renames in its own client).
|
||||
export async function renameDeviceSession(): Promise<void>
|
||||
{
|
||||
const st = useAppStore.getState();
|
||||
if (st.authMode !== "prod" || isDesktop()) { return; }
|
||||
if (!st.user || !st.accessToken || !st.selfDeviceName) { return; }
|
||||
await mintDeviceSession(true);
|
||||
}
|
||||
|
||||
async function mintDeviceSession(force = false): Promise<void>
|
||||
{
|
||||
// Re-read inside the lock: another tab may have just seeded device_id (or this tab may have
|
||||
// gained a token meanwhile). force=true is the rename path: re-mint even though a token is
|
||||
// already held, to push the new label to the broker.
|
||||
const st = useAppStore.getState();
|
||||
if (!force && st.accessToken) { return; }
|
||||
const deviceName = st.selfDeviceName;
|
||||
if (!deviceName) { return; }
|
||||
|
||||
const storedId = window.localStorage.getItem(DEVICE_ID_KEY) ?? "";
|
||||
let r: Response;
|
||||
try
|
||||
{
|
||||
// Raw fetch (not apiFetch): identity rides the broker cookie, and a 401 here must
|
||||
// not trigger the refresh→forceLogout path (there is no refresh token yet).
|
||||
r = await fetch("/api/auth/device-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_id: storedId,
|
||||
device_name: deviceName,
|
||||
device_type: "browser",
|
||||
}),
|
||||
});
|
||||
}
|
||||
catch { return; }
|
||||
if (!r.ok) { return; }
|
||||
const data = await r.json().catch(() => null) as DeviceSessionResp | null;
|
||||
if (!data?.access_token || !data.device_id) { return; }
|
||||
|
||||
window.localStorage.setItem(DEVICE_ID_KEY, data.device_id);
|
||||
const cur = useAppStore.getState();
|
||||
if (!cur.user) { return; }
|
||||
cur.setAuth({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
user: cur.user,
|
||||
});
|
||||
}
|
||||
|
||||
// refreshSessionScope re-reads /api/me to correct the session's capability level
|
||||
// (full / guest) into the store, so a restricted guest's account-management entries
|
||||
// stay hidden. Best-effort; failure falls back to "full" (entries only show, backend
|
||||
// still 403s). dev has no guest concept; skip.
|
||||
export async function refreshSessionScope(): Promise<void>
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return; }
|
||||
if (!store.user || !store.accessToken) { return; }
|
||||
if (!store.user) { return; }
|
||||
const scope = await fetchMeScope();
|
||||
useAppStore.getState().setSessionScope(scope);
|
||||
}
|
||||
|
||||
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
|
||||
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
|
||||
// persistDesktopDeviceName(Go 持久化),dev 无 session。失败静默——只是体验降级。
|
||||
export function syncWebDeviceName(name: string): void
|
||||
// syncWebDeviceName is retained as a no-op after the migration: a QR-paired device's
|
||||
// name is set server-side at pairing (and renamed via the device API); a global-SSO
|
||||
// browser keeps no managed device row. The local selfDeviceName still drives presence.
|
||||
export function syncWebDeviceName(_name: string): void
|
||||
{
|
||||
if (isDesktop()) { return; }
|
||||
if (useAppStore.getState().authMode !== "prod") { return; }
|
||||
void fetch("/api/auth/device", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_name: name }),
|
||||
}).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
// ---- PKCE helpers ---------------------------------------------------------
|
||||
|
||||
function generateRandomBase64url(numBytes: number): string
|
||||
{
|
||||
const arr = new Uint8Array(numBytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return base64urlEncode(arr);
|
||||
}
|
||||
|
||||
async function sha256Base64url(input: string): Promise<string>
|
||||
{
|
||||
const buf = new TextEncoder().encode(input);
|
||||
const hash = await crypto.subtle.digest("SHA-256", buf);
|
||||
return base64urlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
function base64urlEncode(bytes: Uint8Array): string
|
||||
{
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
/* no-op: see comment above */
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做
|
||||
// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的
|
||||
// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback →
|
||||
// 批准页),故用 sessionStorage 串起三个状态:
|
||||
//
|
||||
// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页
|
||||
// URL(PENDING)。
|
||||
// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端
|
||||
// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH,
|
||||
// 清掉 PENDING,整页跳回批准页 URL。
|
||||
// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。
|
||||
//
|
||||
// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备;
|
||||
// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。
|
||||
|
||||
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
|
||||
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
|
||||
// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出,
|
||||
// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。
|
||||
const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionId(string)
|
||||
|
||||
// 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径):
|
||||
// - /link[?…] ——批准页(扫码登录的 step-up);
|
||||
// - /settings ——设置页(登录会话登出的 step-up)。
|
||||
// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以
|
||||
// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" /
|
||||
// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。
|
||||
function isSafeReturnPath(path: string): boolean
|
||||
{
|
||||
for (const base of [ "/link", "/settings" ])
|
||||
{
|
||||
if (path === base) { return true; }
|
||||
if (path.startsWith(base + "?")) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface StepUpPending
|
||||
{
|
||||
verifier: string;
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export interface StepUpStash
|
||||
{
|
||||
code: string;
|
||||
verifier: string;
|
||||
}
|
||||
|
||||
// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向)
|
||||
// 即不写——调用方据返回值判定是否安全发起跳转。
|
||||
export function stashStepUpPending(pending: StepUpPending): boolean
|
||||
{
|
||||
if (typeof window === "undefined") { return false; }
|
||||
if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; }
|
||||
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||
return true;
|
||||
}
|
||||
|
||||
// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉,
|
||||
// 决定不走 /api/auth/exchange)。不消费。
|
||||
export function peekStepUpPending(): StepUpPending | null
|
||||
{
|
||||
if (typeof window === "undefined") { return null; }
|
||||
const raw = window.sessionStorage.getItem(PENDING_KEY);
|
||||
if (!raw) { return null; }
|
||||
const p = safeParse<StepUpPending>(raw);
|
||||
if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; }
|
||||
return p;
|
||||
}
|
||||
|
||||
export function clearStepUpPending(): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
window.sessionStorage.removeItem(PENDING_KEY);
|
||||
}
|
||||
|
||||
// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给
|
||||
// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。
|
||||
export function stashStepUpCode(stash: StepUpStash): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
if (!stash.code || !stash.verifier) { return; }
|
||||
window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash));
|
||||
}
|
||||
|
||||
// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏
|
||||
// 与复用)。无 / 非法返回 null。
|
||||
export function consumeStepUpCode(): StepUpStash | null
|
||||
{
|
||||
if (typeof window === "undefined") { return null; }
|
||||
const raw = window.sessionStorage.getItem(STASH_KEY);
|
||||
window.sessionStorage.removeItem(STASH_KEY);
|
||||
if (!raw) { return null; }
|
||||
const s = safeParse<StepUpStash>(raw);
|
||||
if (!s?.code || !s.verifier) { return null; }
|
||||
return s;
|
||||
}
|
||||
|
||||
export function clearStepUpStash(): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
window.sessionStorage.removeItem(STASH_KEY);
|
||||
}
|
||||
|
||||
// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId,
|
||||
// 跨整页跳转留到回到设置页后取出重试。
|
||||
export function stashPendingRevoke(sessionId: string): void
|
||||
{
|
||||
if (typeof window === "undefined") { return; }
|
||||
if (!sessionId) { return; }
|
||||
window.sessionStorage.setItem(REVOKE_KEY, sessionId);
|
||||
}
|
||||
|
||||
// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。
|
||||
export function consumePendingRevoke(): string | null
|
||||
{
|
||||
if (typeof window === "undefined") { return null; }
|
||||
const id = window.sessionStorage.getItem(REVOKE_KEY);
|
||||
window.sessionStorage.removeItem(REVOKE_KEY);
|
||||
return id || null;
|
||||
}
|
||||
|
||||
function safeParse<T>(raw: string): T | null
|
||||
{
|
||||
try { return JSON.parse(raw) as T; }
|
||||
catch { return null; }
|
||||
}
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
isDesktop,
|
||||
saveIncomingFileDesktop,
|
||||
} from "../../net/desktop";
|
||||
import {
|
||||
abortIncomingDownloadIOS,
|
||||
appendIncomingDownloadIOS,
|
||||
beginIncomingDownloadIOS,
|
||||
finalizeIncomingDownloadIOS,
|
||||
isIOSShell,
|
||||
saveIncomingFileIOS,
|
||||
} from "../../net/ios";
|
||||
import { toast } from "../../ui/feedback";
|
||||
|
||||
// close() 两态结果:blob=整文件在内存,交给 downloadBlob;saved=桌面大文件已流式
|
||||
@@ -45,9 +53,13 @@ export interface IncomingSink
|
||||
cancel(): Promise<void>;
|
||||
}
|
||||
|
||||
// 桌面混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
|
||||
// 混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
|
||||
// 桌面 RAM 充裕、上限放宽;iOS 受 jetsam 限制(无预警直接 kill 进程),更早 spill、
|
||||
// 批更小,避免接收大文件时内存峰值触发 kill。
|
||||
const DESKTOP_MEMORY_CAP = 256 * 1024 * 1024;
|
||||
const DESKTOP_FLUSH_BATCH = 64 * 1024 * 1024;
|
||||
const IOS_MEMORY_CAP = 64 * 1024 * 1024;
|
||||
const IOS_FLUSH_BATCH = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 按环境选 sink:桌面走混合有界内存槽(见文件头注释),浏览器优先 OPFS、失败回退
|
||||
@@ -60,6 +72,10 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro
|
||||
{
|
||||
return openHybridDesktopSink(sessionId, fileName);
|
||||
}
|
||||
if (isIOSShell())
|
||||
{
|
||||
return openHybridIOSSink(sessionId, fileName);
|
||||
}
|
||||
// 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthy(optional
|
||||
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
|
||||
if (typeof navigator !== "undefined" && navigator.storage)
|
||||
@@ -74,38 +90,55 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro
|
||||
return openMemorySink();
|
||||
}
|
||||
|
||||
// 混合有界内存槽的存储后端配置:内存上限 / spill 批大小 + 四个落盘桥方法(begin /
|
||||
// append / finalize / abort,与桌面、iOS 各自的原生桥同形)。
|
||||
interface StreamingSinkConfig
|
||||
{
|
||||
cap: number;
|
||||
batch: number;
|
||||
begin: (sessionId: string) => Promise<void>;
|
||||
append: (sessionId: string, bytes: Uint8Array) => Promise<void>;
|
||||
finalize: (sessionId: string, name: string) => Promise<string>;
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面混合有界内存槽。小文件(暂存 < CAP)纯内存累积,close 时返回 Blob 交给
|
||||
* downloadBlob(= 既有桌面整文件过 Go 写盘路径);一旦暂存越过 CAP,转流式落盘——
|
||||
* 把已暂存的全部按 ≤批大小刷给 Go、释放内存,之后每满一批再刷,内存恒定有界。
|
||||
* 混合有界内存槽(桌面与 iOS 共用,只换落盘后端)。小文件(暂存 < cap)纯内存累积,
|
||||
* close 时返回 Blob 交给 downloadBlob(= 整文件过原生桥写盘路径);一旦暂存越过 cap,
|
||||
* 转流式落盘——把已暂存的全部按 ≤批大小刷给原生、释放内存,之后每满一批再刷,内存
|
||||
* 恒定有界。
|
||||
*
|
||||
* write() 由调用方(p2p / relay 的 receiveQueue promise 链)严格串行调用,故内部
|
||||
* 无需再加锁。
|
||||
*/
|
||||
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
|
||||
function openHybridStreamingSink(
|
||||
sessionId: string,
|
||||
fileName: string,
|
||||
cfg: StreamingSinkConfig,
|
||||
): IncomingSink
|
||||
{
|
||||
let pending: ArrayBuffer[] = [];
|
||||
let pendingBytes = 0;
|
||||
let spilled = false;
|
||||
let closed = false;
|
||||
|
||||
// 从 pending 头部取出至多 DESKTOP_FLUSH_BATCH 字节拼成一块 append 给 Go。
|
||||
// drainAll=true 把剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
|
||||
// 从 pending 头部取出至多 cfg.batch 字节拼成一块 append 给原生。drainAll=true 把
|
||||
// 剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
|
||||
const flush = async (drainAll: boolean): Promise<void> =>
|
||||
{
|
||||
while (pendingBytes >= DESKTOP_FLUSH_BATCH || (drainAll && pendingBytes > 0))
|
||||
while (pendingBytes >= cfg.batch || (drainAll && pendingBytes > 0))
|
||||
{
|
||||
let take = 0;
|
||||
const batch: ArrayBuffer[] = [];
|
||||
while (pending.length > 0
|
||||
&& (take === 0 || take + pending[0].byteLength <= DESKTOP_FLUSH_BATCH))
|
||||
&& (take === 0 || take + pending[0].byteLength <= cfg.batch))
|
||||
{
|
||||
const c = pending.shift() as ArrayBuffer;
|
||||
batch.push(c);
|
||||
take += c.byteLength;
|
||||
}
|
||||
pendingBytes -= take;
|
||||
await appendIncomingDownloadDesktop(sessionId, concatChunks(batch, take));
|
||||
await cfg.append(sessionId, concatChunks(batch, take));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,11 +153,11 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
|
||||
|
||||
if (!spilled)
|
||||
{
|
||||
if (pendingBytes >= DESKTOP_MEMORY_CAP)
|
||||
if (pendingBytes >= cfg.cap)
|
||||
{
|
||||
// 越过内存上限:开始流式落盘,把已暂存的全部刷给 Go,转滚动批。
|
||||
// 越过内存上限:开始流式落盘,把已暂存的全部刷给原生,转滚动批。
|
||||
spilled = true;
|
||||
await beginIncomingDownloadDesktop(sessionId);
|
||||
await cfg.begin(sessionId);
|
||||
await flush(true);
|
||||
}
|
||||
return;
|
||||
@@ -141,7 +174,7 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
|
||||
return { kind: "blob", blob: new Blob(pending) };
|
||||
}
|
||||
await flush(true);
|
||||
const path = await finalizeIncomingDownloadDesktop(sessionId, fileName);
|
||||
const path = await cfg.finalize(sessionId, fileName);
|
||||
return { kind: "saved", path };
|
||||
},
|
||||
async cancel()
|
||||
@@ -149,11 +182,37 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
|
||||
closed = true;
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
if (spilled) { await abortIncomingDownloadDesktop(sessionId); }
|
||||
if (spilled) { await cfg.abort(sessionId); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 桌面后端(Wails → Go)。
|
||||
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
|
||||
{
|
||||
return openHybridStreamingSink(sessionId, fileName, {
|
||||
cap: DESKTOP_MEMORY_CAP,
|
||||
batch: DESKTOP_FLUSH_BATCH,
|
||||
begin: beginIncomingDownloadDesktop,
|
||||
append: appendIncomingDownloadDesktop,
|
||||
finalize: finalizeIncomingDownloadDesktop,
|
||||
abort: abortIncomingDownloadDesktop,
|
||||
});
|
||||
}
|
||||
|
||||
// iOS 后端(WKWebView 桥 → 原生沙盒)。内存上限更小,避免 jetsam(见 IOS_MEMORY_CAP)。
|
||||
function openHybridIOSSink(sessionId: string, fileName: string): IncomingSink
|
||||
{
|
||||
return openHybridStreamingSink(sessionId, fileName, {
|
||||
cap: IOS_MEMORY_CAP,
|
||||
batch: IOS_FLUSH_BATCH,
|
||||
begin: beginIncomingDownloadIOS,
|
||||
append: appendIncomingDownloadIOS,
|
||||
finalize: finalizeIncomingDownloadIOS,
|
||||
abort: abortIncomingDownloadIOS,
|
||||
});
|
||||
}
|
||||
|
||||
// concatChunks 把若干 ArrayBuffer 拼成一个 Uint8Array(供 base64 过桥)。
|
||||
function concatChunks(parts: ArrayBuffer[], totalBytes: number): Uint8Array
|
||||
{
|
||||
@@ -275,6 +334,25 @@ export function downloadBlob(blob: Blob, fileName: string, sessionId?: string):
|
||||
});
|
||||
return;
|
||||
}
|
||||
// iOS 无头壳同理:整文件经桥交原生写入沙盒下载目录;WKWebView 无浏览器下载回退,
|
||||
// 故失败必须显式报错、绝不静默丢文件(同桌面)。
|
||||
if (isIOSShell())
|
||||
{
|
||||
void saveIncomingFileIOS(fileName, blob)
|
||||
.then((savedPath) =>
|
||||
{
|
||||
toast.ok(t("transfer.savedTo", { path: savedPath }));
|
||||
if (sessionId) { void cleanupOPFS(sessionId); }
|
||||
})
|
||||
.catch((e) =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("ios save failed", e);
|
||||
toast.error(t("transfer.saveFailed", { error: e instanceof Error ? e.message : String(e) }));
|
||||
if (sessionId) { void cleanupOPFS(sessionId); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
browserDownload(blob, fileName, sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ const CHANNEL_NAME = "cdrop-file";
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
const HIGH_WATERMARK = 16 * 1024 * 1024;
|
||||
const LOW_WATERMARK = 4 * 1024 * 1024;
|
||||
// 发送端「读写解耦」:一次读 4 MiB 进内存,再从内存切 CHUNK_SIZE 子片 dc.send。原先
|
||||
// 每 64 KiB 都 await file.slice().arrayBuffer(),逐片异步文件读在 iOS Safari 上极慢(实测
|
||||
// 直连 P2P 仅 ~250 KB/s,远低于中继 3 MB/s)。块读把异步文件读次数降 ~64 倍,SCTP 消息
|
||||
// 大小与流控(HIGH/LOW watermark)完全不变,接收端无感。
|
||||
const READ_BLOCK_SIZE = 4 * 1024 * 1024;
|
||||
const ICE_CANDIDATE_POOL = 4;
|
||||
|
||||
export interface FileMeta
|
||||
@@ -498,49 +503,65 @@ class Session
|
||||
// "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。
|
||||
this.startSendProgressPoll();
|
||||
|
||||
// 发送块大小固定 64 KiB:实测放大到 256 KiB 在 iOS Safari 上反而把吞吐打到
|
||||
// <100 KB/s(SCTP 大消息分片/重组代价),故维持 CHUNK_SIZE。读写解耦(块读)保留。
|
||||
const sendChunk = CHUNK_SIZE;
|
||||
|
||||
let offset = 0;
|
||||
while (offset < file.size)
|
||||
{
|
||||
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。
|
||||
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
|
||||
// kMaxQueuedSendDataBytes(16 MiB)时直接抛 OperationError;我们 HIGH
|
||||
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
|
||||
// 时不触发,下一片就会跨过硬上限挂掉。
|
||||
if (dc.bufferedAmount + CHUNK_SIZE > HIGH_WATERMARK)
|
||||
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。
|
||||
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size);
|
||||
const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer());
|
||||
|
||||
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。
|
||||
let inBlock = 0;
|
||||
while (inBlock < block.byteLength)
|
||||
{
|
||||
await waitForBufferLow(dc, LOW_WATERMARK);
|
||||
}
|
||||
const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
|
||||
const buf = await slice.arrayBuffer();
|
||||
try
|
||||
{
|
||||
dc.send(buf);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
// 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能
|
||||
// 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失
|
||||
// 败说明通道本身有问题,让外层 .catch 走 markServerFail。
|
||||
if (e instanceof DOMException && e.name === "OperationError")
|
||||
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。
|
||||
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
|
||||
// kMaxQueuedSendDataBytes(16 MiB)时直接抛 OperationError;我们 HIGH
|
||||
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
|
||||
// 时不触发,下一片就会跨过硬上限挂掉。
|
||||
if (dc.bufferedAmount + sendChunk > HIGH_WATERMARK)
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("p2p send queue full, backing off then retrying", {
|
||||
bufferedAmount: dc.bufferedAmount,
|
||||
readyState: dc.readyState,
|
||||
bytesSent: this.bytesSent,
|
||||
});
|
||||
await waitForBufferLow(dc, LOW_WATERMARK);
|
||||
dc.send(buf);
|
||||
}
|
||||
else
|
||||
const end = Math.min(inBlock + sendChunk, block.byteLength);
|
||||
// subarray 是零拷贝视图;dc.send 接受 ArrayBufferView,只发该视图字节范围。
|
||||
const chunk = block.subarray(inBlock, end);
|
||||
try
|
||||
{
|
||||
throw e;
|
||||
dc.send(chunk);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
// 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能
|
||||
// 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失
|
||||
// 败说明通道本身有问题,让外层 .catch 走 markServerFail。
|
||||
if (e instanceof DOMException && e.name === "OperationError")
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("p2p send queue full, backing off then retrying", {
|
||||
bufferedAmount: dc.bufferedAmount,
|
||||
readyState: dc.readyState,
|
||||
bytesSent: this.bytesSent,
|
||||
});
|
||||
await waitForBufferLow(dc, LOW_WATERMARK);
|
||||
dc.send(chunk);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const sent = end - inBlock;
|
||||
inBlock += sent;
|
||||
offset += sent;
|
||||
this.bytesSent = offset;
|
||||
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
|
||||
this.emitProgress();
|
||||
}
|
||||
offset += buf.byteLength;
|
||||
this.bytesSent = offset;
|
||||
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
|
||||
this.emitProgress();
|
||||
}
|
||||
|
||||
dc.send(JSON.stringify({ type: "done" }));
|
||||
|
||||
@@ -145,6 +145,8 @@ export const enUS: Partial<TranslationDict> = {
|
||||
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
|
||||
"qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
|
||||
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
|
||||
"qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.",
|
||||
"qr.approve.trustContinue": "Trust & Continue",
|
||||
"qr.approve.approve": "Approve sign-in",
|
||||
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
|
||||
"qr.approve.stepUp.button": "Verify and approve",
|
||||
@@ -343,4 +345,68 @@ export const enUS: Partial<TranslationDict> = {
|
||||
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
|
||||
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
|
||||
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
|
||||
|
||||
// ---- iOS client ------------------------------------------------------
|
||||
"ios.tab.transfer": "Transfers",
|
||||
"ios.tab.devices": "Devices",
|
||||
"ios.tab.settings": "Settings",
|
||||
"ios.login.generating": "Generating code…",
|
||||
"ios.login.waiting": "Waiting for approval",
|
||||
"ios.login.guide": "Scan to approve from another signed-in device",
|
||||
"ios.login.expired": "Code expired — please try again",
|
||||
"ios.login.failed": "Sign-in failed — please try again",
|
||||
"ios.login.refresh": "Refresh QR Code",
|
||||
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
|
||||
"ios.send.pickDevice": "Choose a device",
|
||||
"ios.transfer.sending": "Sending",
|
||||
"ios.settings.engine": "Engine",
|
||||
"ios.settings.status": "Status",
|
||||
"ios.settings.device": "Device",
|
||||
"ios.settings.clipboard": "Clipboard",
|
||||
"ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.",
|
||||
"ios.clipboard.upload": "Upload Clipboard",
|
||||
"ios.clipboard.pull": "Pull Clipboard",
|
||||
"ios.clipboard.uploading": "Uploading…",
|
||||
"ios.clipboard.pulling": "Pulling…",
|
||||
"ios.clipboard.uploaded": "Uploaded to cloud clipboard",
|
||||
"ios.clipboard.pulled": "Written to clipboard",
|
||||
"ios.clipboard.empty": "Clipboard is empty",
|
||||
"ios.clipboard.failed": "Clipboard sync failed",
|
||||
"ios.devices.remove": "Remove Device",
|
||||
"ios.devices.thisDevice": "This device",
|
||||
"ios.devices.revoked": "Device removed",
|
||||
"ios.devices.revokeFailed": "Remove failed",
|
||||
"ios.devices.revokeStepUp": "Re-verify on the web to remove this device",
|
||||
"ios.engine.disconnected": "Disconnected",
|
||||
"ios.engine.ready": "Ready",
|
||||
"ios.transfer.empty": "No transfers yet",
|
||||
"ios.transfer.incoming": "Receive",
|
||||
"ios.transfer.outgoing": "Send",
|
||||
"ios.send.noDevices": "No online devices to send to",
|
||||
"ios.devices.empty": "No devices yet",
|
||||
"ios.detail.title": "Transfer Details",
|
||||
"ios.detail.direction": "Direction",
|
||||
"ios.detail.peer": "Peer",
|
||||
"ios.detail.state": "State",
|
||||
"ios.detail.phase": "Phase",
|
||||
"ios.detail.channel": "Channel",
|
||||
"ios.detail.size": "Size",
|
||||
"ios.detail.progress": "Progress",
|
||||
"ios.detail.speed": "Speed",
|
||||
"ios.settings.account": "Account",
|
||||
"ios.settings.user": "User",
|
||||
"ios.settings.logout": "Sign Out",
|
||||
"ios.settings.deviceName": "Device Name",
|
||||
"ios.settings.deviceNameNote": "Renaming takes effect on next sign-in",
|
||||
"ios.settings.deviceCount": "Known Devices",
|
||||
"ios.settings.signaling": "Signaling",
|
||||
"ios.settings.presenceEvents": "Presence Events",
|
||||
"ios.settings.reconnecting": "Reconnecting",
|
||||
"ios.settings.lastLog": "Last Log",
|
||||
"ios.tab.files": "Files",
|
||||
"ios.files.title": "Received Files",
|
||||
"ios.files.empty": "No files received yet",
|
||||
"ios.files.share": "Share",
|
||||
"ios.files.done": "Done",
|
||||
"ios.detail.openFile": "Open File",
|
||||
};
|
||||
|
||||
@@ -142,6 +142,8 @@ export const zhCN = {
|
||||
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
|
||||
"qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
|
||||
"qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
|
||||
"qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。",
|
||||
"qr.approve.trustContinue": "信任并继续",
|
||||
"qr.approve.approve": "批准登录",
|
||||
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
|
||||
"qr.approve.stepUp.button": "验证并批准",
|
||||
@@ -339,4 +341,68 @@ export const zhCN = {
|
||||
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
|
||||
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
|
||||
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
|
||||
|
||||
// ---- iOS 客户端 ------------------------------------------------------
|
||||
"ios.tab.transfer": "传输",
|
||||
"ios.tab.devices": "设备",
|
||||
"ios.tab.settings": "设置",
|
||||
"ios.login.generating": "正在生成二维码…",
|
||||
"ios.login.waiting": "等待扫码批准",
|
||||
"ios.login.guide": "用另一台已登录的设备扫码批准",
|
||||
"ios.login.expired": "二维码已失效,请重试",
|
||||
"ios.login.failed": "登录失败,请重试",
|
||||
"ios.login.refresh": "刷新二维码",
|
||||
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
|
||||
"ios.send.pickDevice": "选择接收设备",
|
||||
"ios.transfer.sending": "发送中",
|
||||
"ios.settings.engine": "引擎",
|
||||
"ios.settings.status": "状态",
|
||||
"ios.settings.device": "设备",
|
||||
"ios.settings.clipboard": "剪贴板",
|
||||
"ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。",
|
||||
"ios.clipboard.upload": "上传剪贴板",
|
||||
"ios.clipboard.pull": "拉取剪贴板",
|
||||
"ios.clipboard.uploading": "正在上传…",
|
||||
"ios.clipboard.pulling": "正在拉取…",
|
||||
"ios.clipboard.uploaded": "已上传到云剪贴板",
|
||||
"ios.clipboard.pulled": "已写入本机剪贴板",
|
||||
"ios.clipboard.empty": "剪贴板为空",
|
||||
"ios.clipboard.failed": "剪贴板同步失败",
|
||||
"ios.devices.remove": "移除设备",
|
||||
"ios.devices.thisDevice": "本机",
|
||||
"ios.devices.revoked": "已移除设备",
|
||||
"ios.devices.revokeFailed": "移除失败",
|
||||
"ios.devices.revokeStepUp": "需在网页端重新验证后才能移除",
|
||||
"ios.engine.disconnected": "未连接",
|
||||
"ios.engine.ready": "已就绪",
|
||||
"ios.transfer.empty": "暂无传输",
|
||||
"ios.transfer.incoming": "接收",
|
||||
"ios.transfer.outgoing": "发送",
|
||||
"ios.send.noDevices": "没有在线设备,无法发送",
|
||||
"ios.devices.empty": "暂无设备",
|
||||
"ios.detail.title": "传输详情",
|
||||
"ios.detail.direction": "方向",
|
||||
"ios.detail.peer": "对端",
|
||||
"ios.detail.state": "状态",
|
||||
"ios.detail.phase": "阶段",
|
||||
"ios.detail.channel": "通道",
|
||||
"ios.detail.size": "大小",
|
||||
"ios.detail.progress": "进度",
|
||||
"ios.detail.speed": "速度",
|
||||
"ios.settings.account": "账户",
|
||||
"ios.settings.user": "用户",
|
||||
"ios.settings.logout": "退出登录",
|
||||
"ios.settings.deviceName": "设备名称",
|
||||
"ios.settings.deviceNameNote": "改名将在下次登录后生效",
|
||||
"ios.settings.deviceCount": "已知设备",
|
||||
"ios.settings.signaling": "信令连接",
|
||||
"ios.settings.presenceEvents": "在线事件",
|
||||
"ios.settings.reconnecting": "重连中",
|
||||
"ios.settings.lastLog": "最近日志",
|
||||
"ios.tab.files": "文件",
|
||||
"ios.files.title": "收到的文件",
|
||||
"ios.files.empty": "还没有收到文件",
|
||||
"ios.files.share": "分享",
|
||||
"ios.files.done": "完成",
|
||||
"ios.detail.openFile": "打开文件",
|
||||
} as const;
|
||||
|
||||
@@ -146,6 +146,8 @@ export const zhTW: Partial<TranslationDict> = {
|
||||
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
|
||||
"qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
|
||||
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
|
||||
"qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。",
|
||||
"qr.approve.trustContinue": "信任並繼續",
|
||||
"qr.approve.approve": "批准登入",
|
||||
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
|
||||
"qr.approve.stepUp.button": "驗證並批准",
|
||||
@@ -343,4 +345,68 @@ export const zhTW: Partial<TranslationDict> = {
|
||||
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
|
||||
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
|
||||
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
|
||||
|
||||
// ---- iOS 客戶端 ------------------------------------------------------
|
||||
"ios.tab.transfer": "傳輸",
|
||||
"ios.tab.devices": "裝置",
|
||||
"ios.tab.settings": "設定",
|
||||
"ios.login.generating": "正在產生 QR 碼…",
|
||||
"ios.login.waiting": "等待掃碼核准",
|
||||
"ios.login.guide": "用另一台已登入的裝置掃碼核准",
|
||||
"ios.login.expired": "QR 碼已失效,請重試",
|
||||
"ios.login.failed": "登入失敗,請重試",
|
||||
"ios.login.refresh": "重新整理 QR 碼",
|
||||
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
|
||||
"ios.send.pickDevice": "選擇接收裝置",
|
||||
"ios.transfer.sending": "傳送中",
|
||||
"ios.settings.engine": "引擎",
|
||||
"ios.settings.status": "狀態",
|
||||
"ios.settings.device": "裝置",
|
||||
"ios.settings.clipboard": "剪貼簿",
|
||||
"ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。",
|
||||
"ios.clipboard.upload": "上傳剪貼簿",
|
||||
"ios.clipboard.pull": "拉取剪貼簿",
|
||||
"ios.clipboard.uploading": "正在上傳…",
|
||||
"ios.clipboard.pulling": "正在拉取…",
|
||||
"ios.clipboard.uploaded": "已上傳到雲端剪貼簿",
|
||||
"ios.clipboard.pulled": "已寫入本機剪貼簿",
|
||||
"ios.clipboard.empty": "剪貼簿是空的",
|
||||
"ios.clipboard.failed": "剪貼簿同步失敗",
|
||||
"ios.devices.remove": "移除裝置",
|
||||
"ios.devices.thisDevice": "本機",
|
||||
"ios.devices.revoked": "已移除裝置",
|
||||
"ios.devices.revokeFailed": "移除失敗",
|
||||
"ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除",
|
||||
"ios.engine.disconnected": "未連線",
|
||||
"ios.engine.ready": "已就緒",
|
||||
"ios.transfer.empty": "尚無傳輸",
|
||||
"ios.transfer.incoming": "接收",
|
||||
"ios.transfer.outgoing": "傳送",
|
||||
"ios.send.noDevices": "沒有上線裝置,無法傳送",
|
||||
"ios.devices.empty": "尚無裝置",
|
||||
"ios.detail.title": "傳輸詳情",
|
||||
"ios.detail.direction": "方向",
|
||||
"ios.detail.peer": "對端",
|
||||
"ios.detail.state": "狀態",
|
||||
"ios.detail.phase": "階段",
|
||||
"ios.detail.channel": "通道",
|
||||
"ios.detail.size": "大小",
|
||||
"ios.detail.progress": "進度",
|
||||
"ios.detail.speed": "速度",
|
||||
"ios.settings.account": "帳戶",
|
||||
"ios.settings.user": "使用者",
|
||||
"ios.settings.logout": "登出",
|
||||
"ios.settings.deviceName": "裝置名稱",
|
||||
"ios.settings.deviceNameNote": "改名將在下次登入後生效",
|
||||
"ios.settings.deviceCount": "已知裝置",
|
||||
"ios.settings.signaling": "信令連線",
|
||||
"ios.settings.presenceEvents": "上線事件",
|
||||
"ios.settings.reconnecting": "重新連線中",
|
||||
"ios.settings.lastLog": "最近日誌",
|
||||
"ios.tab.files": "檔案",
|
||||
"ios.files.title": "收到的檔案",
|
||||
"ios.files.empty": "尚未收到檔案",
|
||||
"ios.files.share": "分享",
|
||||
"ios.files.done": "完成",
|
||||
"ios.detail.openFile": "開啟檔案",
|
||||
};
|
||||
|
||||
+5
-3
@@ -79,11 +79,13 @@ async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!state.accessToken)
|
||||
// A QR-paired device sends its broker access token as Bearer. A global-SSO
|
||||
// browser has none — the broker's domain cookie carries identity, so we send
|
||||
// no Authorization header and let the edge inject X-Auth from that cookie.
|
||||
if (state.accessToken)
|
||||
{
|
||||
throw new Error(t("errors.noAccessToken"));
|
||||
headers.set("Authorization", `Bearer ${state.accessToken}`);
|
||||
}
|
||||
headers.set("Authorization", `Bearer ${state.accessToken}`);
|
||||
}
|
||||
|
||||
if (state.selfDeviceName)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// iOS 原生壳的桥接层:仅在 iOS WKWebView「无头引擎」壳中可用。浏览器 / 桌面里
|
||||
// isIOSShell() 返回 false,所有调用点回退既有 Web 行为,因此本模块对纯 Web 与桌面
|
||||
// 构建零影响——同一份引擎代码同时部署到浏览器、桌面与 iOS 无头壳。
|
||||
//
|
||||
// 方向与桌面(Wails)相反,是本模块存在的根由:
|
||||
// - 桌面:React UI 在 WebView 顶层,调 DOWN 到 Go 绑定方法(window.go.main.App.*,
|
||||
// 直接返回 Promise)。
|
||||
// - iOS(arch A,见 ios/PLAN.md §2):原生 SwiftUI 在顶层,本 WebView 是「无头
|
||||
// 传输引擎」。JS→原生只能 postMessage 到 WKScriptMessageHandler(无返回值),
|
||||
// 原生→JS 用 evaluateJavaScript 回调全局函数。故 callNative 以「请求 id + 全局
|
||||
// resolver」把单向 postMessage 拼回请求 / 响应语义。
|
||||
//
|
||||
// 原生侧须实现的契约(Swift 实现据此对齐):
|
||||
// - 注册名为 "cdropEngine" 的 WKScriptMessageHandler,收 { id, method, payload }。
|
||||
// - 处理完一次 callNative 后,evaluateJavaScript 调
|
||||
// window.__cdropEngineResolve(id, ok, value)(ok=false 时 value 为错误串)。
|
||||
// - 推事件(进度 / 状态 / 传入 offer 等)用 evaluateJavaScript 调
|
||||
// window.__cdropEngineEvent(name, payload)。
|
||||
// - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload /
|
||||
// abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。
|
||||
|
||||
// WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。
|
||||
interface WebKitMessageHandler
|
||||
{
|
||||
postMessage: (msg: unknown) => void;
|
||||
}
|
||||
|
||||
// 原生注册的引擎消息处理器。其在场即判定为 iOS 无头壳。
|
||||
function handler(): WebKitMessageHandler | null
|
||||
{
|
||||
const wk = (window as unknown as {
|
||||
webkit?: { messageHandlers?: { cdropEngine?: WebKitMessageHandler } };
|
||||
}).webkit;
|
||||
return wk?.messageHandlers?.cdropEngine ?? null;
|
||||
}
|
||||
|
||||
// isIOSShell 仅要求消息处理器在场——原生注入它即代表无头壳就绪。浏览器 / 桌面里
|
||||
// 不存在 window.webkit.messageHandlers.cdropEngine,故恒为 false。
|
||||
export function isIOSShell(): boolean
|
||||
{
|
||||
return typeof window !== "undefined" && handler() !== null;
|
||||
}
|
||||
|
||||
// ── 请求 / 响应关联(callNative) ──────────────────────────────────────────
|
||||
|
||||
interface Pending
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
const pending = new Map<number, Pending>();
|
||||
let nextRequestId = 1;
|
||||
|
||||
// 安装全局 resolver(幂等):原生完成一次调用后经 evaluateJavaScript 调它回交结果。
|
||||
function installResolver(): void
|
||||
{
|
||||
const w = window as unknown as {
|
||||
__cdropEngineResolve?: (id: number, ok: boolean, value: unknown) => void;
|
||||
};
|
||||
if (w.__cdropEngineResolve) { return; }
|
||||
w.__cdropEngineResolve = (id, ok, value) =>
|
||||
{
|
||||
const p = pending.get(id);
|
||||
if (!p) { return; }
|
||||
pending.delete(id);
|
||||
if (ok) { p.resolve(value); }
|
||||
else { p.reject(new Error(typeof value === "string" ? value : "native error")); }
|
||||
};
|
||||
}
|
||||
|
||||
// callNative 投递一次 { id, method, payload } 给原生,返回待原生回调 resolve 的
|
||||
// Promise。桥缺失(非 iOS 壳)直接 reject——调用点应先以 isIOSShell() 守卫。
|
||||
function callNative<T>(method: string, payload?: unknown): Promise<T>
|
||||
{
|
||||
const h = handler();
|
||||
if (!h) { return Promise.reject(new Error("ios bridge unavailable")); }
|
||||
installResolver();
|
||||
const id = nextRequestId;
|
||||
nextRequestId += 1;
|
||||
return new Promise<T>((resolve, reject) =>
|
||||
{
|
||||
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
||||
h.postMessage({ id, method, payload });
|
||||
});
|
||||
}
|
||||
|
||||
// notifyNative 单向投递一条通知给原生(进度 / 状态 / 就绪 / 错误等,无需回执)。原生
|
||||
// 据消息形状区分:含 id 为 callNative 请求、含 notify 为单向通知。非 iOS 壳为 no-op,
|
||||
// 故引擎可无条件调用、无需在每个发射点加 isIOSShell() 分支。
|
||||
export function notifyNative(name: string, payload?: unknown): void
|
||||
{
|
||||
const h = handler();
|
||||
if (!h) { return; }
|
||||
h.postMessage({ notify: name, payload });
|
||||
}
|
||||
|
||||
// ── 原生 → JS 事件 ─────────────────────────────────────────────────────────
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||
|
||||
// 安装全局事件入口(幂等):原生用 evaluateJavaScript 调它派发事件。
|
||||
function installEventSink(): void
|
||||
{
|
||||
const w = window as unknown as {
|
||||
__cdropEngineEvent?: (name: string, payload: unknown) => void;
|
||||
};
|
||||
if (w.__cdropEngineEvent) { return; }
|
||||
w.__cdropEngineEvent = (name, payload) =>
|
||||
{
|
||||
const set = eventHandlers.get(name);
|
||||
if (!set) { return; }
|
||||
for (const fn of set) { fn(payload); }
|
||||
};
|
||||
}
|
||||
|
||||
// onNativeEvent 订阅一类原生事件,返回取消订阅函数。非 iOS 壳里仍可调用(只是原生
|
||||
// 永不派发),保持调用点无需分支。
|
||||
export function onNativeEvent(name: string, fn: EventHandler): () => void
|
||||
{
|
||||
installEventSink();
|
||||
let set = eventHandlers.get(name);
|
||||
if (!set)
|
||||
{
|
||||
set = new Set();
|
||||
eventHandlers.set(name, set);
|
||||
}
|
||||
set.add(fn);
|
||||
return () => { set?.delete(fn); };
|
||||
}
|
||||
|
||||
// ── 接收落盘桥(与桌面 desktop.ts 同形,落 iOS 沙盒) ──────────────────────
|
||||
|
||||
// bytesToBase64 分块走 btoa,避免一次 String.fromCharCode(...大数组) 触发调用栈溢出。
|
||||
// 与 desktop.ts 同实现;两边各持一份以保持桥模块互不依赖。
|
||||
function bytesToBase64(bytes: Uint8Array): string
|
||||
{
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK)
|
||||
{
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载
|
||||
// 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。
|
||||
export async function saveIncomingFileIOS(name: string, blob: Blob): Promise<string>
|
||||
{
|
||||
const buf = await blob.arrayBuffer();
|
||||
return callNative<string>("saveDownload", { name, data: bytesToBase64(new Uint8Array(buf)) });
|
||||
}
|
||||
|
||||
// 流式落盘封装:接收端混合 sink 越过内存上限后,把溢出批次交原生写临时文件,finalize
|
||||
// 时改名落定。与桌面 Begin/Append/Finalize/Abort 同语义(见 incomingSink.ts)。
|
||||
export async function beginIncomingDownloadIOS(sessionId: string): Promise<void>
|
||||
{
|
||||
await callNative<void>("beginDownload", { sessionId });
|
||||
}
|
||||
|
||||
export async function appendIncomingDownloadIOS(sessionId: string, bytes: Uint8Array): Promise<void>
|
||||
{
|
||||
await callNative<void>("appendDownload", { sessionId, data: bytesToBase64(bytes) });
|
||||
}
|
||||
|
||||
export async function finalizeIncomingDownloadIOS(sessionId: string, name: string): Promise<string>
|
||||
{
|
||||
return callNative<string>("finalizeDownload", { sessionId, name });
|
||||
}
|
||||
|
||||
export async function abortIncomingDownloadIOS(sessionId: string): Promise<void>
|
||||
{
|
||||
if (!handler()) { return; }
|
||||
try { await callNative<void>("abortDownload", { sessionId }); }
|
||||
catch { /* 取消 / 失败路径,吞掉 */ }
|
||||
}
|
||||
+9
-34
@@ -67,8 +67,10 @@ export interface QrApprovedUser
|
||||
export interface QrStatus
|
||||
{
|
||||
status: QrStatusPhase;
|
||||
// status === "approved" 时一并带回:
|
||||
// status === "approved" 时一并带回 broker 令牌对 + 设备身份:
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
deviceId?: string;
|
||||
expiresIn?: number;
|
||||
user?: QrApprovedUser;
|
||||
deviceName?: string;
|
||||
@@ -78,6 +80,8 @@ interface QrStatusRaw
|
||||
{
|
||||
status: QrStatusPhase;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
device_id?: string;
|
||||
expires_in?: number;
|
||||
user?: QrApprovedUser;
|
||||
device_name?: string;
|
||||
@@ -102,6 +106,8 @@ async function qrStatusOnce(
|
||||
return {
|
||||
status: data.status,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
deviceId: data.device_id,
|
||||
expiresIn: data.expires_in,
|
||||
user: data.user,
|
||||
deviceName: data.device_name,
|
||||
@@ -174,9 +180,6 @@ export interface QrRequestInfo
|
||||
requestIp: string;
|
||||
expiresAt: number;
|
||||
status: QrStatusPhase;
|
||||
// step_up 开启时为 true:批准前必须做一次新鲜的 prompt=login 再认证,把这次
|
||||
// 拿到的授权码 + verifier 交给 approve 就地核验(见 features/qr/stepUp.ts)。
|
||||
stepUp: boolean;
|
||||
}
|
||||
|
||||
interface QrRequestInfoRaw
|
||||
@@ -186,7 +189,6 @@ interface QrRequestInfoRaw
|
||||
request_ip: string;
|
||||
expires_at: number;
|
||||
status: QrStatusPhase;
|
||||
step_up?: boolean;
|
||||
}
|
||||
|
||||
// qrRequest 取待批准设备的信息,批准页据此渲染安全确认。需完整登录(apiFetch 带
|
||||
@@ -202,35 +204,17 @@ export async function qrRequest(requestId: string, code: string): Promise<QrRequ
|
||||
requestIp: data.request_ip,
|
||||
expiresAt: data.expires_at,
|
||||
status: data.status,
|
||||
stepUp: data.step_up === true,
|
||||
};
|
||||
}
|
||||
|
||||
// stepUpRequired 标记 approve 因 step-up 再认证缺失 / 过期被后端拒(403
|
||||
// {error:"step_up_required"})。批准页据此把用户带回再认证一遭,而非当成普通错误。
|
||||
export class StepUpRequiredError extends Error
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super("step_up_required");
|
||||
this.name = "StepUpRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
// qrApprove 批准该设备登入本账号。scope 决定授予的会话级别:full=完整登录、
|
||||
// guest=受限访客;与 persist 时长一一对应(full+persist=7 天滑动、guest+once=1 小时)。
|
||||
// 成功 204。
|
||||
//
|
||||
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier,
|
||||
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
|
||||
// 403 step_up_required(抛 StepUpRequiredError)。step_up 关时这俩字段被后端忽略。
|
||||
// guest=受限访客(经 broker 委托签发对应 tier 的会话)。成功 204。step-up 二次认证
|
||||
// 已移除(full 档即视为可信,路由层 requireFullSession 守门)。
|
||||
export async function qrApprove(
|
||||
requestId: string,
|
||||
code: string,
|
||||
scope: QrScope,
|
||||
persist: QrPersist,
|
||||
stepUpCode?: string,
|
||||
stepUpVerifier?: string,
|
||||
): Promise<void>
|
||||
{
|
||||
const body: Record<string, string> = {
|
||||
@@ -239,11 +223,6 @@ export async function qrApprove(
|
||||
scope,
|
||||
persist,
|
||||
};
|
||||
if (stepUpCode && stepUpVerifier)
|
||||
{
|
||||
body.step_up_code = stepUpCode;
|
||||
body.step_up_verifier = stepUpVerifier;
|
||||
}
|
||||
const r = await apiFetch("/api/auth/qr/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -252,10 +231,6 @@ export async function qrApprove(
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
if (r.status === 403 && /step_up_required/.test(text))
|
||||
{
|
||||
throw new StepUpRequiredError();
|
||||
}
|
||||
throw new Error(`qr approve ${r.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-53
@@ -33,11 +33,11 @@ export async function fetchMeScope(): Promise<SessionScopeValue>
|
||||
// 会话「种类」:oidc / self / guest 为网页 / 扫码会话;macos / windows / linux / ios
|
||||
// 为原生客户端(桌面 / 移动)——它们用 IdP 令牌、不入 web_sessions,由设备登记表呈现。
|
||||
export type SessionKind =
|
||||
| "oidc" | "self" | "guest"
|
||||
| "browser"
|
||||
| "macos" | "windows" | "linux" | "ios";
|
||||
|
||||
const KNOWN_KINDS: readonly SessionKind[] = [
|
||||
"oidc", "self", "guest", "macos", "windows", "linux", "ios",
|
||||
"browser", "macos", "windows", "linux", "ios",
|
||||
];
|
||||
|
||||
export interface AuthSession
|
||||
@@ -50,25 +50,21 @@ export interface AuthSession
|
||||
current: boolean;
|
||||
// online=true:该设备当前在线(有活跃 SSE 连接)。用于列表排序与在线指示。
|
||||
online: boolean;
|
||||
// native=true:原生客户端(无 web_session),登出走设备登记端点而非会话端点。
|
||||
native: boolean;
|
||||
createdAt: number;
|
||||
lastUsedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface AuthSessionRaw
|
||||
{
|
||||
id: string;
|
||||
device_id?: string;
|
||||
device_name?: string;
|
||||
kind?: string;
|
||||
scope?: string;
|
||||
current?: boolean;
|
||||
online?: boolean;
|
||||
native?: boolean;
|
||||
created_at?: number;
|
||||
last_used_at?: number;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
interface SessionsResp
|
||||
@@ -78,7 +74,7 @@ interface SessionsResp
|
||||
|
||||
function normalizeKind(kind: string | undefined): SessionKind
|
||||
{
|
||||
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "oidc";
|
||||
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "browser";
|
||||
}
|
||||
|
||||
// listSessions 拉取当前账号下的全部登录会话(含本机),每条带 online(当前是否在线)。
|
||||
@@ -96,61 +92,19 @@ export async function listSessions(): Promise<AuthSession[]>
|
||||
scope: r.scope === "guest" ? "guest" : "full",
|
||||
current: r.current === true,
|
||||
online: r.online === true,
|
||||
native: r.native === true,
|
||||
createdAt: r.created_at ?? 0,
|
||||
lastUsedAt: r.last_used_at ?? 0,
|
||||
expiresAt: r.expires_at ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// StepUpRequiredError:DELETE /api/auth/sessions/{id} 因再认证缺失 / 过期被后端拒
|
||||
// (403 {error:"step_up_required"})。调用方据此发起一次 step-up 再认证而非当成
|
||||
// 普通错误。
|
||||
export class StepUpRequiredError extends Error
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super("step_up_required");
|
||||
this.name = "StepUpRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
// revokeSession 真正吊销一条登录会话。网页 / 扫码会话走 /auth/sessions/{id}(删会话
|
||||
// 行 + 连带删设备);原生客户端(id 形如 "device:设备名")无 web_session,改走
|
||||
// /devices/{name} 按设备名注销。两者均:成功 204;后端要求再认证时抛
|
||||
// StepUpRequiredError(403 step_up_required),其余非 2xx 抛普通错误。
|
||||
const NATIVE_ID_PREFIX = "device:";
|
||||
|
||||
// revokeSession 吊销一条会话(= 一台设备):DELETE /api/auth/sessions/{id}(id=device_id)。
|
||||
// 后端连带 broker 吊销该设备的会话 + 删本地设备行。成功 204。step-up 二次认证已移除。
|
||||
export async function revokeSession(id: string): Promise<void>
|
||||
{
|
||||
const path = id.startsWith(NATIVE_ID_PREFIX)
|
||||
? `/api/devices/${encodeURIComponent(id.slice(NATIVE_ID_PREFIX.length))}`
|
||||
: `/api/auth/sessions/${encodeURIComponent(id)}`;
|
||||
const r = await apiFetch(path, { method: "DELETE" });
|
||||
const r = await apiFetch(`/api/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
if (r.status === 403 && /step_up_required/.test(text))
|
||||
{
|
||||
throw new StepUpRequiredError();
|
||||
}
|
||||
throw new Error(`revoke session ${r.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
// postStepUp 把一次新鲜 prompt=login 再认证拿到的 {code, verifier} 交给后端就地
|
||||
// 核验,成功后服务端在本会话记下 stepped_up_at(5 分钟窗口内复用,不再反复要求)。
|
||||
// 成功 204;step-up 未开后端亦 204(视作通过);失败 403。
|
||||
export async function postStepUp(code: string, verifier: string): Promise<void>
|
||||
{
|
||||
const r = await apiFetch("/api/auth/stepup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, verifier }),
|
||||
});
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`step-up ${r.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,8 +339,7 @@ function isAuthRoute(pathname: string): boolean
|
||||
|| pathname === "/setup"
|
||||
|| pathname === "/link"
|
||||
|| pathname === "/link/new"
|
||||
|| pathname === "/link/scan"
|
||||
|| pathname.startsWith("/oauth/");
|
||||
|| pathname === "/link/scan";
|
||||
}
|
||||
|
||||
function initial(name: string): string
|
||||
|
||||
+47
-93
@@ -2,12 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Check, Clock, LogIn, ShieldCheck, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { loginProd, startStepUpReauth } from "../features/auth/auth";
|
||||
import { loginRedirect } from "../features/auth/auth";
|
||||
import { stashLinkReturn } from "../features/qr/returnTo";
|
||||
import { consumeStepUpCode, type StepUpStash } from "../features/qr/stepUp";
|
||||
import { isDesktop, startDesktopLogin } from "../net/desktop";
|
||||
import {
|
||||
qrApprove, qrDeny, qrRequest, StepUpRequiredError,
|
||||
qrApprove, qrDeny, qrRequest,
|
||||
type QrPersist, type QrRequestInfo,
|
||||
} from "../net/qr";
|
||||
import { useAppStore } from "../store";
|
||||
@@ -44,12 +43,8 @@ function LinkApprovePage()
|
||||
const [ submitting, setSubmitting ] = useState(false);
|
||||
const [ outcome, setOutcome ] = useState<Outcome>(null);
|
||||
const [ actionError, setActionError ] = useState<string | null>(null);
|
||||
// step-up:true=后端要求批准前先做新鲜 prompt=login 再认证(provider 2FA 即此处过)。
|
||||
const [ stepUpRequired, setStepUpRequired ] = useState(false);
|
||||
// 再认证往返回来后由后端二次拒(403 step_up_required),文案提示并允许重试。
|
||||
const [ stepUpRejected, setStepUpRejected ] = useState(false);
|
||||
|
||||
// 未登录:暂存当前 URL 后走标准登录,登录后由首页 beforeLoad 弹回这里。
|
||||
// 未登录:暂存当前 URL 后走 broker 全局 SSO 登录,登录后由首页 beforeLoad 弹回这里。
|
||||
const handleSignIn = useCallback(async () =>
|
||||
{
|
||||
stashLinkReturn(window.location.pathname + window.location.search);
|
||||
@@ -60,12 +55,12 @@ function LinkApprovePage()
|
||||
await startDesktopLogin();
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
else { await loginProd(); }
|
||||
else { loginRedirect(); }
|
||||
}
|
||||
catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); }
|
||||
}, [ navigate ]);
|
||||
|
||||
// 已登录且参数齐全:拉取待批准设备信息(含 step_up 标记)。
|
||||
// 已登录且参数齐全:拉取待批准设备信息。
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!user || !requestId || !code) { return; }
|
||||
@@ -75,7 +70,6 @@ function LinkApprovePage()
|
||||
{
|
||||
if (cancelled) { return; }
|
||||
setInfo(data);
|
||||
setStepUpRequired(data.stepUp);
|
||||
if (data.status === "expired") { setOutcome("expired"); }
|
||||
else if (data.status === "denied") { setOutcome("denied"); }
|
||||
else if (data.status === "approved") { setOutcome("approved"); }
|
||||
@@ -85,69 +79,40 @@ function LinkApprovePage()
|
||||
return () => { cancelled = true; };
|
||||
}, [ user, requestId, code ]);
|
||||
|
||||
// doApprove 执行批准;stash 非空时把 step-up 再认证拿到的 code+verifier 一并交给
|
||||
// 后端就地核验。403 step_up_required(StepUpRequiredError)单独走「需重新验证」态,
|
||||
// 不当成普通错误。
|
||||
const doApprove = useCallback(async (stash?: StepUpStash) =>
|
||||
// doApprove 执行批准。step-up 二次认证已移除(full 档即可信,路由层守门)。
|
||||
const doApprove = useCallback(async () =>
|
||||
{
|
||||
if (!requestId || !code) { return; }
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
setStepUpRejected(false);
|
||||
try
|
||||
{
|
||||
// persist=「信任此设备」→ 完整会话(scope=full);once=「仅此次」→ 受限访客
|
||||
// (scope=guest)。后端按 scope 决定授予的会话级别,二者与 persist 时长一一对应。
|
||||
// (scope=guest)。后端按 scope 经 broker 委托签发对应 tier 的会话。
|
||||
const scope = persist === "persist" ? "full" : "guest";
|
||||
await qrApprove(requestId, code, scope, persist, stash?.code, stash?.verifier);
|
||||
await qrApprove(requestId, code, scope, persist);
|
||||
setOutcome("approved");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
|
||||
else { setActionError(e instanceof Error ? e.message : String(e)); }
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
finally { setSubmitting(false); }
|
||||
}, [ requestId, code, persist ]);
|
||||
|
||||
// 再认证往返回来:检测到暂存的 step-up {code, verifier} → 自动调 approve 完成批准
|
||||
// → 用完即清(consumeStepUpCode 取出即清)。仅在已登录、设备信息已就绪后跑一次。
|
||||
// 原生客户端(非浏览器:iOS / 桌面)只接受完整权限会话——用户原则:原生 App 不允许受限
|
||||
// 访客(仅 Web / PWA 可 guest)。故批准端对原生请求强制信任时长=persist(scope=full)、
|
||||
// 隐藏「仅此次」选项,只给「信任并继续 / 拒绝」。后端 handleQRApprove 亦兜底强制。
|
||||
const requiresFull = info != null && info.deviceType !== "browser";
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!user || !info || outcome) { return; }
|
||||
const stash = consumeStepUpCode();
|
||||
if (!stash) { return; }
|
||||
void doApprove(stash);
|
||||
}, [ user, info, outcome, doApprove ]);
|
||||
if (requiresFull) { setPersist("persist"); }
|
||||
}, [ requiresFull ]);
|
||||
|
||||
// handleApprove:批准按钮。step-up 要求且尚无再认证凭证时,先把用户带去做一次
|
||||
// 新鲜 prompt=login 再认证(往返回来后由上面的 effect 自动接力 approve);否则
|
||||
// 维持原有直接批准行为不变。
|
||||
// handleApprove:批准按钮,直接提交(step-up 已移除)。
|
||||
const handleApprove = async () =>
|
||||
{
|
||||
if (!requestId || !code) { return; }
|
||||
if (stepUpRequired)
|
||||
{
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
setStepUpRejected(false);
|
||||
try
|
||||
{
|
||||
// 把当前「信任时长」选择编进 returnTo:step-up 整页跳转回来后页面重载、
|
||||
// React state 复位,必须从 URL 恢复 persist,否则会回落默认、令本应受限
|
||||
// 的设备被建成完整会话(#2 根因)。
|
||||
const back = new URLSearchParams(window.location.search);
|
||||
back.set("p", persist);
|
||||
await startStepUpReauth(window.location.pathname + "?" + back.toString());
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
setSubmitting(false);
|
||||
}
|
||||
// 成功则整页跳转去 provider,本组件随之卸载,不再 setSubmitting。
|
||||
return;
|
||||
}
|
||||
await doApprove();
|
||||
};
|
||||
|
||||
@@ -241,46 +206,37 @@ function LinkApprovePage()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */}
|
||||
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
|
||||
<TrustOption
|
||||
value="persist"
|
||||
selected={persist === "persist"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.trust.title")}
|
||||
hint={t("qr.approve.trust.hint")}
|
||||
/>
|
||||
<TrustOption
|
||||
value="once"
|
||||
selected={persist === "once"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.once.title")}
|
||||
hint={t("qr.approve.once.hint")}
|
||||
/>
|
||||
</div>
|
||||
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。
|
||||
原生客户端不给选择——强制完整登录,故隐藏分段,仅留下方完整权限说明。 */}
|
||||
{!requiresFull && (
|
||||
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
|
||||
<TrustOption
|
||||
value="persist"
|
||||
selected={persist === "persist"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.trust.title")}
|
||||
hint={t("qr.approve.trust.hint")}
|
||||
/>
|
||||
<TrustOption
|
||||
value="once"
|
||||
selected={persist === "once"}
|
||||
onSelect={setPersist}
|
||||
title={t("qr.approve.once.title")}
|
||||
hint={t("qr.approve.once.hint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 权限范围说明:随选中项切换——信任=完整登录、仅此次=受限访客,明确告知
|
||||
这台设备将以什么身份登录(非装饰)。 */}
|
||||
{/* 权限范围说明:原生客户端固定完整登录;浏览器随选中项切换(信任=完整、仅此次=
|
||||
受限访客),明确告知这台设备将以什么身份登录(非装饰)。 */}
|
||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||
{persist === "persist"
|
||||
? t("qr.approve.scopeFull")
|
||||
: t("qr.approve.scopeGuest")}
|
||||
{requiresFull
|
||||
? t("qr.approve.nativeFull")
|
||||
: persist === "persist"
|
||||
? t("qr.approve.scopeFull")
|
||||
: t("qr.approve.scopeGuest")}
|
||||
</Callout>
|
||||
|
||||
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
|
||||
{stepUpRequired && !stepUpRejected && (
|
||||
<Callout tone="info" icon={<ShieldCheck size={16} />}>
|
||||
{t("qr.approve.stepUp.notice")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */}
|
||||
{stepUpRejected && (
|
||||
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||||
{t("qr.approve.stepUp.rejected")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{actionError}</Callout>
|
||||
)}
|
||||
@@ -291,13 +247,11 @@ function LinkApprovePage()
|
||||
size="lg"
|
||||
block
|
||||
loading={submitting}
|
||||
leftIcon={
|
||||
stepUpRequired ? <ShieldCheck size={16} /> : <Check size={16} />
|
||||
}
|
||||
leftIcon={<Check size={16} />}
|
||||
onClick={() => { void handleApprove(); }}
|
||||
>
|
||||
{stepUpRequired
|
||||
? t("qr.approve.stepUp.button")
|
||||
{requiresFull
|
||||
? t("qr.approve.trustContinue")
|
||||
: t("qr.approve.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -43,6 +43,7 @@ function LinkNewPage()
|
||||
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
|
||||
setAuth({
|
||||
accessToken: st.accessToken,
|
||||
refreshToken: st.refreshToken ?? null,
|
||||
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
|
||||
});
|
||||
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
|
||||
|
||||
+14
-13
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
|
||||
import { loginDev, loginRedirect } from "../features/auth/auth";
|
||||
import { isDesktop, startDesktopLogin } from "../net/desktop";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { useAppStore } from "../store";
|
||||
@@ -21,24 +21,23 @@ function LoginPage()
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
const localAuthMode = useAppStore((s) => s.authMode);
|
||||
const user = useAppStore((s) => s.user);
|
||||
|
||||
const [ devUser, setDevUser ] = useState(search.dev_user ?? "");
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
// 前端编译期推断的 mode(DEV/PROD)可能与后端实际部署不一致
|
||||
// (例如把 prod 构建打到 dev 后端测试),因此以服务器返回的为准。
|
||||
const [ serverMode, setServerMode ] = useState<"dev" | "prod" | null>(null);
|
||||
|
||||
// 已登录态落在 /login 时反应式跳首页。全局 SSO 登录回跳后 rd 指回本页(登录前用户
|
||||
// 被 __root 守卫从原路由送到了 /login),bootstrapAuth 据 broker cookie 注入身份后
|
||||
// 若不主动离开,就停在登录页形成“登录成功却又见登录页”的回环。__root 的守卫只把
|
||||
// 未登录态送进 /login,登录态的反向跳转由此补齐(含 dev 同步登录后的兜底)。
|
||||
useEffect(() =>
|
||||
{
|
||||
let cancelled = false;
|
||||
fetchAuthConfig().then(
|
||||
(cfg) => { if (!cancelled) { setServerMode(cfg.auth_mode); } },
|
||||
() => { /* 回退到编译期 mode */ },
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
if (user) { navigate({ to: "/" }); }
|
||||
}, [ user, navigate ]);
|
||||
|
||||
const effectiveMode = serverMode ?? localAuthMode;
|
||||
// 后端不再暴露 /api/auth/config(OIDC 已委托给 broker);登录模式以前端编译期
|
||||
// 注入的 authMode 为准(dev / prod)。
|
||||
const effectiveMode = localAuthMode;
|
||||
|
||||
const handleDevLogin = () =>
|
||||
{
|
||||
@@ -68,7 +67,9 @@ function LoginPage()
|
||||
}
|
||||
else
|
||||
{
|
||||
await loginProd();
|
||||
// 跳到 broker 全局 SSO 登录(cdrop 后端 302 代跳);登录后回跳本页,
|
||||
// 边缘 human 分支据 broker cookie 注入身份。
|
||||
loginRedirect();
|
||||
}
|
||||
}
|
||||
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Loader } from "@mantine/core";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
clearStepUpState, completeOAuthLogin, verifyStepUpState,
|
||||
} from "../features/auth/auth";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import {
|
||||
clearStepUpPending, peekStepUpPending, stashStepUpCode,
|
||||
} from "../features/qr/stepUp";
|
||||
import { t } from "../i18n";
|
||||
import { Callout } from "../ui/primitives";
|
||||
|
||||
export const Route = createFileRoute("/oauth/callback")({
|
||||
component: CallbackPage,
|
||||
validateSearch: (search: Record<string, unknown>) =>
|
||||
({
|
||||
code: typeof search.code === "string" ? search.code : undefined,
|
||||
state: typeof search.state === "string" ? search.state : undefined,
|
||||
error: typeof search.error === "string" ? search.error : undefined,
|
||||
error_description: typeof search.error_description === "string"
|
||||
? search.error_description
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function CallbackPage()
|
||||
{
|
||||
const search = Route.useSearch();
|
||||
const [ err, setErr ] = useState<string | null>(null);
|
||||
|
||||
// ref guard:completeOAuthLogin 内部会消费 localStorage 中的 PKCE_VERIFIER /
|
||||
// OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在
|
||||
// navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。
|
||||
//
|
||||
// navigate 是异步的:promise resolve → setErr/navigate;navigate 实际生效之间
|
||||
// 仍可能发生一次 re-render(useNavigate 返回值在某些版本里非稳定引用,会让
|
||||
// useEffect deps 抖动重跑)。ref guard 杜绝逻辑重入,cancelled flag 兜底防止
|
||||
// unmount 后 setErr。
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (startedRef.current) { return; }
|
||||
startedRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// 把 setErr 推迟一拍:navigate({to:"/"}) 是异步的,promise resolve
|
||||
// 后 callback 真正 unmount 前可能渲染一帧 err 状态(即使 success path
|
||||
// 不该走这里,但 setAuth → store 更新 → 父级 re-render → useNavigate
|
||||
// 引用波动 → effect 二度入场是过去的真实路径,残留闪现)。
|
||||
//
|
||||
// 失败时延迟 800ms 显示,给 success 路径足够时间触发卸载;真正失败
|
||||
// 800ms 内 cleanup 不会被调用,setErr 仍能渲染失败态。
|
||||
const showErrDeferred = (msg: string) =>
|
||||
{
|
||||
console.error("[oauth/callback]", msg);
|
||||
window.setTimeout(() =>
|
||||
{
|
||||
if (!cancelled) { setErr(msg); }
|
||||
}, 800);
|
||||
};
|
||||
|
||||
if (search.error)
|
||||
{
|
||||
showErrDeferred(`${search.error}:${search.error_description ?? ""}`);
|
||||
return;
|
||||
}
|
||||
if (!search.code || !search.state)
|
||||
{
|
||||
showErrDeferred(t("oauth.missingParams"));
|
||||
return;
|
||||
}
|
||||
|
||||
// step-up 分叉:批准页发起的 prompt=login 再认证回到这里。此时 code 是要
|
||||
// 交给后端 approve 去 IdP 换的一次性凭证——前端绝不调 /api/auth/exchange
|
||||
// 消费它(那会在服务端轮换会话)。只校 state(防 CSRF,同常规登录口径)、
|
||||
// 把 {code, verifier} 暂存给批准页、清掉「待办」,整页跳回批准页 URL。
|
||||
const pending = peekStepUpPending();
|
||||
if (pending)
|
||||
{
|
||||
if (!verifyStepUpState(search.state))
|
||||
{
|
||||
clearStepUpPending();
|
||||
showErrDeferred("OAuth state mismatch (possible CSRF or stale tab)");
|
||||
return;
|
||||
}
|
||||
stashStepUpCode({ code: search.code, verifier: pending.verifier });
|
||||
clearStepUpPending();
|
||||
// 整页跳转回批准页(returnTo 已在 stash 时通过 open-redirect 校验)。
|
||||
window.location.replace(pending.returnTo);
|
||||
return;
|
||||
}
|
||||
clearStepUpState(); // 非 step-up 路径兜底:清掉可能残留的 step-up state。
|
||||
|
||||
completeOAuthLogin(search.code, search.state).then(
|
||||
// 整页跳转(window.location.replace),不是路由软导航。Safari 下,从
|
||||
// Casdoor 回跳后那条客户端导航会复用 OAuth 流程遗留的 HTTP 连接,而这些
|
||||
// 连接在 Safari 里会卡死后续资源(setup.js / 头像)约 2 分钟——手动刷新即
|
||||
// 恢复,印证是连接状态问题。整页重载拿到全新连接,绕开该 bug;auth 已在
|
||||
// sessionStorage,同标签刷新存活。replace 不入历史,避免后退重触已消费的 code。
|
||||
() => { if (!cancelled) { window.location.replace("/"); } },
|
||||
(e) => { showErrDeferred(e instanceof Error ? e.message : String(e)); },
|
||||
);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅在 mount 时跑一次;search 由 URL 决定,mount 时即定。
|
||||
|
||||
if (err)
|
||||
{
|
||||
return (
|
||||
<AuthShell title={t("oauth.failed")} hideBrand>
|
||||
<Callout tone="error" icon={<AlertTriangle size={16} />}>{err}</Callout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthShell title={t("oauth.signingIn")} subtitle="正在向 Casdoor 交换访问令牌…" hideBrand>
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "var(--space-4) 0" }}>
|
||||
<Loader color="var(--accent)" />
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
+24
-120
@@ -8,14 +8,10 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { DesktopSettings } from "../features/desktop/DesktopSettings";
|
||||
import {
|
||||
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
|
||||
} from "../features/qr/stepUp";
|
||||
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
|
||||
import { apiFetch } from "../net/api";
|
||||
import {
|
||||
currentPushState,
|
||||
disablePush,
|
||||
@@ -24,7 +20,7 @@ import {
|
||||
type PushState,
|
||||
} from "../net/push";
|
||||
import {
|
||||
listSessions, postStepUp, revokeSession, StepUpRequiredError,
|
||||
listSessions, revokeSession,
|
||||
type AuthSession,
|
||||
} from "../net/sessions";
|
||||
import { useAppStore } from "../store";
|
||||
@@ -77,21 +73,14 @@ function SettingsPage()
|
||||
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}`);
|
||||
}
|
||||
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连
|
||||
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话,
|
||||
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名,
|
||||
// broker label 于下次登录代铸时同步。
|
||||
setSelfDeviceName(trimmedName);
|
||||
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
await renameDeviceSession();
|
||||
toast.ok(t("settings.deviceName.success"));
|
||||
}
|
||||
catch (e)
|
||||
@@ -106,28 +95,15 @@ function SettingsPage()
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnregisterSelf = async () =>
|
||||
const handleUnregisterSelf = () =>
|
||||
{
|
||||
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 } });
|
||||
}
|
||||
// logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
|
||||
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
|
||||
setSelfDeviceName(null);
|
||||
logout();
|
||||
navigate({ to: "/login", search: { dev_user: undefined } });
|
||||
};
|
||||
|
||||
const handleSignOut = () =>
|
||||
@@ -348,7 +324,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
|
||||
const [ loadError, setLoadError ] = useState(false);
|
||||
const [ busyId, setBusyId ] = useState<string | null>(null);
|
||||
const [ stepUpRejected, setStepUpRejected ] = useState(false);
|
||||
|
||||
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
|
||||
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
|
||||
@@ -375,56 +350,15 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
void reload();
|
||||
}, [ onSignedOutSelf, reload ]);
|
||||
|
||||
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
|
||||
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
|
||||
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
|
||||
// 进页面拉一次列表。受限访客无权管理会话、后端 GET 会 403,故跳过、走说明态。
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isGuest) { return; }
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () =>
|
||||
{
|
||||
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
|
||||
const stash = consumeStepUpCode();
|
||||
const pendingId = consumePendingRevoke();
|
||||
const list = await reload();
|
||||
if (cancelled) { return; }
|
||||
|
||||
if (stash && pendingId)
|
||||
{
|
||||
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
|
||||
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
|
||||
const target = list?.find((x) => x.id === pendingId);
|
||||
setBusyId(pendingId);
|
||||
try
|
||||
{
|
||||
await postStepUp(stash.code, stash.verifier);
|
||||
await revokeSession(pendingId);
|
||||
if (cancelled) { return; }
|
||||
if (target) { finishRevoke(target); }
|
||||
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (cancelled) { return; }
|
||||
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
|
||||
else
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
void reload();
|
||||
}
|
||||
finally { if (!cancelled) { setBusyId(null); } }
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [ isGuest, reload, finishRevoke ]);
|
||||
void reload();
|
||||
}, [ isGuest, reload ]);
|
||||
|
||||
// handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
|
||||
// step-up 二次认证已移除(full 档即可信)。
|
||||
const handleRevoke = async (sess: AuthSession) =>
|
||||
{
|
||||
const confirmMsg = sess.current
|
||||
@@ -433,7 +367,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
if (!window.confirm(confirmMsg)) { return; }
|
||||
|
||||
setBusyId(sess.id);
|
||||
setStepUpRejected(false);
|
||||
try
|
||||
{
|
||||
await revokeSession(sess.id);
|
||||
@@ -441,29 +374,9 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
if (e instanceof StepUpRequiredError)
|
||||
{
|
||||
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
|
||||
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
|
||||
stashPendingRevoke(sess.id);
|
||||
try
|
||||
{
|
||||
await startStepUpReauth("/settings");
|
||||
return; // 跳转去 provider,不再 setBusyId。
|
||||
}
|
||||
catch (reauthErr)
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
|
||||
}));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
toast.error(t("settings.error.delete", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
finally { setBusyId(null); }
|
||||
};
|
||||
@@ -485,11 +398,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
<Panel title={t("settings.sessions.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
|
||||
{stepUpRejected && (
|
||||
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
|
||||
{t("settings.sessions.stepUpRejected")}
|
||||
</Callout>
|
||||
)}
|
||||
{loadError ? (
|
||||
<Group>
|
||||
<Text size="sm" style={{ color: "var(--state-error)" }}>
|
||||
@@ -522,14 +430,10 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
|
||||
);
|
||||
}
|
||||
|
||||
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
|
||||
// (macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
|
||||
// 会话种类标签:迁移后每条会话即一台设备,统一复用设备类型标签
|
||||
// (「网页」/「macOS 客户端」等)。
|
||||
function sessionKindLabel(kind: AuthSession["kind"]): string
|
||||
{
|
||||
if (kind === "oidc" || kind === "self" || kind === "guest")
|
||||
{
|
||||
return t(`settings.sessions.kind.${kind}` as const);
|
||||
}
|
||||
return t(`deviceType.${kind}` as const);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Check, Monitor } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { syncWebDeviceName } from "../features/auth/auth";
|
||||
import { ensureDeviceSession, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { persistDesktopDeviceName } from "../net/desktop";
|
||||
import { useAppStore } from "../store";
|
||||
import { t } from "../i18n";
|
||||
@@ -26,9 +26,11 @@ function SetupPage()
|
||||
const navigate = useNavigate();
|
||||
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
|
||||
const [ name, setName ] = useState(suggestDefault());
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
|
||||
const handleSave = () =>
|
||||
const handleSave = async () =>
|
||||
{
|
||||
if (saving) { return; } // 防重复提交:代铸期间再次点击会多铸一条设备会话
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { return; }
|
||||
if (!isAsciiDeviceName(trimmed))
|
||||
@@ -36,10 +38,21 @@ function SetupPage()
|
||||
toast.warn(t("settings.deviceName.asciiOnly"));
|
||||
return;
|
||||
}
|
||||
setSelfDeviceName(trimmed);
|
||||
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
navigate({ to: "/" });
|
||||
setSaving(true);
|
||||
try
|
||||
{
|
||||
setSelfDeviceName(trimmed);
|
||||
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
// 浏览器:名字既定,立即代铸一条托管设备会话(桌面 / 已持令牌者为 no-op),
|
||||
// 使本机以此名进入统一设备列表后再进入应用。
|
||||
await ensureDeviceSession();
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,7 +87,7 @@ function SetupPage()
|
||||
block
|
||||
leftIcon={<Check size={16} />}
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim()}
|
||||
disabled={!name.trim() || saving}
|
||||
>
|
||||
{t("setup.save")}
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,11 @@ export function normalizeTerminal(cur: TransferRecord, finalState: string): Tran
|
||||
// ---- storage keys & readers ----
|
||||
|
||||
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
|
||||
// refresh_token now lives in JS too (sessionStorage): after the Auth Broker migration
|
||||
// a QR-paired device holds the broker's access + refresh pair directly and refreshes
|
||||
// via /api/auth/refresh. A global-SSO browser holds neither (the broker domain cookie
|
||||
// carries it). Desktop keeps its refresh in the Go process, never injected.
|
||||
export const REFRESH_TOKEN_KEY = "cdrop.refresh_token";
|
||||
export const USER_KEY = "cdrop.user";
|
||||
export const SESSION_SCOPE_KEY = "cdrop.session_scope";
|
||||
export const SELF_DEVICE_KEY = "cdrop.self_device";
|
||||
@@ -37,6 +42,10 @@ export const THEME_KEY = "cdrop.theme";
|
||||
interface InjectedSession
|
||||
{
|
||||
access_token: string;
|
||||
// refresh_token 现随注入会话一并下发(iOS 引擎自刷需要它):引擎用它经
|
||||
// /api/auth/refresh 续期,并在 broker 轮换后回报原生更新 Keychain。桌面注入仍不带
|
||||
// (桌面 refresh 在 Go 进程内)。
|
||||
refresh_token?: string;
|
||||
user: { id: string; name: string; avatar?: string };
|
||||
}
|
||||
|
||||
@@ -94,6 +103,17 @@ export function readSessionAccess(): string | null
|
||||
return window.sessionStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// readSessionRefresh restores the broker refresh token across a same-tab reload.
|
||||
// Desktop injection never carries it (refresh lives in the Go process); a global-SSO
|
||||
// browser has none. Null when absent.
|
||||
export function readSessionRefresh(): string | null
|
||||
{
|
||||
const inj = injectedSession();
|
||||
if (inj?.refresh_token) { return inj.refresh_token; }
|
||||
if (typeof window === "undefined") { return null; }
|
||||
return window.sessionStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// user.id / user.name 不是机密(公开显示在 UI、JWT 里可解码),随 access_token
|
||||
// 一并持久化,才能让刷新 / 重启后路由守卫不再误跳 /login。
|
||||
export function readSessionUser(): User | null
|
||||
|
||||
@@ -2,16 +2,18 @@ import type { StateCreator } from "zustand";
|
||||
import type { AppState, AuthMode, SessionScope } from "../types";
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
REFRESH_TOKEN_KEY,
|
||||
SESSION_SCOPE_KEY,
|
||||
USER_KEY,
|
||||
readSessionAccess,
|
||||
readSessionRefresh,
|
||||
readSessionScope,
|
||||
readSessionUser,
|
||||
} from "../helpers";
|
||||
|
||||
export type AuthSlice = Pick<
|
||||
AppState,
|
||||
"authMode" | "accessToken" | "user" | "sessionScope"
|
||||
"authMode" | "accessToken" | "refreshToken" | "user" | "sessionScope"
|
||||
| "setAuth" | "setSessionScope" | "clearAuth"
|
||||
>;
|
||||
|
||||
@@ -25,20 +27,33 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
|
||||
({
|
||||
authMode: initialAuthMode,
|
||||
accessToken: readSessionAccess(),
|
||||
refreshToken: readSessionRefresh(),
|
||||
user: readSessionUser(),
|
||||
sessionScope: readSessionScope(),
|
||||
|
||||
// setAuth records the session. accessToken may be null (global-SSO browser — the
|
||||
// broker cookie carries identity). refreshToken is optional: omit it to leave a
|
||||
// previously stored one untouched (e.g. a /api/me bootstrap that only confirms the
|
||||
// user); pass null to clear it, a string to replace it.
|
||||
setAuth: (a) =>
|
||||
{
|
||||
if (typeof window !== "undefined")
|
||||
{
|
||||
window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken);
|
||||
if (a.accessToken) { window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken); }
|
||||
else { window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); }
|
||||
if (a.refreshToken !== undefined)
|
||||
{
|
||||
if (a.refreshToken) { window.sessionStorage.setItem(REFRESH_TOKEN_KEY, a.refreshToken); }
|
||||
else { window.sessionStorage.removeItem(REFRESH_TOKEN_KEY); }
|
||||
}
|
||||
window.sessionStorage.setItem(USER_KEY, JSON.stringify(a.user));
|
||||
}
|
||||
set({
|
||||
set((s) =>
|
||||
({
|
||||
accessToken: a.accessToken,
|
||||
refreshToken: a.refreshToken !== undefined ? a.refreshToken : s.refreshToken,
|
||||
user: a.user,
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
// setSessionScope 由 /api/me 解析结果驱动(登录成功 / 开机水合后调用)。
|
||||
@@ -58,10 +73,11 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
|
||||
if (typeof window !== "undefined")
|
||||
{
|
||||
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
window.sessionStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
window.sessionStorage.removeItem(USER_KEY);
|
||||
window.sessionStorage.removeItem(SESSION_SCOPE_KEY);
|
||||
}
|
||||
// scope 回 "full":登出后下一个登录者默认完整态,再由其自己的 /api/me 校正。
|
||||
set({ accessToken: null, user: null, sessionScope: "full" });
|
||||
set({ accessToken: null, refreshToken: null, user: null, sessionScope: "full" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -123,13 +123,17 @@ export interface AppState
|
||||
{
|
||||
// ---- auth slice ----
|
||||
authMode: AuthMode;
|
||||
// accessToken is null for a global-SSO browser (the broker domain cookie carries
|
||||
// identity; requests go without a Bearer). A QR-paired device holds the broker
|
||||
// access token here and the refresh token alongside.
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
// 本会话权限级别(/api/me 的 scope)。默认 "full";登录成功 / 开机水合后据
|
||||
// /api/me 校正为 "guest" 时,UI 隐藏「扫码登录新设备」「移除设备」「登出他人」
|
||||
// 等账号管理入口,并展示「受限访客」标识。
|
||||
sessionScope: SessionScope;
|
||||
setAuth: (a: { accessToken: string; user: User }) => void;
|
||||
setAuth: (a: { accessToken: string | null; refreshToken?: string | null; user: User }) => void;
|
||||
setSessionScope: (scope: SessionScope) => void;
|
||||
clearAuth: () => void;
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@ export default defineConfig({
|
||||
),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// 多入口:main = 完整 React 应用(index.html);engine = iOS 无头传输引擎
|
||||
// (engine.html,不含 React / UI,见 src/engine/main.ts、ios/PLAN.md arch A)。
|
||||
// 两个 bundle 互不污染——engine 不把 React / 路由拖进 main,main 也不含引擎入口。
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
engine: fileURLToPath(new URL("./engine.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
|
||||
Reference in New Issue
Block a user