设备名与令牌解耦 + iOS Broker 应用内登录 / 消息 / 图库 / 记录持久化 + 三端 bug 修复

设备名与令牌解耦(presence 按 device_id 键控):
- hub 主键改稳定 device_id(presence / online / kick 用之),peer 路由仍按设备名经 hub 内 clientFor 解析——不动传输核心;Client 加 Name + 连接键,PresenceDevice 加 device_id,sse 传 claims.DeviceID,publishPresence 按 device_id 判在线、name 取自 devices 表
- 改名走已有 PATCH /api/devices/{device_id}(不重铸会话、不换 token、不中断 SSE)+ hub.Rename 更新活跃连接名 + 重广播 presence,全端即时无重复项;sessions 列表 name 用 devices 表覆盖 broker Label;web 改名从重铸迁到 PATCH,iOS 设置页即时改名;DeviceItem / DeviceInfo 按 device_id 键控判本机
- 移除「需移除两次」:publishPresence 不再把「有 device_id 但无 devices 行」的 live 连接当在线(吊销后用未过期 token 重连的僵尸),只显示 code-less 连接;合法重登新建行故正常
- revoke 对无 broker 会话的本地行也清(清掉合成测试 / 幽灵设备);跨端吊销 / 断连一律按 device_id Kick

iOS Broker 应用内登录(免扫码):
- BrokerLogin.swift:ASWebAuthenticationSession 跑 broker /device/authorize + /device/token 的 PKCE 设备授权流(对齐桌面 loopback,自定义 scheme cdrop://auth-callback 回调)→ /api/auth/device-session 代铸出带真名 + 稳定 device_id 的设备会话;DeviceIDStore 跨登录复用 device_id 杜绝重登重复
- LoginView 主登录改 broker 账号登录、扫码降级为可展开备选

iOS 功能补全:
- 设备间消息(引擎 main.ts 单条增量过桥 + onNativeEvent sendMessage;EngineController 消息状态 + 新 MessagesView 会话 UI;后端 /api/message 已就绪)
- 图库发送(PhotosPicker 文件 / 图库菜单 → stagePhotoData → 复用 cdrop-file 发送链路;NSPhotoLibraryUsageDescription)
- 传输历史 / 消息记录持久化(RecordsStore 落 Application Support JSON,跨重启存活)+ 长按删除 + 清空(二次确认)
- 进行中传输主动取消 / 立即切中继(详情页两按钮,复用引擎桥命令)
- 身份愈合(引擎 /api/me 取真实显示名经 identityUpdated 回报 Keychain,修扫码登录「用户显示 UUID」)
- 触发本地网络权限(NWBrowser 浏览 _cdrop._tcp)使 WKWebView WebRTC 取 host 候选

iOS 发送 / 启动修复:
- 发送在读文件即失败:引擎在 https 源下 fetch("cdrop-file://") 跨源 + Range 头触发 CORS 预检被拦 → 改走原生桥 bridgeFileSource / readFileSlice(512 KiB 分块 base64),绕开 CORS、整文件仍不整体进内存
- 冷启重开「missing injected session」:EngineWebView.makeUIView 在 boot 注入前确定性接好 auth,消除注入竞态

桌面 bug 修复:
- 登出静默失败:window.confirm 在 Wails WebView 失效(返回 falsy 致动作被静默跳过)→ 跨平台 DOM 确认弹窗(utils/confirm 的 Promise 式 confirmDialog + ui/ConfirmHost 的 Mantine Modal)取代之;登出改 await 先吊销 broker 会话再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh 的竞态)
- 扫 iOS 登录二维码被拒「不是本站的码」:wails:// 源永不等 https 站点源 → 桌面放宽 parseLinkApprovalUrl 的 origin 等值校验(仍留 /link 路径 + r/c;批准 r/c 一律发往本端后端、外站码只 404,无开放重定向)
- macOS 本地网络权限触发(localnetwork_darwin NWBrowser + Info.plist 键);相机修复(NSCameraUsageDescription)

web / 跨端:
- presence 透传 device_id + 离线检测按 device_id;会话列表随 presence 防抖重拉(已登出设备自动从列表消失,不再需手动刷新);移除设备 / 清剪贴板 / 撤销令牌 / 登出均改 DOM 确认弹窗

分发 / 签名:
- 真机签名从手动 scaffold 改自动 provisioning(删 Signing.xcconfig,project.yml 去 profile specifier,just ios-device 用 -allowProvisioningUpdates + ASC 团队 key);App Group 改名 group.net.commilitia.Commilitia-Drop(含 entitlements)
- hub 测试加 device_id 键控 + 改名解耦回归(-race)
This commit is contained in:
2026-06-27 03:50:04 +08:00
parent 44096069a7
commit 127070e226
51 changed files with 1655 additions and 227 deletions
+100 -14
View File
@@ -21,12 +21,12 @@
import { refreshSessionScope } from "../features/auth/auth";
import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard";
import { sendMessage } from "../features/messaging/messaging";
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 { rangeSource } from "../features/transfer/source";
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { useAppStore } from "../store";
import type { TransferRecord } from "../store/types";
@@ -101,6 +101,7 @@ function subscribeStore(): void
let prevActive = st0.activeTransfers;
let prevDoneId = st0.history[0]?.sessionId;
let prevDevices = st0.devices;
let prevMsgId = st0.messages[0]?.id;
let prevSse = st0.sseConnected;
let prevAuthed = st0.accessToken !== null;
let prevRefresh = st0.refreshToken;
@@ -139,6 +140,16 @@ function subscribeStore(): void
notifyNative("presence", { devices: s.devices });
pushHubState(s);
}
// 设备间文本消息:messages 最新在前(addMessage 前插),每次新增就把新的头条作为「增量」
// 推原生(而非整列)。原生持久化记录、按 id 去重前插——单条增量避免引擎重启后空列表整组
// 覆盖掉原生已持久化的历史消息(重启清空问题的根因)。messages 由 hub.ts handleIncomingMessage
// (入)与 sendMessage(出)维护。
const headMsg = s.messages[0];
if (headMsg && headMsg.id !== prevMsgId)
{
prevMsgId = headMsg.id;
notifyNative("message", { message: headMsg });
}
if (s.sseConnected !== prevSse)
{
prevSse = s.sseConnected;
@@ -167,14 +178,15 @@ function subscribeStore(): void
});
}
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露。R-iOS-4:用
// rangeSource 惰性按块拉取(原生据 Range 头 seek 文件、回 206),整文件永不进 WebView
// 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。
// handleSendFile:原生把待发文件经 payload.urlcdrop-file://<id>暴露。R-iOS-4:用
// bridgeFileSource 经桥按块取片(原生据 [start,end) seek 文件、回 base64),整文件永不整体
// WebView 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。改走桥而非跨 origin
// fetch,绕开自定义 scheme 的 CORS 拦截(否则发送在读文件即失败)。
async function handleSendFile(p: SendFilePayload): Promise<void>
{
try
{
const src = rangeSource(p.url, p.name, p.size, p.type ?? "");
const src = bridgeFileSource(p.url, p.name, p.size, p.type ?? "");
const sessionId = await startOutgoingTransfer(p.target, src);
notifyNative("sendStarted", { sessionId, name: p.name });
}
@@ -237,13 +249,33 @@ function bindCommands(): void
})
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
});
// 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求
// step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。
// 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{device_id})。按稳定 device_id(路由
// 参数即 device_id),原生从 presence 的 device_id 传来——之前误传设备名致后端按 device_id
// 找不到而「移除失败」。需完整会话;403(step-up)回错给原生提示「请在网页端完成」。
onNativeEvent("revokeDevice", (payload) =>
{
const name = (payload as { name?: string }).name ?? "";
if (!name) { return; }
void revokeDevice(name);
const p = payload as { device_id?: string; name?: string };
const id = p.device_id ?? "";
if (!id) { return; }
void revokeDevice(id);
});
// 设备间文本消息(发送):原生把 { to=对端设备名, text } 送来,经 POST /api/message 转发(hub
// SendTo 按名解析到对端 live 连接)。发送成功后 store.addMessage 会触发上面的 message 订阅回推。
onNativeEvent("sendMessage", (payload) =>
{
const p = payload as { to?: string; text?: string };
if (!p.to || !p.text) { return; }
void sendMessage(p.to, p.text)
.catch((e) => notifyNative("error", { stage: "message", message: e instanceof Error ? e.message : String(e) }));
});
// 本机即时改名(设备名与令牌解耦):原生送来 { device_id=本机, name },经 PATCH /api/devices/
// {device_id} 只改名字、不换 token、不重连——后端按 device_id 改名 + 重广播 presence。成功后
// 更新引擎 selfDeviceName(后续 X-Device-Name 用新名),并回报原生更新本地名 / Keychain。
onNativeEvent("renameSelf", (payload) =>
{
const p = payload as { device_id?: string; name?: string };
if (!p.device_id || !p.name) { return; }
void renameSelf(p.device_id, p.name);
});
// APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。
onNativeEvent("registerPush", (payload) =>
@@ -311,11 +343,11 @@ async function provisionWidgetSession(deviceId: string, deviceName: string): Pro
}
}
async function revokeDevice(name: string): Promise<void>
async function revokeDevice(deviceID: string): Promise<void>
{
try
{
const r = await apiFetch(`/api/devices/${encodeURIComponent(name)}`, { method: "DELETE" });
const r = await apiFetch(`/api/devices/${encodeURIComponent(deviceID)}`, { method: "DELETE" });
if (r.status === 403)
{
notifyNative("error", { stage: "revoke", message: "step_up_required" });
@@ -326,7 +358,7 @@ async function revokeDevice(name: string): Promise<void>
notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` });
return;
}
notifyNative("deviceRevoked", { name });
notifyNative("deviceRevoked", { device_id: deviceID });
}
catch (e)
{
@@ -334,6 +366,59 @@ async function revokeDevice(name: string): Promise<void>
}
}
// renameSelf renames THIS device by its stable device_id, decoupled from the session token:
// PATCH /api/devices/{device_id} only changes the name (no re-mint), then the engine adopts the
// new name as X-Device-Name and reports it back so the native shell persists it.
async function renameSelf(deviceID: string, name: string): Promise<void>
{
try
{
const r = await apiFetch(`/api/devices/${encodeURIComponent(deviceID)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!r.ok)
{
notifyNative("error", { stage: "rename", message: `HTTP ${r.status}` });
return;
}
useAppStore.getState().setSelfDeviceName(name);
notifyNative("renamed", { name });
}
catch (e)
{
notifyNative("error", { stage: "rename", message: e instanceof Error ? e.message : String(e) });
}
}
// healIOSIdentity corrects the display identity after a QR login. collectQRSession returns the
// subject UUID as the name (cdrop holds no accounts table; qr.go), so the injected session shows
// "你好, <uuid>". Once the token is live at the edge, /api/me carries the real X-Auth-Name: read
// it, update the store, and push it to the native shell (which persists it to the Keychain so the
// real name survives a relaunch). Best-effort; a failure just leaves the prior name.
async function healIOSIdentity(): Promise<void>
{
try
{
const r = await apiFetch("/api/me");
if (!r.ok) { return; }
const me = (await r.json()) as { user_id?: string; name?: string; avatar?: string };
if (!me?.user_id) { return; }
const realName = me.name && me.name !== me.user_id ? me.name : "";
if (!realName) { return; }
const cur = useAppStore.getState();
if (!cur.user) { return; }
if (cur.user.name === realName && cur.user.avatar === (me.avatar || cur.user.avatar)) { return; }
useAppStore.getState().setAuth({
accessToken: cur.accessToken,
user: { id: cur.user.id, name: realName, avatar: me.avatar || cur.user.avatar },
});
notifyNative("identityUpdated", { user_id: me.user_id, name: realName, avatar: me.avatar ?? "" });
}
catch { /* best-effort identity heal */ }
}
// installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败
// 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。
function installLogBridge(): void
@@ -384,6 +469,7 @@ function boot(): void
void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令
void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用
void refreshSessionScope(); // 校正 /api/me 的权限级别
void healIOSIdentity(); // 用 /api/me 的真名替换 QR 登录注入的 UUID 名(#12
// 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳
// 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。
+52 -5
View File
@@ -49,18 +49,22 @@ export function loginRedirect(): void
// ---- logout ---------------------------------------------------------------
export function logout()
export async function logout(): Promise<void>
{
// 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 */ });
// 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.
// Revoke this device's own broker session server-side BEFORE clearing local credentials,
// so it can't be refreshed back. This must be AWAITED, not fire-and-forget: on desktop the
// refresh token lives in the Go process and clearDesktopSession() drops it — if we cleared
// first, an expired-access 401 here could no longer refresh-and-retry and the broker session
// would leak (the "桌面登出静默失败" half that isn't the missing confirm dialog). apiFetch
// transparently refreshes the access token for this call. Global-SSO browser → server no-op.
if (useAppStore.getState().authMode === "prod")
{
void apiFetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
try { await apiFetch("/api/auth/logout", { method: "POST" }); }
catch { /* best-effort; clear local state regardless */ }
}
useAppStore.getState().clearAuth();
// Desktop: drop the Go-side persisted session, else the next launch re-injects it.
@@ -277,6 +281,49 @@ export async function ensureDeviceSession(): Promise<void>
}
}
// renameDevice changes this device's display name WITHOUT touching its session token — the
// decoupled rename. It PATCHes /api/devices/{device_id}; the server updates the name keyed by
// the stable device_id and re-broadcasts presence, so every peer sees the new name at once with
// no token rotation, no SSE interruption, and no duplicate device row. Falls back to a 代铸
// re-mint only when this browser has no managed device session yet (cookie-only, pre-代铸) or the
// row is unexpectedly gone (404). Caller has already updated selfDeviceName (drives X-Device-Name).
export async function renameDevice(): Promise<void>
{
const st = useAppStore.getState();
if (st.authMode !== "prod") { return; }
const name = st.selfDeviceName;
if (!name) { return; }
const storedId = (typeof window !== "undefined")
? (window.localStorage.getItem(DEVICE_ID_KEY) ?? "")
: "";
// No managed device session yet (no token / no device_id): adopt the name at 代铸 instead.
if (!storedId || (!st.accessToken && !isDesktop()))
{
await renameDeviceSession();
return;
}
let r: Response;
try
{
r = await apiFetch(`/api/devices/${encodeURIComponent(storedId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
}
catch
{
// Transient network error: the local name is already set and rides X-Device-Name; a
// later presence refresh / reconnect reconciles. Don't fall back to a token-rotating
// re-mint on a transient error.
return;
}
// 404 = the device row is gone (e.g. revoked elsewhere): re-mint to re-register under the name.
if (r.status === 404) { await renameDeviceSession(); }
}
// 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
@@ -8,6 +8,7 @@ import {
Trash2,
} from "lucide-react";
import { Button, DynText, IconButton, Panel, Tooltip } from "../../ui/primitives";
import { confirmDialog } from "../../utils/confirm";
import { toast } from "../../ui/feedback";
import type { ClipboardState } from "../../store";
import { t } from "../../i18n";
@@ -110,7 +111,7 @@ export function ClipboardPanel(props: ClipboardPanelProps)
const handleClear = async () =>
{
if (!hasContent) { return; }
if (!window.confirm(t("home.clipboard.clearConfirm"))) { return; }
if (!await confirmDialog(t("home.clipboard.clearConfirm"))) { return; }
setBusy(true);
try
{
+2 -1
View File
@@ -3,6 +3,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { Copy, KeyRound, Plus } from "lucide-react";
import { apiJSON } from "../../net/api";
import { t } from "../../i18n";
import { confirmDialog } from "../../utils/confirm";
import { formatRelative } from "../../utils/format";
import { Badge, Button, Callout, DynText, Panel, TextField } from "../../ui/primitives";
import { toast } from "../../ui/feedback";
@@ -113,7 +114,7 @@ export function ShortcutTokens()
const handleRevoke = async (token: ShortcutTokenView) =>
{
if (!window.confirm(t("settings.shortcut.revokeConfirm"))) { return; }
if (!await confirmDialog(t("settings.shortcut.revokeConfirm"))) { return; }
setRevoking((prev) => new Set(prev).add(token.jti));
try
+6 -2
View File
@@ -194,6 +194,7 @@ function handlePresence(data: unknown): void
const r = raw as Record<string, unknown>;
if (typeof r.name !== "string" || typeof r.type !== "string") { continue; }
devices.push({
deviceId: typeof r.device_id === "string" && r.device_id ? r.device_id : undefined,
name: r.name,
type: r.type,
online: Boolean(r.online),
@@ -202,7 +203,10 @@ function handlePresence(data: unknown): void
}
const store = useAppStore.getState();
const prevOnline = new Map(store.devices.map((d) => [d.name, d.online] as const));
// Track online state by the stable device_id (fall back to name for a code-less device),
// so a rename — which changes only the name — is never misread as offline-then-online.
const idOf = (d: DeviceInfo): string => d.deviceId ?? d.name;
const prevOnline = new Map(store.devices.map((d) => [idOf(d), d.online] as const));
store.setDevices(devices);
// Detect peers that just transitioned to offline; if any active transfer
@@ -210,7 +214,7 @@ function handlePresence(data: unknown): void
// by transfer:state FAILED) will remove the entry, so we don't double-fire.
for (const d of devices)
{
if (!d.online && prevOnline.get(d.name))
if (!d.online && prevOnline.get(idOf(d)))
{
for (const t of Object.values(store.activeTransfers))
{
+15 -1
View File
@@ -355,6 +355,9 @@ export const enUS: Partial<TranslationDict> = {
"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.broker": "Sign In",
"ios.login.brokerStarting": "Opening sign-in…",
"ios.login.scanAlt": "Sign in by scanning from another device",
"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",
@@ -397,7 +400,18 @@ export const enUS: Partial<TranslationDict> = {
"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.deviceNameNote": "Renaming takes effect at once and syncs to all devices, without affecting sign-in.",
"ios.settings.deviceName.success": "Device name updated",
"ios.tab.messages": "Messages",
"ios.send.fromFiles": "From Files",
"ios.send.fromPhotos": "From Photos",
"ios.messages.empty": "No messages yet",
"ios.messages.noPeers": "No device to message",
"ios.messages.placeholder": "Type a message…",
"ios.records.clear": "Clear",
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
"ios.transfer.cancel": "Cancel transfer",
"ios.transfer.forceRelay": "Relay now",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
+15 -1
View File
@@ -351,6 +351,9 @@ export const zhCN = {
"ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试",
"ios.login.broker": "登录",
"ios.login.brokerStarting": "正在打开登录…",
"ios.login.scanAlt": "用其他设备扫码登录",
"ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备",
@@ -393,7 +396,18 @@ export const zhCN = {
"ios.settings.user": "用户",
"ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称",
"ios.settings.deviceNameNote": "改名将在下次登录后生效",
"ios.settings.deviceNameNote": "改名即时生效并同步到所有设备,不影响登录。",
"ios.settings.deviceName.success": "设备名已更新",
"ios.tab.messages": "消息",
"ios.send.fromFiles": "从文件",
"ios.send.fromPhotos": "从图库",
"ios.messages.empty": "暂无消息",
"ios.messages.noPeers": "没有可发送消息的设备",
"ios.messages.placeholder": "输入消息…",
"ios.records.clear": "清空",
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
"ios.transfer.cancel": "取消传输",
"ios.transfer.forceRelay": "立即切到中继",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
+15 -1
View File
@@ -355,6 +355,9 @@ export const zhTW: Partial<TranslationDict> = {
"ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試",
"ios.login.broker": "登入",
"ios.login.brokerStarting": "正在開啟登入…",
"ios.login.scanAlt": "用其他裝置掃碼登入",
"ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置",
@@ -397,7 +400,18 @@ export const zhTW: Partial<TranslationDict> = {
"ios.settings.user": "使用者",
"ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱",
"ios.settings.deviceNameNote": "改名將在下次登入後生效",
"ios.settings.deviceNameNote": "改名即時生效並同步到所有裝置,不影響登入。",
"ios.settings.deviceName.success": "裝置名稱已更新",
"ios.tab.messages": "訊息",
"ios.send.fromFiles": "從檔案",
"ios.send.fromPhotos": "從相簿",
"ios.messages.empty": "尚無訊息",
"ios.messages.noPeers": "沒有可傳送訊息的裝置",
"ios.messages.placeholder": "輸入訊息…",
"ios.records.clear": "清空",
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
"ios.transfer.cancel": "取消傳輸",
"ios.transfer.forceRelay": "立即切到中繼",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
+2
View File
@@ -10,6 +10,7 @@ import {
type MantineColorsTuple,
} from "@mantine/core";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ConfirmHost } from "./ui/ConfirmHost";
import { ToastViewport } from "./ui/feedback";
import { routeTree } from "./routeTree.gen";
import { useAppStore, type ThemeMode } from "./store";
@@ -107,6 +108,7 @@ function App()
forceColorScheme={force}
>
<ToastViewport />
<ConfirmHost />
<RouterProvider key={locale} router={router} />
</MantineProvider>
);
+56
View File
@@ -19,6 +19,8 @@
// - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload /
// abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。
import type { FileSource } from "../features/transfer/source";
// WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。
interface WebKitMessageHandler
{
@@ -146,6 +148,15 @@ function bytesToBase64(bytes: Uint8Array): string
return btoa(binary);
}
// base64ToBytes 把原生回传的 base64(发送侧按 Range 读出的文件片)解回字节。
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer>
{
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) { out[i] = binary.charCodeAt(i); }
return out;
}
// saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载
// 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。
export async function saveIncomingFileIOS(name: string, blob: Blob): Promise<string>
@@ -177,3 +188,48 @@ export async function abortIncomingDownloadIOS(sessionId: string): Promise<void>
try { await callNative<void>("abortDownload", { sessionId }); }
catch { /* 取消 / 失败路径,吞掉 */ }
}
// ── 发送侧文件源(经桥按 Range 取片) ────────────────────────────────────────
// 每次桥取片的上限:把单次 evaluateJavaScript 回传的 base64 串控制在 ~683 KiB512 KiB×4/3),
// 远小于会卡顿的体量。p2p 一次读 4 MiB、relay 一次 1 MiBbridgeFileSource 内部按此循环拼齐。
const BRIDGE_SLICE_MAX = 512 * 1024;
// readFileSliceIOS:经桥让原生按 [start, end) seek 读暂存的待发文件、回 base64,再解回字节。
// 取代原先「fetch(cdrop-file://) + Range」——引擎在 https origin 下跨 origin fetch 自定义 scheme
// 被 CORS 拦死(Range 头触发预检、scheme handler 无 CORS 头)→ 发送在读文件即失败。改走桥与
// 接收落盘同形,绕开 CORS;整文件仍永不整体进 WebView 内存(按需取片)。
async function readFileSliceIOS(url: string, start: number, end: number): Promise<Uint8Array<ArrayBuffer>>
{
const b64 = await callNative<string>("readFileSlice", { url, start, end });
return base64ToBytes(b64);
}
// bridgeFileSourceiOS 无头引擎的发送字节源(取代 rangeSource 的跨 origin fetch)。slice 把请求
// 区间切成 ≤BRIDGE_SLICE_MAX 的子片逐次过桥取回再拼齐,返回严格等于 [start, end) 的副本,故
// p2p / relay 的块读语义完全不变。
export function bridgeFileSource(url: string, name: string, size: number, type: string): FileSource
{
return {
name,
size,
type: type || "application/octet-stream",
async slice(start, end)
{
const total = end - start;
if (total <= 0) { return new Uint8Array(0); }
const out = new Uint8Array(total);
let pos = 0;
while (pos < total)
{
const s = start + pos;
const e = Math.min(s + BRIDGE_SLICE_MAX, end);
const bytes = await readFileSliceIOS(url, s, e);
if (bytes.byteLength === 0) { break; } // 防御:原生异常返回空,避免死循环
out.set(bytes.subarray(0, Math.min(bytes.byteLength, total - pos)), pos);
pos += bytes.byteLength;
}
return out;
},
};
}
+8 -3
View File
@@ -1,4 +1,5 @@
import { apiFetch, apiJSON } from "./api";
import { isDesktop } from "./desktop";
// 扫码登录的网络封装。两条信任边界:
// - 显码页(新设备)在 start 时还未登录,故 start / status 是公开端点,
@@ -264,19 +265,23 @@ export interface LinkParams
}
// parseLinkApprovalUrl 把扫到的二维码内容解析成批准页参数,严格校验后才放行——
// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。
// 只接受「本站 origin + /link 路径 + 同时带 r 与 c」的 URL:
// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。校验:
// - 同源(origin 严格等于当前页 origin)——挡开放重定向 / 钓鱼站二维码;
// - 路径恰为 /link——挡指向本站其他页的码;
// - r/c 皆非空——挡残缺码。
// 任一不满足返回 null(调用方据此提示「继续对准」,不导航)。
//
// 桌面例外:Wails 壳跑在 wails:// 自定义 scheme origin,而二维码恒带 https 站点 origin,故同源
// 校验在桌面永不可能命中 → 桌面扫任何码都被误判「不是本站的码」。桌面放宽 origin 等值校验(仍留
// /link 路径 + r/c 校验)是安全的:批准用的 r/c 一律发往本端自己的后端(与码的 origin 无关,外站
// 码只会 404),且导航目标是 SPA 自己的 /link 路由而非码里的外部 URL,故无开放重定向风险。
export function parseLinkApprovalUrl(raw: string): LinkParams | null
{
let url: URL;
try { url = new URL(raw); }
catch { return null; }
if (url.origin !== window.location.origin) { return null; }
if (!isDesktop() && url.origin !== window.location.origin) { return null; }
if (url.pathname !== "/link") { return null; }
const requestId = url.searchParams.get("r");
+23 -12
View File
@@ -9,7 +9,8 @@ import {
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth";
import { logout, renameDevice, syncWebDeviceName } from "../features/auth/auth";
import { confirmDialog } from "../utils/confirm";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import {
@@ -73,14 +74,13 @@ function SettingsPage()
setRenaming(true);
try
{
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名
// broker label 于下次登录代铸时同步。
// 改名与会话令牌解耦:先落本地名(root effect 会以新名作 X-Device-Name),再 PATCH
// /api/devices/{device_id} 只改名字——服务端按稳定 device_id 改名 + 重广播 presence
// 不换 token、不中断 SSE、不产生重复设备行;全端即时看到新名。桌面同步持久化本地名
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
await renameDeviceSession();
await renameDevice();
toast.ok(t("settings.deviceName.success"));
}
catch (e)
@@ -95,20 +95,21 @@ function SettingsPage()
}
};
const handleUnregisterSelf = () =>
const handleUnregisterSelf = async () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
if (!await confirmDialog(t("settings.unregister.currentConfirm"))) { return; }
// logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
// await:先完成 broker 吊销再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh)。
setSelfDeviceName(null);
logout();
await logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
const handleSignOut = () =>
const handleSignOut = async () =>
{
logout();
await logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
@@ -357,6 +358,16 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
void reload();
}, [ isGuest, reload ]);
// 会话列表随 presence 自动刷新(N):设备上线 / 下线 / 被吊销(含别处登出)都改 store.devices
// 这里据此防抖重拉 /api/auth/sessions,使已登出设备及时从列表消失,不再需要手动刷新页面。
const devices = useAppStore((s) => s.devices);
useEffect(() =>
{
if (isGuest) { return; }
const timer = window.setTimeout(() => { void reload(); }, 800);
return () => window.clearTimeout(timer);
}, [ devices, isGuest, reload ]);
// handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
// step-up 二次认证已移除(full 档即可信)。
const handleRevoke = async (sess: AuthSession) =>
@@ -364,7 +375,7 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
const confirmMsg = sess.current
? t("settings.sessions.confirmCurrent")
: t("settings.sessions.confirmOther", { name: sess.deviceName });
if (!window.confirm(confirmMsg)) { return; }
if (!await confirmDialog(confirmMsg)) { return; }
setBusyId(sess.id);
try
+3
View File
@@ -16,6 +16,9 @@ export interface User
export interface DeviceInfo
{
/** 稳定不透明设备 id(broker meta)。设备名与令牌解耦后,它是去重 / 判本机 / 跨端
吊销的真正标识——改名只换 name,deviceId 不变。后端 presence 总会带;旧缓存可能缺。 */
deviceId?: string;
name: string;
type: string;
online: boolean;
+31
View File
@@ -0,0 +1,31 @@
import { Button, Group, Modal, Text } from "@mantine/core";
import { t } from "../i18n";
import { useConfirmStore } from "../utils/confirm";
// 全局确认框宿主:挂一次于 React 树(main.tsxMantineProvider 内)。confirmDialog() 触发时弹出,
// 用户点确认 / 取消结清对应 Promise。DOM 渲染,浏览器 + Wails 桌面均可用(取代失效的 window.confirm)。
export function ConfirmHost()
{
const pending = useConfirmStore((s) => s.pending);
const settle = useConfirmStore((s) => s.settle);
return (
<Modal
opened={pending !== null}
onClose={() => settle(false)}
title={t("common.confirm")}
centered
withCloseButton={false}
>
<Text size="sm">{pending?.message}</Text>
<Group justify="flex-end" mt="lg">
<Button variant="default" onClick={() => settle(false)}>
{t("common.cancel")}
</Button>
<Button color="red" onClick={() => settle(true)}>
{t("common.confirm")}
</Button>
</Group>
</Modal>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { create } from "zustand";
// 应用内确认框(取代在 Wails 桌面 WebView 失效的 window.confirm)。confirmDialog 弹一个 DOM
// 渲染的 Mantine Modal(浏览器 + 桌面都可用),返回用户是否确认。须在 React 树里挂一次
// <ConfirmHost/>(见 main.tsx)。破坏性操作(移除设备 / 全部清除 / 撤销令牌等)用它做二次确认。
interface PendingConfirm
{
message: string;
resolve: (ok: boolean) => void;
}
interface ConfirmStore
{
pending: PendingConfirm | null;
open: (message: string) => Promise<boolean>;
settle: (ok: boolean) => void;
}
export const useConfirmStore = create<ConfirmStore>((set, get) =>
({
pending: null,
open: (message) => new Promise<boolean>((resolve) =>
{
// 同一时刻只挂一个确认框;若已有未决的,先把它按「取消」结清,避免 resolve 泄漏。
const prev = get().pending;
if (prev) { prev.resolve(false); }
set({ pending: { message, resolve } });
}),
settle: (ok) =>
{
const p = get().pending;
if (!p) { return; }
set({ pending: null });
p.resolve(ok);
},
}));
export function confirmDialog(message: string): Promise<boolean>
{
return useConfirmStore.getState().open(message);
}