iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备

- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource
- 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI
- APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式
- Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎)
- 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换)
- 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册
- I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键
- ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
This commit is contained in:
2026-06-27 00:27:55 +08:00
parent 10cf36ecee
commit 44096069a7
59 changed files with 2585 additions and 144 deletions
+65 -12
View File
@@ -25,6 +25,7 @@ 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 { useAppStore } from "../store";
import type { TransferRecord } from "../store/types";
@@ -166,22 +167,15 @@ function subscribeStore(): void
});
}
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为
// File 交给引擎发起传输。
//
// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按
// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露。R-iOS-4:用
// rangeSource 惰性按块拉取(原生据 Range 头 seek 文件、回 206),整文件永不进 WebView
// 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。
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);
const src = rangeSource(p.url, p.name, p.size, p.type ?? "");
const sessionId = await startOutgoingTransfer(p.target, src);
notifyNative("sendStarted", { sessionId, name: p.name });
}
catch (e)
@@ -251,6 +245,21 @@ function bindCommands(): void
if (!name) { return; }
void revokeDevice(name);
});
// APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。
onNativeEvent("registerPush", (payload) =>
{
const p = payload as { token?: string; locale?: string };
if (!p.token) { return; }
void registerPush(p.token, p.locale ?? "");
});
// 控件专用设备会话代铸:主 app 让引擎(持完整会话)给控制中心控件铸一条独立委派会话
// (独立 device_id),回交原生存 App Group 供控件自用 / 自刷,不与引擎抢 refresh 轮换。
onNativeEvent("provisionWidgetSession", (payload) =>
{
const p = payload as { device_id?: string; device_name?: string };
if (!p.device_id) { return; }
void provisionWidgetSession(p.device_id, p.device_name ?? "");
});
onNativeEvent("shutdown", () =>
{
hubCtrl.abort();
@@ -258,6 +267,50 @@ function bindCommands(): void
});
}
// registerPush:把原生送来的 APNs 设备令牌上报后端(POST /api/push/apns/register)。走引擎
// 而非原生直连,复用 apiFetch 的新鲜会话 token + 自动续期(同剪贴板 / 吊销范式)。后端未配
// APNs(无 .p8)时返回 503best-effort 忽略即可。
async function registerPush(token: string, locale: string): Promise<void>
{
try
{
await apiFetch("/api/push/apns/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_token: token, locale }),
});
}
catch (e)
{
notifyNative("error", { stage: "push", message: e instanceof Error ? e.message : String(e) });
}
}
// provisionWidgetSession:经 /api/auth/device-session 给控件代铸一条独立委派会话(device_type
// ios),把 access / refresh 回交原生(存 App Group)。控件据此独立调 /api/clipboard 并自刷。
async function provisionWidgetSession(deviceId: string, deviceName: string): Promise<void>
{
try
{
const r = await apiFetch("/api/auth/device-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: deviceId, device_name: deviceName, device_type: "ios" }),
});
if (!r.ok) { return; }
const d = (await r.json()) as { access_token: string; refresh_token: string; user_id: string };
notifyNative("widgetSession", {
access_token: d.access_token,
refresh_token: d.refresh_token,
user_id: d.user_id,
});
}
catch (e)
{
notifyNative("error", { stage: "widget", message: e instanceof Error ? e.message : String(e) });
}
}
async function revokeDevice(name: string): Promise<void>
{
try
+14 -12
View File
@@ -2,6 +2,7 @@ import { apiFetch } from "../../net/api";
import { getICEServers } from "./iceServers";
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
import type { FileSource } from "./source";
// WebRTC client wired to the brief §2 invariants:
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
@@ -461,9 +462,9 @@ class Session
}
}
async startOutgoing(file: File): Promise<void>
async startOutgoing(src: FileSource): Promise<void>
{
this.outgoingTotal = file.size;
this.outgoingTotal = src.size;
const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true });
this.bindDataChannel(dc);
// bindDataChannel 给 receiver 绑了 onopensender 这里覆盖一下,等
@@ -471,7 +472,7 @@ class Session
dc.onopen = () =>
{
this.setPhase("dc_open");
void this.streamFile(file).catch((e) =>
void this.streamFile(src).catch((e) =>
{
// eslint-disable-next-line no-console
console.error("p2p send failed", e);
@@ -485,7 +486,7 @@ class Session
await this.sendSignal({ type: "offer", sdp: offer });
}
private async streamFile(file: File): Promise<void>
private async streamFile(src: FileSource): Promise<void>
{
if (!this.dc) { throw new Error("no data channel"); }
const dc = this.dc;
@@ -495,7 +496,7 @@ class Session
await this.markP2PActive();
// Header.
const meta: FileMeta = { name: file.name, size: file.size };
const meta: FileMeta = { name: src.name, size: src.size };
dc.send(JSON.stringify({ type: "meta", ...meta }));
// buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow
@@ -508,11 +509,12 @@ class Session
const sendChunk = CHUNK_SIZE;
let offset = 0;
while (offset < file.size)
while (offset < src.size)
{
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size);
const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer());
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。iOS 下
// src.slice 经 Range 向原生拉取该块,整文件不进 WebView 内存(R-iOS-4)。
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, src.size);
const block = await src.slice(offset, blockEnd);
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。
let inBlock = 0;
@@ -573,7 +575,7 @@ class Session
// 端发,这里只在抽干后驱动发送端自身 UI 收尾)。
await waitForBufferLow(dc, 0);
this.stopSendProgressPoll();
this.bytesSent = file.size;
this.bytesSent = src.size;
this.emitProgress();
this.setState("completed");
// /done is intentionally posted by the *receiver* only — they are the
@@ -847,13 +849,13 @@ function buildPublicSession(sess: Session): P2PSession
export function p2pStartOutgoing(
sessionId: string,
receiverName: string,
file: File,
src: FileSource,
): P2PSession
{
const sess = new Session(sessionId, receiverName, "sender");
p2pSessions.set(receiverName, sess);
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
void sess.startOutgoing(file).catch((e) =>
void sess.startOutgoing(src).catch((e) =>
{
// Cancellation (fallback path closes pc → pending createOffer rejects)
// is expected, not a transport failure — let the relay flow drive state.
+9 -7
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "../../net/api";
import { deliverIncoming, openIncomingSink } from "./incomingSink";
import type { FileSource } from "./source";
// Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks
// via POST and receiver streams the response via GET. Streaming POST is NOT
@@ -30,20 +31,21 @@ const PER_CHUNK_TIMEOUT_MS = 60_000;
// X-Bytes-Received and don't re-send the duplicate.
export async function relaySendFile(
sessionId: string,
file: File,
src: FileSource,
onProgress?: (p: RelayProgress) => void,
abortSignal?: AbortSignal,
): Promise<void>
{
const url = `/api/relay/${encodeURIComponent(sessionId)}/chunk`;
let offset = 0;
while (offset < file.size)
while (offset < src.size)
{
if (abortSignal?.aborted) { throw new Error("relay send aborted"); }
const end = Math.min(offset + CHUNK, file.size);
const slice = file.slice(offset, end);
const isLast = end === file.size;
const end = Math.min(offset + CHUNK, src.size);
// iOS 下经 Range 向原生拉该 1 MiB 片,整文件不进 WebView 内存(R-iOS-4)。
const slice = await src.slice(offset, end);
const isLast = end === src.size;
const result = await postChunkWithRetry(url, slice, offset, isLast, abortSignal);
if (result.skip)
@@ -55,7 +57,7 @@ export async function relaySendFile(
{
offset = end;
}
onProgress?.({ bytes: offset, total: file.size });
onProgress?.({ bytes: offset, total: src.size });
}
}
@@ -104,7 +106,7 @@ function withDeadline(outer: AbortSignal | undefined, ms: number): ScopedSignal
async function postChunkWithRetry(
url: string,
slice: Blob,
slice: Uint8Array<ArrayBuffer>,
offset: number,
isLast: boolean,
abortSignal?: AbortSignal,
+66
View File
@@ -0,0 +1,66 @@
// FileSource:发送端的「字节来源」抽象,把传输层(p2p / relay)与具体文件载体解耦。
// web / 桌面用真 Fileslice 即内存读);iOS 无头引擎用 Range 拉取——向原生
// WKURLSchemeHandler 按需取片,整文件永不进 WebView 内存,避 jetsamR-iOS-4)。
// 两实现字节语义一致,故 p2p 的块读 / 子片发送、relay 的 1 MiB 分块全程不感知来源差异。
export interface FileSource
{
readonly name: string;
readonly size: number;
readonly type: string;
// 读取 [start, end) 字节区间(end 独占),返回该区间的副本。底层 buffer 固定为
// ArrayBuffer(非 SharedArrayBuffer),以满足 RTCDataChannel.send / fetch body 的类型约束。
slice(start: number, end: number): Promise<Uint8Array<ArrayBuffer>>;
}
// fileSource:包一个已在内存的 File(web / 桌面,以及 iOS「收到的文件再转发」等场景)。
// slice 行为与既有 `new Uint8Array(await file.slice(...).arrayBuffer())` 逐字节一致,
// 故对非 iOS 平台零行为变更。
export function fileSource(file: File): FileSource
{
return {
name: file.name,
size: file.size,
type: file.type || "application/octet-stream",
async slice(start, end)
{
return new Uint8Array(await file.slice(start, end).arrayBuffer());
},
};
}
// rangeSource:经 HTTP Range 向原生 WKURLSchemeHandler 惰性拉取文件片(iOS 大文件流式发送)。
// 每次 slice 只取 [start, end) 一段,WebView 常驻内存上限即单块大小(p2p 的
// READ_BLOCK_SIZE = 4 MiB)。url 形如 `cdrop-file://<id>`;原生据 Range 头 seek 文件、
// 回 206 Partial Content 仅含该区间字节。
export function rangeSource(
url: string,
name: string,
size: number,
type: string,
): FileSource
{
return {
name,
size,
type: type || "application/octet-stream",
async slice(start, end)
{
const resp = await fetch(url, {
headers: { Range: `bytes=${start}-${end - 1}` },
});
if (!resp.ok && resp.status !== 206)
{
throw new Error(`range fetch ${resp.status} for [${start}, ${end})`);
}
const buf = new Uint8Array(await resp.arrayBuffer());
// 正确性兜底:若原生未识别 Range(返回 200 整文件),客户端再切一刀,使返回
// 严格等于请求区间。原生正常回 206 时此分支不触发,内存仍按块有界。
if (resp.status === 200 && buf.byteLength > end - start)
{
return buf.subarray(start, end);
}
return buf;
},
};
}
+11 -10
View File
@@ -1,6 +1,7 @@
import { apiFetch } from "../../net/api";
import { p2pStartIncoming, p2pStartOutgoing, p2pCleanup, type P2PSession } from "./p2p";
import { relayReceiveFile, relaySendFile } from "./relay";
import type { FileSource } from "./source";
import { useAppStore, type TransferRecord } from "../../store";
// Orchestrator that picks P2P first, falls back to Relay on ICE timeout
@@ -12,7 +13,7 @@ interface OutgoingState
{
sessionId: string;
peerName: string;
file: File;
src: FileSource;
p2p: P2PSession | null;
fallbackTimer: number | null;
abort: AbortController;
@@ -25,7 +26,7 @@ const incomingFiles = new Map<string, { name: string; size: number; sender: stri
// arms a 30s watchdog that calls /fallback if ICE hasn't reached `connected`.
export async function startOutgoingTransfer(
receiverName: string,
file: File,
src: FileSource,
): Promise<string>
{
const initR = await apiFetch("/api/transfer/initiate", {
@@ -33,8 +34,8 @@ export async function startOutgoingTransfer(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
receiver_name: receiverName,
file_name: file.name,
file_size: file.size,
file_name: src.name,
file_size: src.size,
}),
});
if (!initR.ok)
@@ -47,8 +48,8 @@ export async function startOutgoingTransfer(
useAppStore.getState().upsertTransfer({
sessionId,
direction: "outgoing",
fileName: file.name,
fileSize: file.size,
fileName: src.name,
fileSize: src.size,
state: "PENDING",
peerName: receiverName,
startedAt: Date.now(),
@@ -61,7 +62,7 @@ export async function startOutgoingTransfer(
const state: OutgoingState = {
sessionId,
peerName: receiverName,
file,
src,
p2p: null,
fallbackTimer: null,
abort,
@@ -87,7 +88,7 @@ export async function startOutgoingTransfer(
}
}, ICE_TIMEOUT_MS);
const p2p = p2pStartOutgoing(sessionId, receiverName, file);
const p2p = p2pStartOutgoing(sessionId, receiverName, src);
state.p2p = p2p;
p2p.onState((s) =>
@@ -134,11 +135,11 @@ export async function handleRelayReady(sessionId: string): Promise<void>
p2pCleanup(out.peerName);
try
{
await relaySendFile(sessionId, out.file, (p) =>
await relaySendFile(sessionId, out.src, (p) =>
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId]
?? currentRecord(sessionId, "outgoing", out.peerName, out.file.name, out.file.size);
?? currentRecord(sessionId, "outgoing", out.peerName, out.src.name, out.src.size);
const grew = p.bytes > (cur.bytesTransferred ?? 0);
store.upsertTransfer({
...cur,
+6
View File
@@ -409,4 +409,10 @@ export const enUS: Partial<TranslationDict> = {
"ios.files.share": "Share",
"ios.files.done": "Done",
"ios.detail.openFile": "Open File",
"ios.bg.title": "Transferring files",
"ios.bg.subtitle": "Continues in the background",
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control",
};
+6
View File
@@ -405,4 +405,10 @@ export const zhCN = {
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "打开文件",
"ios.bg.title": "正在传输文件",
"ios.bg.subtitle": "切到后台仍继续传输",
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件",
} as const;
+6
View File
@@ -409,4 +409,10 @@ export const zhTW: Partial<TranslationDict> = {
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案",
"ios.bg.title": "正在傳輸檔案",
"ios.bg.subtitle": "切到背景仍繼續傳輸",
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項",
};
+3 -2
View File
@@ -9,6 +9,7 @@ import { ActivityFeed } from "../features/transfer/ActivityFeed";
import { ClipboardPanel } from "../features/clipboard/ClipboardPanel";
import { MessageList } from "../features/messaging/MessageList";
import { startOutgoingTransfer } from "../features/transfer/transfer";
import { fileSource } from "../features/transfer/source";
import { sendMessage } from "../features/messaging/messaging";
import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
import { t } from "../i18n";
@@ -60,7 +61,7 @@ function HomePage()
if (!selectedDevice || !selectedFile) { return; }
try
{
await startOutgoingTransfer(selectedDevice, selectedFile);
await startOutgoingTransfer(selectedDevice, fileSource(selectedFile));
setSelectedFile(null);
}
catch (e)
@@ -79,7 +80,7 @@ function HomePage()
const handleDropFile = async (deviceName: string, file: File) =>
{
setSelectedDevice(deviceName);
try { await startOutgoingTransfer(deviceName, file); }
try { await startOutgoingTransfer(deviceName, fileSource(file)); }
catch (e)
{
toast.error("文件发送失败", e instanceof Error ? e.message : String(e));