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:
@@ -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) });
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }); }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "在线事件",
|
||||
|
||||
@@ -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": "上線事件",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user