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,45 @@
import WireProtocol
/// T-iOS-23 · OSC-title sanitiser. Terminal titles (OSC 0/2, surfaced by
/// SwiftTerm's `setTerminalTitle` delegate) are HOST/ATTACKER-CONTROLLED input
/// they must pass through here before any list/registry/UI use (plan §7).
///
/// Rules, in order:
/// 1. Strip ALL Unicode control characters (C0 + C1 + DEL). SwiftTerm's OSC
/// parsing should already exclude C0, but that is NOT assumed escape
/// injection gets zero tolerance at this boundary.
/// 2. Strip the spoofing vectors OSC parsing does NOT touch: zero-width and
/// bidirectional formatting characters U+200B200F (zero-width space /
/// non-joiner / joiner, LRM/RLM), U+202A202E (bidi embeddings + the
/// classic RLO override), U+20662069 (bidi isolates), plus U+FEFF
/// (zero-width no-break space / BOM). Note: stripping U+200D (ZWJ) splits
/// multi-person emoji into their parts safety over ligature aesthetics.
/// 3. Trim leading/trailing whitespace (mirrors web `title.trim() || null`,
/// public/tabs.ts:626 the caller treats "" as "no title").
/// 4. Truncate to `Tunables.titleMaxLength` CHARACTERS (grapheme clusters
/// an emoji flood caps at 256 visible glyphs and no glyph is ever split).
///
/// Pure + idempotent: sanitize(sanitize(x)) == sanitize(x).
public enum TitleSanitizer {
/// Zero-width / bidi scalar ranges stripped on top of the control set.
private static let strippedScalarRanges: [ClosedRange<UInt32>] = [
0x200B...0x200F, // zero-width space/non-joiner/joiner, LRM, RLM
0x202A...0x202E, // bidi embeddings, pops and overrides (incl. RLO)
0x2066...0x2069, // bidi isolates (LRI/RLI/FSI/PDI)
0xFEFF...0xFEFF, // zero-width no-break space / BOM
]
public static func sanitize(_ raw: String) -> String {
let kept = raw.unicodeScalars.filter { !isDisallowed($0) }
let trimmed = String(String.UnicodeScalarView(kept))
.trimmingCharacters(in: .whitespacesAndNewlines)
return String(trimmed.prefix(Tunables.titleMaxLength))
}
private static func isDisallowed(_ scalar: Unicode.Scalar) -> Bool {
// generalCategory .control covers C0 (U+0000001F), DEL (U+007F)
// and C1 (U+0080009F) in one authoritative check.
if scalar.properties.generalCategory == .control { return true }
return strippedScalarRanges.contains { $0.contains(scalar.value) }
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
/// T-iOS-23 · Pure unread-watermark bookkeeping for the session switcher.
///
/// A session shows an unread dot iff the server's `lastOutputAt` snapshot
/// (`GET /live-sessions`, T-iOS-37 optional field) is STRICTLY newer than the
/// local last-seen watermark mirroring the web tab dot (public/tabs.ts
/// `hasActivity`: inactive tab got output since last viewed; viewing clears it).
///
/// Semantics (documented decisions):
/// - **Missing watermark = 0**: a session never seen on this device has unseen
/// output by definition (`lastOutputAt >= createdAt > 0` server-side), so it
/// lights up until first viewed.
/// - **`lastOutputAt` nil / non-positive never unread**: nil means a pre-P1
/// server that does not serialize the field; non-positive is malformed
/// untrusted input no data, no dot (plan §4: never trust, never guess).
/// - **Monotonic**: recording an EARLIER time never lowers an existing
/// watermark (late/out-of-order callbacks cannot resurrect a dot).
/// - **Capped**: at most `maxEntries` watermarks, oldest-seen dropped first
/// the App persists this dictionary (UserDefaults) and sessions are
/// ephemeral, so unbounded growth is a slow leak.
///
/// Persistence-agnostic pure struct (plan §7): the App layer owns loading /
/// saving `watermarks` (UserDefaults); this type never does I/O.
public struct UnreadLedger: Sendable, Equatable {
/// Upper bound on persisted watermarks (oldest dropped beyond it).
public static let maxEntries = 512
/// sessionId last-seen instant (ms since epoch, same clock as the
/// server's `lastOutputAt`).
public let watermarks: [UUID: Int]
public init(watermarks: [UUID: Int] = [:]) {
self.watermarks = Self.capped(watermarks)
}
/// New ledger with `sessionId` marked seen at `ms` (monotonic max an
/// earlier timestamp never lowers the stored watermark). The receiver is
/// untouched (immutable style).
public func record(seen sessionId: UUID, at ms: Int) -> UnreadLedger {
UnreadLedger(
watermarks: watermarks.merging([sessionId: ms]) { existing, new in
max(existing, new)
}
)
}
/// Unread test: strictly newer server output than the local watermark.
/// nil / non-positive `lastOutputAt` is never unread (see type doc).
public func isUnread(sessionId: UUID, lastOutputAt: Int?) -> Bool {
guard let lastOutputAt, lastOutputAt > 0 else { return false }
return lastOutputAt > (watermarks[sessionId] ?? 0)
}
/// Keep the `maxEntries` NEWEST watermarks (deterministic tie-break by
/// uuid string so equal timestamps cannot flap across runs).
private static func capped(_ watermarks: [UUID: Int]) -> [UUID: Int] {
guard watermarks.count > maxEntries else { return watermarks }
let newestFirst = watermarks.sorted { lhs, rhs in
lhs.value != rhs.value
? lhs.value > rhs.value
: lhs.key.uuidString < rhs.key.uuidString
}
return Dictionary(uniqueKeysWithValues: Array(newestFirst.prefix(maxEntries)))
}
}