iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度

- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/)
- UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型
- 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber
- 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md
- 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主
- 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate
- 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方
- 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
This commit is contained in:
2026-06-28 19:33:08 +08:00
parent f1a00d128e
commit 349d94aa8f
39 changed files with 2535 additions and 295 deletions
+7 -4
View File
@@ -71,8 +71,8 @@ export function TransferRow(props: TransferRowProps)
// 终态记录可删除(从列表移除);非终态记录可取消(驱动后端 /cancel)。
const terminal = done || failed || cancelled;
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「立即中继」入口,
// 跳过 30s ICE 等待——只对 outgoing 有意义(驱动后端 /fallback)。
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「中继传输」入口手动切中继——
// 无自动回退后这是切中继的唯一途径,只对 outgoing 有意义(驱动后端 /fallback)。
const canRelayNow = tr.direction === "outgoing"
&& (tr.state === "ACCEPTED" || tr.state === "P2P_ACTIVE")
&& tr.phase !== "fallback_pending"
@@ -192,10 +192,13 @@ function bytesText(tr: TransferRecord): string
// 卡住了」状态已表达停滞,再显示一个陈旧的非零速度会误导。速率由 store 做 EMA 平滑。
function speedText(tr: TransferRecord, stalled: boolean): string
{
if (stalled) { return ""; }
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
const rate = tr.bytesPerSec;
if (!rate || !Number.isFinite(rate) || rate <= 0) { return ""; }
// 完成后显示平均速度(store completeTransfer 已把 bytesPerSec 置为总字节 / 总耗时的均速)。
if (tr.state === "DONE") { return t("transfer.avgRate", { rate: formatBytes(rate) }); }
// 进行中:瞬时速度(EMA 平滑)。卡死 / 非流动阶段不显示陈旧速度,避免误导。
if (stalled) { return ""; }
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
return t("transfer.bytesRate", { rate: formatBytes(rate) });
}
+9 -45
View File
@@ -4,10 +4,10 @@ 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
// (plan §M9: 30s after start, if iceConnectionState != connected → /fallback).
const ICE_TIMEOUT_MS = 30_000;
// Orchestrator that starts P2P and leaves relay to a manual user action.
// 不再有 30s ICE 超时自动回退中继:P2P 无时限尝试,中继仅由用户手动触发(TransferRow 的
// 「中继传输」按钮 → skipWaitRelay)。自动回退会在弱网/慢 ICE 下抢先切走、且掩盖 P2P 是否
// 终将连通,故按产品要求移除——所有中继改手动。
interface OutgoingState
{
@@ -15,7 +15,6 @@ interface OutgoingState
peerName: string;
src: FileSource;
p2p: P2PSession | null;
fallbackTimer: number | null;
// waitTimer / waitUnsub back the "wait for an offline receiver to wake" path: the timeout that
// fails the transfer if the peer never comes online, and the presence subscription that fires
// P2P once it does.
@@ -73,7 +72,6 @@ export async function startOutgoingTransfer(
peerName: receiverName,
src,
p2p: null,
fallbackTimer: null,
waitTimer: null,
waitUnsub: null,
abort,
@@ -86,8 +84,9 @@ export async function startOutgoingTransfer(
return sessionId;
}
// beginP2P arms the relay-fallback watchdog and starts the P2P offer. Extracted from
// startOutgoingTransfer so the offline-receiver path can defer it until the peer wakes.
// beginP2P starts the P2P offer. Extracted from startOutgoingTransfer so the
// offline-receiver path can defer it until the peer wakes. No auto-fallback watchdog:
// relay is manual only (skipWaitRelay).
function beginP2P(state: OutgoingState): void
{
const { sessionId, peerName, src } = state;
@@ -95,35 +94,8 @@ function beginP2P(state: OutgoingState): void
const r0 = store0.activeTransfers[sessionId];
if (r0 && r0.phase === "waiting_peer") { store0.upsertTransfer({ ...r0, phase: "waiting_accept" }); }
// Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
// intentionally synchronous: if createOffer / setLocalDescription hang
// (e.g. injected stub during fallback testing) the timer must still fire.
state.fallbackTimer = window.setTimeout(() =>
{
const cur = outgoing.get(sessionId);
if (!cur) { return; }
const p2pState = cur.p2p?.state;
if (p2pState !== "connected" && p2pState !== "completed")
{
// 把"已经放弃 P2P,等服务端 RELAY_ACTIVE"这个状态显式落到 store
// UI 据此告诉用户「还在通,正在切到中继」而不是干等。
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
void triggerFallback(sessionId);
}
}, ICE_TIMEOUT_MS);
const p2p = p2pStartOutgoing(sessionId, peerName, src);
state.p2p = p2p;
p2p.onState((s) =>
{
if (s === "completed" || s === "connected")
{
if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
}
});
}
// PEER_WAIT_MS bounds how long the sender waits for an offline receiver to wake + reconnect.
@@ -311,7 +283,6 @@ function cleanupOutgoing(sessionId: string): void
{
const s = outgoing.get(sessionId);
if (!s) { return; }
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
if (s.waitUnsub) { s.waitUnsub(); }
s.abort.abort();
@@ -325,17 +296,10 @@ export function cleanupTransfer(sessionId: string): void
incomingFiles.delete(sessionId);
}
// UI-triggered (发送方「跳过等待、立即中继」):发送方不等 30s ICE 超时,直接切到
// 中继。复用 watchdog 的逻辑——先停掉那个定时器以免重复 fire,把记录标成
// fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
// UI-triggered (发送方「中继传输」):手动把传输切到中继(无自动回退后,这是切中继的唯一途径)。
// 把记录标成 fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
export async function skipWaitRelay(sessionId: string): Promise<void>
{
const cur = outgoing.get(sessionId);
if (cur?.fallbackTimer)
{
clearTimeout(cur.fallbackTimer);
cur.fallbackTimer = null;
}
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
+3 -2
View File
@@ -284,7 +284,7 @@ export const enUS: Partial<TranslationDict> = {
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
"transfer.action.relayNow": "Relay now",
"transfer.action.relayNow": "Relay",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
@@ -313,6 +313,7 @@ export const enUS: Partial<TranslationDict> = {
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
"transfer.avgRate": "Avg {{rate}}/s",
// ---- debug expansion ------------------------------------------------
"transfer.debug.toggle": "Debug",
@@ -414,7 +415,7 @@ export const enUS: Partial<TranslationDict> = {
"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.transfer.forceRelay": "Relay",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
+3 -2
View File
@@ -280,7 +280,7 @@ export const zhCN = {
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
"transfer.action.relayNow": "立即中继",
"transfer.action.relayNow": "中继传输",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
@@ -309,6 +309,7 @@ export const zhCN = {
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.avgRate": "平均 {{rate}}/秒",
// ---- 调试展开区 -----------------------------------------------------
"transfer.debug.toggle": "调试信息",
@@ -410,7 +411,7 @@ export const zhCN = {
"ios.records.clear": "清空",
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
"ios.transfer.cancel": "取消传输",
"ios.transfer.forceRelay": "立即切到中继",
"ios.transfer.forceRelay": "中继传输",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
+3 -2
View File
@@ -284,7 +284,7 @@ export const zhTW: Partial<TranslationDict> = {
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
"transfer.action.relayNow": "立即中繼",
"transfer.action.relayNow": "中繼傳輸",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
@@ -313,6 +313,7 @@ export const zhTW: Partial<TranslationDict> = {
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.avgRate": "平均 {{rate}}/秒",
// ---- 偵錯展開區 -----------------------------------------------------
"transfer.debug.toggle": "偵錯資訊",
@@ -414,7 +415,7 @@ export const zhTW: Partial<TranslationDict> = {
"ios.records.clear": "清空",
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
"ios.transfer.cancel": "取消傳輸",
"ios.transfer.forceRelay": "立即切到中繼",
"ios.transfer.forceRelay": "中繼傳輸",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
+13 -1
View File
@@ -46,7 +46,19 @@ export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice>
{
const cur = s.activeTransfers[sessionId];
if (!cur) { return s; }
const finalRec = normalizeTerminal(cur, finalState);
let finalRec = normalizeTerminal(cur, finalState);
// 完成后速度显示「平均速度」(总字节 / 总耗时),取代最后的瞬时 EMA 速率——后者只反映末段
// 抽样、对整体传输无代表性。仅 DONE 有意义(FAILED/CANCELLED 为部分传输,不覆写)。全平台
// 一处生效:web/desktop 读此记录、iOS 经 toWire→transferDone 拿到同一 bytesPerSec。
if (finalState === "DONE")
{
const elapsedMs = Date.now() - (cur.startedAt ?? Date.now());
const total = cur.fileSize > 0 ? cur.fileSize : (cur.bytesTransferred ?? 0);
if (elapsedMs > 0 && total > 0)
{
finalRec = { ...finalRec, bytesPerSec: total / (elapsedMs / 1000) };
}
}
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,