feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push

T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,97 @@
import Foundation
import Testing
import WireProtocol
@testable import SessionCore
/// T-iOS-23 · TitleSanitizer OSC **/**plan §7
///
/// - C0/C1/DEL OSC C0
/// - bidi //U+200B200FU+202A202EU+20662069
/// 仿 U+FEFF
/// - `Tunables.titleMaxLength` Character/emoji
/// - web `title.trim() || null`public/tabs.ts:626
@Suite("TitleSanitizer")
struct TitleSanitizerTests {
// MARK: -
@Test("普通标题原样通过(含中文与合法标点)")
func normalTitlePassesThrough() {
#expect(TitleSanitizer.sanitize("vim · 修改 server.ts") == "vim · 修改 server.ts")
}
@Test("首尾空白/换行被修剪;全空白 → 空串(调用方视为无标题)")
func whitespaceIsTrimmed() {
#expect(TitleSanitizer.sanitize(" claude \n") == "claude")
#expect(TitleSanitizer.sanitize(" \t\n ").isEmpty)
#expect(TitleSanitizer.sanitize("").isEmpty)
}
// MARK: - RED
@Test("超长标题截断到 titleMaxLength 个字符")
func overlongTitleIsTruncated() {
// Arrange
let overlong = String(repeating: "A", count: Tunables.titleMaxLength * 4)
// Act
let sanitized = TitleSanitizer.sanitize(overlong)
// Assert
#expect(sanitized.count == Tunables.titleMaxLength)
}
@Test("U+202E RLO 仿冒 payloadbidi 覆写被剥掉,其余字符保留")
func rloSpoofIsStripped() {
// Arrange: RLO 仿 "gpj.exe" "exe.jpg"
let spoof = "evil\u{202E}gpj.exe"
// Act & Assert
#expect(TitleSanitizer.sanitize(spoof) == "evilgpj.exe")
}
@Test("零宽/双向控制全表被剥U+200B200F、U+202A202E、U+20662069、U+FEFF")
func zeroWidthAndBidiRangesAreStripped() {
// Arrange: +
let hostile = "a\u{200B}b\u{200F}c\u{202A}d\u{202E}e\u{2066}f\u{2069}g\u{FEFF}h"
// Act & Assert
#expect(TitleSanitizer.sanitize(hostile) == "abcdefgh")
}
@Test("C0/C1/DEL 控制字符被剥(不信任 OSC 解析已排除 —— ESC 注入零容忍)")
func controlCharactersAreStripped() {
// Arrange: ESC + CSI BELDELC1 CSIU+009B
let hostile = "ok\u{1B}[31mred\u{07}\u{7F}\u{9B}2Jend"
// Act & Assert
#expect(TitleSanitizer.sanitize(hostile) == "ok[31mred2Jend")
}
@Test("emoji 洪泛:按字素簇截断,不劈开 emoji、不 crash")
func emojiFloodIsCappedByGrapheme() {
// Arrange: emoji scalar × 4
let flood = String(repeating: "🧑‍🧑‍🧒", count: Tunables.titleMaxLength * 4)
// Act
let sanitized = TitleSanitizer.sanitize(flood)
// Assert: ZWJ U+200D
// emoji Character
#expect(sanitized.count <= Tunables.titleMaxLength)
#expect(!sanitized.isEmpty)
#expect(sanitized.allSatisfy { !$0.unicodeScalars.isEmpty })
}
@Test("净化幂等sanitize(sanitize(x)) == sanitize(x)")
func sanitizeIsIdempotent() {
// Arrange
let hostile = " \u{202E}evil\u{1B}]0;fake\u{07}\u{200B} 标题 "
// Act
let once = TitleSanitizer.sanitize(hostile)
let twice = TitleSanitizer.sanitize(once)
// Assert
#expect(once == twice)
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import Testing
import WireProtocol
@testable import SessionCore
/// T-iOS-23 · UnreadLedger pure unread-watermark bookkeeping (plan §7).
/// Mirrors the web tab dot semantics (public/tabs.ts `hasActivity`, viewing
/// clears it) against the server's `lastOutputAt` snapshot (T-iOS-37 field):
/// a session is unread iff its `lastOutputAt` is strictly newer than the local
/// last-seen watermark. Persistence-agnostic the App layer wires UserDefaults.
@Suite("UnreadLedger")
struct UnreadLedgerTests {
private let sessionId = UUID()
private let seenAt = 1_700_000_100_000
// MARK: - UnreadlastOutputAt vs
@Test("lastOutputAt 严格大于已记录水位 → unread等于/小于 → 已读")
func unreadIsStrictlyNewerThanWatermark() {
// Arrange
let ledger = UnreadLedger().record(seen: sessionId, at: seenAt)
// Assert
#expect(ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt + 1))
#expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt))
#expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt - 1))
}
@Test("从未看过的会话(无水位):任何正的 lastOutputAt 都是 unread")
func neverSeenSessionIsUnread() {
// Arrange
let ledger = UnreadLedger()
// Assert: 0 lastOutputAt createdAt > 0
#expect(ledger.isUnread(sessionId: sessionId, lastOutputAt: 1))
#expect(ledger.isUnread(sessionId: sessionId, lastOutputAt: seenAt))
}
@Test("lastOutputAt 缺失(旧服务器)或非正值(不可信输入)→ 绝不显示 unread")
func missingOrGarbageLastOutputAtIsNeverUnread() {
// Arrange
let ledger = UnreadLedger()
// Assert: nil = 0/ =
#expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: nil))
#expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: 0))
#expect(!ledger.isUnread(sessionId: sessionId, lastOutputAt: -5))
}
// MARK: - record +
@Test("record 返回新副本,原 ledger 不变(不可变风格)")
func recordIsImmutable() {
// Arrange
let original = UnreadLedger()
// Act
let recorded = original.record(seen: sessionId, at: seenAt)
// Assert
#expect(original == UnreadLedger())
#expect(original.watermarks[sessionId] == nil)
#expect(recorded.watermarks[sessionId] == seenAt)
}
@Test("record 单调不回退:更早的时间不会降低已有水位")
func recordIsMonotonic() {
// Arrange
let ledger = UnreadLedger().record(seen: sessionId, at: seenAt)
// Act: record
let after = ledger.record(seen: sessionId, at: seenAt - 1_000)
// Assert
#expect(after.watermarks[sessionId] == seenAt)
#expect(!after.isUnread(sessionId: sessionId, lastOutputAt: seenAt))
}
// MARK: -
@Test("超过 maxEntries 时丢弃最旧水位,保留最新的 maxEntries 条")
func capDropsOldestWatermarks() throws {
// Arrange:
var ledger = UnreadLedger()
for offset in 0..<UnreadLedger.maxEntries {
ledger = ledger.record(seen: UUID(), at: seenAt + offset)
}
let oldestId = try #require(ledger.watermarks.min { $0.value < $1.value }?.key)
// Act:
let newcomer = UUID()
let capped = ledger.record(seen: newcomer, at: seenAt + UnreadLedger.maxEntries)
// Assert
#expect(capped.watermarks.count == UnreadLedger.maxEntries)
#expect(capped.watermarks[newcomer] != nil)
#expect(capped.watermarks[oldestId] == nil)
}
@Test("init 从持久化载入时同样施加上限(旧存量超限不带病复活)")
func initCapsOversizedPersistedState() {
// Arrange:
let oversized = Dictionary(
uniqueKeysWithValues: (0..<(UnreadLedger.maxEntries + 10)).map {
(UUID(), seenAt + $0)
}
)
// Act
let ledger = UnreadLedger(watermarks: oversized)
// Assert:
#expect(ledger.watermarks.count == UnreadLedger.maxEntries)
let keptMin = ledger.watermarks.values.min()
#expect(keptMin == seenAt + 10)
}
}