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:
@@ -28,6 +28,10 @@ public struct LiveSessionInfo: Sendable, Equatable {
|
||||
public let rows: Int
|
||||
/// Latest statusLine telemetry, if any (B2).
|
||||
public let telemetry: StatusTelemetry?
|
||||
/// Server ms timestamp of the last PTY output (== createdAt until first
|
||||
/// output). OPTIONAL additive field (T-iOS-37, src/types.ts:259-261) —
|
||||
/// pre-P1 servers omit it; nil means "no unread data source" (T-iOS-23).
|
||||
public let lastOutputAt: Int?
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
@@ -38,7 +42,8 @@ public struct LiveSessionInfo: Sendable, Equatable {
|
||||
cwd: String?,
|
||||
cols: Int,
|
||||
rows: Int,
|
||||
telemetry: StatusTelemetry?
|
||||
telemetry: StatusTelemetry?,
|
||||
lastOutputAt: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.createdAt = createdAt
|
||||
@@ -49,12 +54,14 @@ public struct LiveSessionInfo: Sendable, Equatable {
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
self.telemetry = telemetry
|
||||
self.lastOutputAt = lastOutputAt
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveSessionInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, createdAt, clientCount, status, exited, cwd, cols, rows, telemetry
|
||||
case lastOutputAt
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -71,6 +78,7 @@ extension LiveSessionInfo: Decodable {
|
||||
// Optional/nullable fields: wrong type degrades to nil (tolerant).
|
||||
cwd = try? container.decode(String.self, forKey: .cwd)
|
||||
telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
|
||||
lastOutputAt = try? container.decode(Int.self, forKey: .lastOutputAt)
|
||||
}
|
||||
|
||||
/// Decode a `/live-sessions` response body.
|
||||
|
||||
@@ -107,6 +107,28 @@ struct ModelDecodingTests {
|
||||
#expect(session.rows == 24)
|
||||
}
|
||||
|
||||
@Test("LiveSessionInfo.lastOutputAt 可选字段(T-iOS-37/23):在场→解出;缺席(旧服务器)→nil;坏类型→nil 不丢条目")
|
||||
func lastOutputAtDecodesAsOptional() async throws {
|
||||
// Arrange: 在场 / 缺席 / 坏类型(不可信服务器)三个条目。
|
||||
let present = Self.noTelemetrySessionJSON
|
||||
.replacingOccurrences(of: #""createdAt":1720000000000"#,
|
||||
with: #""createdAt":1720000000000,"lastOutputAt":1720000007000"#)
|
||||
let wrongType = Self.fullSessionJSON
|
||||
.replacingOccurrences(of: #""createdAt":1720000000000"#,
|
||||
with: #""createdAt":1720000000000,"lastOutputAt":"soon""#)
|
||||
|
||||
// Act
|
||||
let sessions = try await fetchSessions(
|
||||
body: "[\(present),\(Self.noTelemetrySessionJSON),\(wrongType)]"
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(sessions.count == 3)
|
||||
#expect(sessions[0].lastOutputAt == 1_720_000_007_000)
|
||||
#expect(sessions[1].lastOutputAt == nil)
|
||||
#expect(sessions[2].lastOutputAt == nil)
|
||||
}
|
||||
|
||||
@Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)")
|
||||
func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws {
|
||||
// Arrange
|
||||
|
||||
@@ -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+200B–200F (zero-width space /
|
||||
/// non-joiner / joiner, LRM/RLM), U+202A–202E (bidi embeddings + the
|
||||
/// classic RLO override), U+2066–2069 (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+0000–001F), DEL (U+007F)
|
||||
// and C1 (U+0080–009F) in one authoritative check.
|
||||
if scalar.properties.generalCategory == .control { return true }
|
||||
return strippedScalarRanges.contains { $0.contains(scalar.value) }
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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+200B–200F、U+202A–202E、U+2066–2069
|
||||
/// —— 真正的仿冒向量;另含 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 仿冒 payload:bidi 覆写被剥掉,其余字符保留")
|
||||
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+200B–200F、U+202A–202E、U+2066–2069、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 序列、BEL、DEL、C1 CSI(U+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)
|
||||
}
|
||||
}
|
||||
@@ -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: - Unread判定(lastOutputAt 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user