设备名与令牌解耦 + 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:
+100
-14
@@ -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.url(cdrop-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 等到下一次变更才填。
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "在线事件",
|
||||
|
||||
@@ -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": "上線事件",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 KiB(512 KiB×4/3),
|
||||
// 远小于会卡顿的体量。p2p 一次读 4 MiB、relay 一次 1 MiB,bridgeFileSource 内部按此循环拼齐。
|
||||
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);
|
||||
}
|
||||
|
||||
// bridgeFileSource:iOS 无头引擎的发送字节源(取代 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
@@ -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
@@ -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
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface User
|
||||
|
||||
export interface DeviceInfo
|
||||
{
|
||||
/** 稳定不透明设备 id(broker meta)。设备名与令牌解耦后,它是去重 / 判本机 / 跨端
|
||||
吊销的真正标识——改名只换 name,deviceId 不变。后端 presence 总会带;旧缓存可能缺。 */
|
||||
deviceId?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
online: boolean;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button, Group, Modal, Text } from "@mantine/core";
|
||||
import { t } from "../i18n";
|
||||
import { useConfirmStore } from "../utils/confirm";
|
||||
|
||||
// 全局确认框宿主:挂一次于 React 树(main.tsx,MantineProvider 内)。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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user