iOS 第四批反馈:隔夜重登修复 + 通知/红点/日志/扫码登他端/主屏小组件 + 长条行交互统一
- #7 隔夜被迫重新登录:auth.ts 加 refreshInFlight 单飞刷新。根因=broker 单次轮换 refresh token + 冷启并发 401(引擎 boot 同时 startHub/refreshSessionScope/healIOSIdentity/refreshICEServers,隔夜 access 已过期全部 401)各自用同一 refresh 刷新,首个轮换、其余 replay 失效旧 token → 401 invalid → forceLogout → 清 Keychain → 重登。单飞使并发 401 共享一次轮换。 - #2 通知系统重做:删 EngineController.deviceActionStatus/clipboardStatus 两字段及设备页/设置页内联灰字(串页、不清除);改瞬时横幅 NotificationBanner(NoticeOverlay 顶部覆盖,成功绿/失败红/信息中性、4s 自动消失、可点叉清除),设备移除/改名/剪贴板结果统一走 notify。 - #5 未读红点:EngineController unreadMessages/unreadTransfers/activeTab/seenIncomingTransfers + setActiveTab(进入对应 tab 清零)+ noteIncomingTransfer;RootView Tab .badge + onChange(selection) 同步当前页。 - #1 标签栏重排:传输 / 文件 / 消息 / 设备 / 设置(文件域相邻、设备归管理区近设置)。 - #3 日志独立界面:EngineController logs 环形缓冲(cap 300)+ clearLogs;新增 LogView(最近 N 条、分级配色、可选中、可清空),设置页引擎段「查看日志」NavigationLink。 - #8 扫码登录他端:iOS 此前仅能展示自身码被扫,缺扫描方批准能力。新增 ScannerView(VisionKit DataScannerViewController + 相机权限 + /link?r=&c= 校验);engine/main.ts 导入 qr.ts 加 fetchLoginRequest/approveLogin/denyLogin(qrRequest/qrApprove(full,persist)/qrDeny)+ 回报 loginRequest/loginApproved/loginDenied/loginFailed;EngineController pendingLoginRequest + 方法 + handleNotify;设备页工具栏「扫码登录设备」入口 + 批准确认(展示对端名/类型/IP)。 - #6 主屏小组件 + 应用外反馈:除控制中心两控件外,新增 CloudClipboardWidget(交互式主屏/锁屏小组件,上传/拉取按钮复用同一 AppIntent);新增 WidgetFeedback(App 外触发弹本地通知反馈);ClipboardIntents perform 加成功/失败/空/needApp 反馈。品牌色 Color.cdropAccent 从 RootView 移入 Shared/Theme(主 app 与扩展共用)。 - 长条行交互统一:设备 / 传输历史 / 收到的文件 一致为「点按打开 + 尾部滑动删除 + 长按菜单」。设备去掉「点按即删」、加长按移除;修滑动移除时「析构按钮预演删除动画致下方设备上移又复位」的抖动(改普通按钮 + 红色,真正删除在二次确认后);传输页 ScrollView→List 以获原生滑动删除,卡片观感经 .plain + 透明行背景 + 隐藏分隔线保留。 - i18n:3 locale 新增 ios.logs./ios.scan./ios.widget./ios.settings.viewLogs 等键并重生 iOS JSON。
This commit is contained in:
@@ -27,6 +27,7 @@ import { startHub } from "../features/transfer/hub";
|
||||
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
|
||||
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
|
||||
import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
|
||||
import { qrApprove, qrDeny, qrRequest } from "../net/qr";
|
||||
import { useAppStore } from "../store";
|
||||
import type { TransferRecord } from "../store/types";
|
||||
|
||||
@@ -277,6 +278,26 @@ function bindCommands(): void
|
||||
if (!p.device_id || !p.name) { return; }
|
||||
void renameSelf(p.device_id, p.name);
|
||||
});
|
||||
// 扫码登录他端(#8):原生(已登录)扫到另一台设备的登录二维码后,送来 { request_id, code }。
|
||||
// 先拉请求信息回报原生供安全确认,再据原生指令批准 / 拒绝(qr.ts,走 apiFetch 带本机 Bearer)。
|
||||
onNativeEvent("fetchLoginRequest", (payload) =>
|
||||
{
|
||||
const p = payload as { request_id?: string; code?: string };
|
||||
if (!p.request_id || !p.code) { return; }
|
||||
void fetchLoginRequest(p.request_id, p.code);
|
||||
});
|
||||
onNativeEvent("approveLogin", (payload) =>
|
||||
{
|
||||
const p = payload as { request_id?: string; code?: string };
|
||||
if (!p.request_id || !p.code) { return; }
|
||||
void approveLogin(p.request_id, p.code);
|
||||
});
|
||||
onNativeEvent("denyLogin", (payload) =>
|
||||
{
|
||||
const p = payload as { request_id?: string; code?: string };
|
||||
if (!p.request_id || !p.code) { return; }
|
||||
void denyLogin(p.request_id, p.code);
|
||||
});
|
||||
// APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。
|
||||
onNativeEvent("registerPush", (payload) =>
|
||||
{
|
||||
@@ -392,6 +413,55 @@ async function renameSelf(deviceID: string, name: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// 扫码登录他端(#8):本机持完整会话,代另一台设备批准其登录请求。三步映射到 qr.ts:
|
||||
// fetchLoginRequest → qrRequest 拉待批准设备信息,回报原生渲染确认(不盲批);
|
||||
// approveLogin → qrApprove 授予完整会话(full + persist,原生只接受 full);
|
||||
// denyLogin → qrDeny 拒绝。任一失败回报 loginFailed(码失效 / 已处理 / 网络),原生提示并清待批准态。
|
||||
async function fetchLoginRequest(requestId: string, code: string): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
const info = await qrRequest(requestId, code);
|
||||
notifyNative("loginRequest", {
|
||||
request_id: requestId,
|
||||
code,
|
||||
device_name: info.deviceName,
|
||||
device_type: info.deviceType,
|
||||
request_ip: info.requestIp,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
notifyNative("loginFailed", {});
|
||||
}
|
||||
}
|
||||
|
||||
async function approveLogin(requestId: string, code: string): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
await qrApprove(requestId, code, "full", "persist");
|
||||
notifyNative("loginApproved", {});
|
||||
}
|
||||
catch
|
||||
{
|
||||
notifyNative("loginFailed", {});
|
||||
}
|
||||
}
|
||||
|
||||
async function denyLogin(requestId: string, code: string): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
await qrDeny(requestId, code);
|
||||
notifyNative("loginDenied", {});
|
||||
}
|
||||
catch
|
||||
{
|
||||
notifyNative("loginFailed", {});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -129,8 +129,28 @@ async function brokerRefresh(refreshToken: string): Promise<BrokerRefreshResult>
|
||||
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
|
||||
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
|
||||
|
||||
// refreshTokens renews the access token; api.ts calls it lazily on a 401.
|
||||
export async function refreshTokens(): Promise<RefreshOutcome>
|
||||
// Single-flight guard. The broker rotates refresh tokens single-use: a refresh consumes
|
||||
// the current refresh token and returns a fresh pair, invalidating the old one. On a cold
|
||||
// boot after a long gap (iOS engine reopened next morning) the access token is expired, so
|
||||
// the boot's concurrent authed calls (SSE + /api/me + ICE + scope) all 401 at once. Without
|
||||
// this guard each 401 calls refreshTokens() in parallel with the SAME stored refresh token:
|
||||
// the first rotates it, the rest replay the now-invalidated token → broker 401 → "invalid"
|
||||
// → forceLogout → a spurious re-login every morning. Collapsing concurrent refreshes onto a
|
||||
// single in-flight promise means one rotation, one new pair, and every waiter retries with
|
||||
// the fresh access token. (Sequential refreshes are safe — each uses the latest token.)
|
||||
let refreshInFlight: Promise<RefreshOutcome> | null = null;
|
||||
|
||||
// refreshTokens renews the access token; api.ts calls it lazily on a 401. Concurrent callers
|
||||
// share one in-flight refresh (see refreshInFlight) so a single-use rotating refresh token is
|
||||
// consumed exactly once.
|
||||
export function refreshTokens(): Promise<RefreshOutcome>
|
||||
{
|
||||
if (refreshInFlight) { return refreshInFlight; }
|
||||
refreshInFlight = doRefreshTokens().finally(() => { refreshInFlight = null; });
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
async function doRefreshTokens(): Promise<RefreshOutcome>
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return "transient"; }
|
||||
|
||||
@@ -429,4 +429,24 @@ export const enUS: Partial<TranslationDict> = {
|
||||
"ios.control.upload": "Upload Clipboard",
|
||||
"ios.control.pull": "Pull Clipboard",
|
||||
"ios.control.sessionLabel": "{{name}} · Control",
|
||||
"ios.settings.viewLogs": "View Logs",
|
||||
"ios.logs.title": "Engine Logs",
|
||||
"ios.logs.empty": "No logs yet",
|
||||
"ios.logs.clear": "Clear Logs",
|
||||
"ios.logs.copy": "Copy All",
|
||||
"ios.scan.entry": "Scan to Sign In",
|
||||
"ios.scan.title": "Scan QR Code",
|
||||
"ios.scan.hint": "Point at the sign-in QR code on another device",
|
||||
"ios.scan.invalid": "Unrecognized sign-in QR code",
|
||||
"ios.scan.approveTitle": "Approve this device to sign in?",
|
||||
"ios.scan.approve": "Approve",
|
||||
"ios.scan.deny": "Deny",
|
||||
"ios.scan.approved": "Device approved",
|
||||
"ios.scan.denied": "Device denied",
|
||||
"ios.scan.failed": "Action failed, please retry",
|
||||
"ios.scan.cameraDenied": "Camera access is required to scan; enable it in Settings",
|
||||
"ios.scan.requestIp": "Source IP",
|
||||
"ios.widget.clipboardName": "Cloud Clipboard",
|
||||
"ios.widget.clipboardDesc": "Quickly upload or pull the shared clipboard",
|
||||
"ios.widget.needApp": "Open the app to sign in first",
|
||||
};
|
||||
|
||||
@@ -425,4 +425,24 @@ export const zhCN = {
|
||||
"ios.control.upload": "上传剪贴板",
|
||||
"ios.control.pull": "拉取剪贴板",
|
||||
"ios.control.sessionLabel": "{{name}} · 控件",
|
||||
"ios.settings.viewLogs": "查看日志",
|
||||
"ios.logs.title": "引擎日志",
|
||||
"ios.logs.empty": "暂无日志",
|
||||
"ios.logs.clear": "清空日志",
|
||||
"ios.logs.copy": "复制全部",
|
||||
"ios.scan.entry": "扫码登录设备",
|
||||
"ios.scan.title": "扫码登录",
|
||||
"ios.scan.hint": "对准另一台设备上的登录二维码",
|
||||
"ios.scan.invalid": "无法识别的登录二维码",
|
||||
"ios.scan.approveTitle": "批准此设备登录本账号?",
|
||||
"ios.scan.approve": "批准登录",
|
||||
"ios.scan.deny": "拒绝",
|
||||
"ios.scan.approved": "已批准设备登录",
|
||||
"ios.scan.denied": "已拒绝设备登录",
|
||||
"ios.scan.failed": "操作失败,请重试",
|
||||
"ios.scan.cameraDenied": "需要相机权限才能扫码,请在系统设置中开启",
|
||||
"ios.scan.requestIp": "来源 IP",
|
||||
"ios.widget.clipboardName": "云剪贴板",
|
||||
"ios.widget.clipboardDesc": "快速上传或拉取设备间剪贴板",
|
||||
"ios.widget.needApp": "请先打开 App 登录",
|
||||
} as const;
|
||||
|
||||
@@ -429,4 +429,24 @@ export const zhTW: Partial<TranslationDict> = {
|
||||
"ios.control.upload": "上傳剪貼簿",
|
||||
"ios.control.pull": "拉取剪貼簿",
|
||||
"ios.control.sessionLabel": "{{name}} · 控制項",
|
||||
"ios.settings.viewLogs": "檢視日誌",
|
||||
"ios.logs.title": "引擎日誌",
|
||||
"ios.logs.empty": "尚無日誌",
|
||||
"ios.logs.clear": "清空日誌",
|
||||
"ios.logs.copy": "複製全部",
|
||||
"ios.scan.entry": "掃碼登入裝置",
|
||||
"ios.scan.title": "掃碼登入",
|
||||
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
|
||||
"ios.scan.invalid": "無法辨識的登入 QR 碼",
|
||||
"ios.scan.approveTitle": "批准此裝置登入本帳號?",
|
||||
"ios.scan.approve": "批准登入",
|
||||
"ios.scan.deny": "拒絕",
|
||||
"ios.scan.approved": "已批准裝置登入",
|
||||
"ios.scan.denied": "已拒絕裝置登入",
|
||||
"ios.scan.failed": "操作失敗,請重試",
|
||||
"ios.scan.cameraDenied": "需要相機權限才能掃碼,請在系統設定中開啟",
|
||||
"ios.scan.requestIp": "來源 IP",
|
||||
"ios.widget.clipboardName": "雲剪貼簿",
|
||||
"ios.widget.clipboardDesc": "快速上傳或拉取裝置間剪貼簿",
|
||||
"ios.widget.needApp": "請先開啟 App 登入",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user