diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift new file mode 100644 index 000000000000..e5f4e9554656 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift @@ -0,0 +1,20 @@ +import Foundation + +struct MobileNotificationFeedListBoundedDecodeOptions: Sendable { + let maxNotifications: Int + let stringLimits: MobileNotificationFeedListStringLimits +} + +func mobileNotificationFeedListBoundedDecodeOptions( + from decoder: any Decoder +) throws -> MobileNotificationFeedListBoundedDecodeOptions { + if let options = decoder.userInfo[.mobileNotificationFeedListBoundedDecodeOptions] + as? MobileNotificationFeedListBoundedDecodeOptions { + return options + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Missing bounded notification feed decode options" + ) + throw DecodingError.dataCorrupted(context) +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift new file mode 100644 index 000000000000..ecdc03bb0293 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift @@ -0,0 +1,122 @@ +import Foundation + +struct MobileNotificationFeedListBoundedItem: Decodable { + let item: MobileNotificationFeedListItem? + + enum CodingKeys: String, CodingKey { + case id + case workspaceID = "workspace_id" + case surfaceID = "surface_id" + case title + case subtitle + case body + case createdAt = "created_at" + case isRead = "is_read" + case retargetsToLiveSurfaceOwner = "retargets_to_live_surface_owner" + case workspaceTitle = "workspace_title" + case surfaceTitle = "surface_title" + } + + init(from decoder: any Decoder) throws { + let options = try mobileNotificationFeedListBoundedDecodeOptions(from: decoder) + let limits = options.stringLimits + let container = try decoder.container(keyedBy: CodingKeys.self) + let surfaceID = try container.decodeIfPresent(String.self, forKey: .surfaceID) + guard let id = try mobileNotificationFeedListIdentityString( + from: container, + forKey: .id, + limitedToUTF8Bytes: limits.identifierByteLimit + ), + let workspaceID = try mobileNotificationFeedListIdentityString( + from: container, + forKey: .workspaceID, + limitedToUTF8Bytes: limits.identifierByteLimit + ), + (surfaceID?.utf8.count ?? 0) <= limits.identifierByteLimit else { + item = nil + return + } + item = MobileNotificationFeedListItem( + id: id, + workspaceID: workspaceID, + surfaceID: surfaceID, + title: try mobileNotificationFeedListString( + from: container, + forKey: .title, + limitedToUTF8Bytes: limits.titleByteLimit + ), + subtitle: try mobileNotificationFeedListOptionalString( + from: container, + forKey: .subtitle, + limitedToUTF8Bytes: limits.subtitleByteLimit + ), + body: try mobileNotificationFeedListString( + from: container, + forKey: .body, + limitedToUTF8Bytes: limits.bodyByteLimit + ), + createdAt: Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .createdAt)), + isRead: try container.decode(Bool.self, forKey: .isRead), + retargetsToLiveSurfaceOwner: try container.decodeIfPresent( + Bool.self, + forKey: .retargetsToLiveSurfaceOwner + ) ?? false, + workspaceTitle: try mobileNotificationFeedListOptionalString( + from: container, + forKey: .workspaceTitle, + limitedToUTF8Bytes: limits.metadataByteLimit + ), + surfaceTitle: try mobileNotificationFeedListOptionalString( + from: container, + forKey: .surfaceTitle, + limitedToUTF8Bytes: limits.metadataByteLimit + ) + ) + } +} + +private func mobileNotificationFeedListIdentityString( + from container: KeyedDecodingContainer, + forKey key: MobileNotificationFeedListBoundedItem.CodingKeys, + limitedToUTF8Bytes maxBytes: Int +) throws -> String? { + let value = try container.decode(String.self, forKey: key) + guard value.utf8.count <= maxBytes else { return nil } + return value +} + +private func mobileNotificationFeedListString( + from container: KeyedDecodingContainer, + forKey key: MobileNotificationFeedListBoundedItem.CodingKeys, + limitedToUTF8Bytes maxBytes: Int +) throws -> String { + try mobileNotificationFeedListString( + container.decode(String.self, forKey: key), + limitedToUTF8Bytes: maxBytes + ) +} + +private func mobileNotificationFeedListOptionalString( + from container: KeyedDecodingContainer, + forKey key: MobileNotificationFeedListBoundedItem.CodingKeys, + limitedToUTF8Bytes maxBytes: Int +) throws -> String? { + guard let value = try container.decodeIfPresent(String.self, forKey: key) else { + return nil + } + return mobileNotificationFeedListString(value, limitedToUTF8Bytes: maxBytes) +} + +private func mobileNotificationFeedListString(_ value: String, limitedToUTF8Bytes maxBytes: Int) -> String { + guard maxBytes >= 0, value.utf8.count > maxBytes else { return value } + var byteCount = 0 + var endIndex = value.startIndex + while endIndex < value.endIndex { + let nextIndex = value.index(after: endIndex) + let characterByteCount = value[endIndex.. MobileNotificationFeedListResponse { try JSONDecoder().decode(Self.self, from: data) } + + /// Creates a response by decoding only the newest retained prefix and bounding + /// text before building + /// the public response DTO. This is used for defensive phone ingress from + /// older Macs that can send more rows or larger fields than current clients + /// retain. + public init( + decodingBounded data: Data, + maxNotifications: Int, + stringLimits: MobileNotificationFeedListStringLimits + ) throws { + try Task.checkCancellation() + let decoder = JSONDecoder() + decoder.userInfo[.mobileNotificationFeedListBoundedDecodeOptions] = MobileNotificationFeedListBoundedDecodeOptions( + maxNotifications: max(0, maxNotifications), + stringLimits: stringLimits + ) + self = try decoder.decode(MobileNotificationFeedListBoundedResponse.self, from: data).response + } +} + +extension CodingUserInfoKey { + static let mobileNotificationFeedListBoundedDecodeOptions = CodingUserInfoKey( + rawValue: "dev.cmux.mobileNotificationFeedListBoundedDecodeOptions" + )! } diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift new file mode 100644 index 000000000000..8deb3b54af3f --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift @@ -0,0 +1,28 @@ +/// Byte limits applied while decoding notification-feed list payloads. +public struct MobileNotificationFeedListStringLimits: Sendable { + /// Maximum UTF-8 bytes accepted for notification, workspace, and surface identifiers. + public let identifierByteLimit: Int + /// Maximum UTF-8 bytes retained for notification titles. + public let titleByteLimit: Int + /// Maximum UTF-8 bytes retained for notification subtitles. + public let subtitleByteLimit: Int + /// Maximum UTF-8 bytes retained for notification bodies. + public let bodyByteLimit: Int + /// Maximum UTF-8 bytes retained for display metadata. + public let metadataByteLimit: Int + + /// Creates non-negative string limits for defensive feed decoding. + public init( + identifierByteLimit: Int, + titleByteLimit: Int, + subtitleByteLimit: Int, + bodyByteLimit: Int, + metadataByteLimit: Int + ) { + self.identifierByteLimit = max(0, identifierByteLimit) + self.titleByteLimit = max(0, titleByteLimit) + self.subtitleByteLimit = max(0, subtitleByteLimit) + self.bodyByteLimit = max(0, bodyByteLimit) + self.metadataByteLimit = max(0, metadataByteLimit) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift index bb497b11996c..d6281836e9af 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift @@ -34,6 +34,43 @@ struct MobileNotificationFeedDTOTests { #expect(!item.retargetsToLiveSurfaceOwner) } + @Test("Bounded list response decodes only the retained prefix") + func boundedListResponseDecodeStopsAtCapAndLimitsStrings() throws { + let data = Data( + """ + {"revision":17,"notifications":[{"id":"overlong-identity","workspace_id":"workspace","title":"Dropped","body":"Dropped","created_at":1721000000,"is_read":false},{"id":"valid-1","workspace_id":"work-1","surface_id":"surf-1","title":"abcdef","subtitle":"ghijk","body":"lmnopqr","created_at":1721000000.25,"is_read":false,"retargets_to_live_surface_owner":true,"workspace_title":"workspace-title","surface_title":"surface-title"},{"workspace_id":"invalid-trailing-row","title":"Dropped","body":"Missing id","created_at":1721000002,"is_read":false}]} + """.utf8 + ) + + #expect(throws: (any Error).self) { + _ = try MobileNotificationFeedListResponse.decode(data) + } + let response = try MobileNotificationFeedListResponse( + decodingBounded: data, + maxNotifications: 1, + stringLimits: MobileNotificationFeedListStringLimits( + identifierByteLimit: 8, + titleByteLimit: 5, + subtitleByteLimit: 4, + bodyByteLimit: 6, + metadataByteLimit: 7 + ) + ) + + #expect(response.revision == 17) + #expect(response.notifications.count == 1) + let first = try #require(response.notifications.first) + #expect(first.id == "valid-1") + #expect(first.workspaceID == "work-1") + #expect(first.surfaceID == "surf-1") + #expect(first.title == "abcde") + #expect(first.subtitle == "ghij") + #expect(first.body == "lmnopq") + #expect(first.workspaceTitle == "workspa") + #expect(first.surfaceTitle == "surface") + #expect(first.retargetsToLiveSurfaceOwner) + } + @Test("Revision-only changed event rejects malformed payloads") func changedEventDecode() { #expect(MobileNotificationFeedChangedEvent.decode(Data(#"{"revision":18}"#.utf8))?.revision == 18) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift index 696dcbde0b4c..262c34998d25 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift @@ -8,6 +8,12 @@ private let notificationFeedLog = Logger( category: "notification-feed" ) +nonisolated private let mobileShellNotificationFeedIdentifierByteLimit = 512 +nonisolated private let mobileShellNotificationFeedTitleByteLimit = 512 +nonisolated private let mobileShellNotificationFeedSubtitleByteLimit = 512 +nonisolated private let mobileShellNotificationFeedBodyByteLimit = 2_048 +nonisolated private let mobileShellNotificationFeedMetadataByteLimit = 512 + @MainActor extension MobileShellComposite { /// Refreshes the chronological feed from every currently connected capable Mac. @@ -66,6 +72,28 @@ extension MobileShellComposite { return .unavailable } + /// Builds a computer-picker-scoped feed from the retained source snapshots + /// before applying the global row cap. Filtering the already-capped global + /// feed can hide an entire Mac when another Mac owns the newest retained + /// rows. + public func notificationFeedItems( + scopedTo macDeviceIDs: Set? + ) -> [MobileNotificationFeedItem] { + guard let macDeviceIDs, !macDeviceIDs.isEmpty else { + return notificationFeedItems + } + let projected = notificationFeedSnapshotsByMac.compactMap { + entry -> MobileNotificationFeedSourceSnapshot? in + let macDeviceID = entry.key + guard macDeviceIDs.contains(macDeviceID) else { return nil } + return MobileNotificationFeedSourceSnapshot( + items: entry.value.items, + connectionStatus: notificationFeedConnectionStatus(for: macDeviceID) + ) + } + return notificationFeedAggregation.items(from: projected) + } + /// Marks one notification read on its owning Mac and reconciles the local snapshot. /// - Parameter item: The immutable feed item selected by the user. public func markNotificationFeedItemRead(_ item: MobileNotificationFeedItem) async { @@ -118,16 +146,16 @@ extension MobileShellComposite { /// Marks every retained notification read on each currently connected capable Mac. public func markAllNotificationFeedItemsRead() async { - await markNotificationFeedItemsRead(notificationFeedItems) + await markNotificationFeedItemsRead(scopedTo: nil) } - /// Marks every retained notification read for the Macs represented by `items`. + /// Marks every retained notification read for the selected computer scope. /// This keeps a computer-scoped feed's bulk action within the scope visible to - /// the user while still using the host's atomic mark-all mutation per Mac. - public func markNotificationFeedItemsRead(_ items: [MobileNotificationFeedItem]) async { - let macDeviceIDs = Set(items.lazy.filter { !$0.isRead }.map(\.macDeviceID)) + /// the user without deriving mutation targets from the capped visible rows. + public func markNotificationFeedItemsRead(scopedTo macDeviceIDs: Set?) async { + if macDeviceIDs?.isEmpty == true { return } let targets = notificationFeedTargets().filter { target in - macDeviceIDs.contains(target.macDeviceID) + (macDeviceIDs?.contains(target.macDeviceID) ?? true) && notificationFeedSnapshotsByMac[target.macDeviceID]?.items.contains(where: { !$0.isRead }) == true } for target in targets { @@ -321,11 +349,30 @@ extension MobileShellComposite { func recomputeNotificationFeedItems() { let projected = notificationFeedSnapshotsByMac.map { macDeviceID, snapshot in let status = notificationFeedConnectionStatus(for: macDeviceID) - return snapshot.items.map { $0.updating(connectionStatus: status) } + return MobileNotificationFeedSourceSnapshot( + items: snapshot.items, + connectionStatus: status + ) } notificationFeedItems = notificationFeedAggregation.items(from: projected) } + /// Keeps the first row for each identity. Callers provide newest-first + /// items, so the retained row is the row aggregation would emit. + private func deduplicatedNotificationFeedItems( + _ items: [MobileNotificationFeedItem] + ) -> [MobileNotificationFeedItem] { + var seenIDs = Set() + seenIDs.reserveCapacity(items.count) + var uniqueItems: [MobileNotificationFeedItem] = [] + uniqueItems.reserveCapacity(items.count) + for item in items { + guard seenIDs.insert(item.id).inserted else { continue } + uniqueItems.append(item) + } + return uniqueItems + } + /// Resolves the foreground Mac id for event routing without exposing RPC state to UI. func normalizedForegroundNotificationFeedMacIDForEvent() -> String? { normalizedForegroundNotificationFeedMacID() @@ -358,6 +405,7 @@ extension MobileShellComposite { macDeviceID: String, displayName: String ) -> Bool { + guard let macDeviceID = normalizedIdentifier(macDeviceID) else { return false } let currentRevision = notificationFeedSnapshotsByMac[macDeviceID]?.revision ?? -1 let minimumRevision = notificationFeedKnownRevisionsByMac[macDeviceID] ?? -1 guard response.revision >= minimumRevision else { @@ -370,27 +418,59 @@ extension MobileShellComposite { guard response.revision >= currentRevision else { return false } let status = notificationFeedConnectionStatus(for: macDeviceID) - let items = response.notifications.compactMap { wire -> MobileNotificationFeedItem? in - let id = wire.id.trimmingCharacters(in: .whitespacesAndNewlines) - let workspaceID = wire.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty, !workspaceID.isEmpty else { return nil } - return MobileNotificationFeedItem( - macDeviceID: macDeviceID, - notificationID: id, - macDisplayName: displayName, - remoteWorkspaceID: workspaceID, - remoteSurfaceID: normalizedOptional(wire.surfaceID), - title: wire.title, - subtitle: normalizedOptional(wire.subtitle), - body: wire.body, - createdAt: wire.createdAt, - isRead: wire.isRead, - retargetsToLiveSurfaceOwner: wire.retargetsToLiveSurfaceOwner, - workspaceTitle: normalizedOptional(wire.workspaceTitle), - surfaceTitle: normalizedOptional(wire.surfaceTitle), - connectionStatus: status - ) - } + let macDisplayName = normalizedDisplayName(displayName, fallback: macDeviceID) + // The Mac feed contract is newest-first. Cap each source snapshot + // before local projection, then sort only that bounded window. Do not + // destructively prune source tails by the current global top rows: + // aggregation is already lazy-capped, and retained per-Mac tails are + // needed to refill the feed when another source is removed or shrinks. + let items = deduplicatedNotificationFeedItems( + response.notifications + .prefix(MobileNotificationFeedAggregation.maxItemCount) + .compactMap { wire -> MobileNotificationFeedItem? in + guard let id = normalizedIdentifier(wire.id), + let workspaceID = normalizedIdentifier(wire.workspaceID) else { + return nil + } + return MobileNotificationFeedItem( + macDeviceID: macDeviceID, + notificationID: id, + macDisplayName: macDisplayName, + remoteWorkspaceID: workspaceID, + remoteSurfaceID: normalizedOptionalIdentifier(wire.surfaceID), + title: mobileShellNotificationFeedString( + wire.title, + limitedToUTF8Bytes: mobileShellNotificationFeedTitleByteLimit + ), + subtitle: normalizedOptionalText( + wire.subtitle, + limitedToUTF8Bytes: mobileShellNotificationFeedSubtitleByteLimit + ), + body: mobileShellNotificationFeedString( + wire.body, + limitedToUTF8Bytes: mobileShellNotificationFeedBodyByteLimit + ), + createdAt: wire.createdAt, + isRead: wire.isRead, + retargetsToLiveSurfaceOwner: wire.retargetsToLiveSurfaceOwner, + workspaceTitle: normalizedOptionalText( + wire.workspaceTitle, + limitedToUTF8Bytes: mobileShellNotificationFeedMetadataByteLimit + ), + surfaceTitle: normalizedOptionalText( + wire.surfaceTitle, + limitedToUTF8Bytes: mobileShellNotificationFeedMetadataByteLimit + ), + connectionStatus: status + ) + } + .sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id < rhs.id + } + ) notificationFeedSnapshotsByMac[macDeviceID] = NotificationFeedMacSnapshot( revision: response.revision, items: items @@ -454,7 +534,20 @@ extension MobileShellComposite { params: [:] ) let data = try await client.sendRequest(request) - let response = try MobileNotificationFeedListResponse.decode(data) + let stringLimits = mobileShellNotificationFeedListStringLimits() + let maxNotifications = MobileNotificationFeedAggregation.maxItemCount + let decoderTask = Task.detached(priority: .userInitiated) { + try MobileNotificationFeedListResponse( + decodingBounded: data, + maxNotifications: maxNotifications, + stringLimits: stringLimits + ) + } + let response = try await withTaskCancellationHandler( + operation: { try await decoderTask.value }, + onCancel: { decoderTask.cancel() } + ) + guard !Task.isCancelled else { return } guard notificationFeedClient(for: macDeviceID) === client else { return } _ = applyNotificationFeedSnapshot( response, @@ -579,7 +672,7 @@ extension MobileShellComposite { private func normalizedForegroundNotificationFeedMacID() -> String? { let raw = foregroundMacDeviceID ?? activeTicket?.macDeviceID - return normalizedOptional(raw) + return normalizedOptionalIdentifier(raw) } private func notificationFeedDisplayName(for macDeviceID: String) -> String { @@ -607,12 +700,65 @@ extension MobileShellComposite { } private func normalizedDisplayName(_ value: String?, fallback: String) -> String { - normalizedOptional(value) ?? fallback + normalizedOptionalText( + value, + limitedToUTF8Bytes: mobileShellNotificationFeedMetadataByteLimit + ) ?? mobileShellNotificationFeedString( + fallback.trimmingCharacters(in: .whitespacesAndNewlines), + limitedToUTF8Bytes: mobileShellNotificationFeedMetadataByteLimit + ) } - private func normalizedOptional(_ value: String?) -> String? { + private func normalizedIdentifier(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.utf8.count <= mobileShellNotificationFeedIdentifierByteLimit else { + return nil + } + return trimmed + } + + private func normalizedOptionalIdentifier(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { return nil } + !trimmed.isEmpty, + trimmed.utf8.count <= mobileShellNotificationFeedIdentifierByteLimit else { + return nil + } return trimmed } + + private func normalizedOptionalText( + _ value: String?, + limitedToUTF8Bytes maxBytes: Int + ) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return mobileShellNotificationFeedString(trimmed, limitedToUTF8Bytes: maxBytes) + } +} + +nonisolated private func mobileShellNotificationFeedListStringLimits() -> MobileNotificationFeedListStringLimits { + MobileNotificationFeedListStringLimits( + identifierByteLimit: mobileShellNotificationFeedIdentifierByteLimit, + titleByteLimit: mobileShellNotificationFeedTitleByteLimit, + subtitleByteLimit: mobileShellNotificationFeedSubtitleByteLimit, + bodyByteLimit: mobileShellNotificationFeedBodyByteLimit, + metadataByteLimit: mobileShellNotificationFeedMetadataByteLimit + ) +} + +private func mobileShellNotificationFeedString(_ value: String, limitedToUTF8Bytes maxBytes: Int) -> String { + guard maxBytes >= 0, value.utf8.count > maxBytes else { return value } + var byteCount = 0 + var endIndex = value.startIndex + while endIndex < value.endIndex { + let nextIndex = value.index(after: endIndex) + let characterByteCount = value[endIndex.. [(notificationIDs: [String], clientID: String?)] { dismisses } + func recordedNotificationFeedMarkAllReadCount() -> Int { notificationFeedMarkAllReadCount } /// Sendable extract of the request fields the router needs, pulled off the /// non-Sendable params dictionary before crossing the Task boundary. @@ -374,6 +376,12 @@ actor RoutingHostRouter { clientID: info.clientID )) return try? Self.resultFrame(id: id, result: [:]) + case "notification.feed.mark_all_read": + notificationFeedMarkAllReadCount += 1 + return try? Self.resultFrame(id: id, result: [ + "marked": 1, + "revision": notificationFeedMarkAllReadCount + 100, + ]) case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport": return try? Self.resultFrame(id: id, result: [:]) default: diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift index 213b7b883253..10cec564698f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift @@ -42,6 +42,251 @@ struct MobileShellNotificationFeedStateTests { #expect(store.notificationFeedItems.map(\.notificationID) == ["a-current", "b-new"]) } + @Test("Aggregation caps the retained phone feed at newest rows") + func aggregationCapsRetainedFeedAtNewestRows() throws { + let store = MobileShellComposite() + let cap = MobileNotificationFeedAggregation.maxItemCount + let olderEntries = (0..<25).map { offset in + NotificationResponseEntry( + id: "old-\(offset)", + createdAt: Double(offset), + isRead: false + ) + } + let newerEntries = (0.. MobileNotificationFeedListResponse { + let notifications = entries.map { entry in + #"{"id":"\#(entry.id)","workspace_id":"workspace","title":"Title","body":"Body","created_at":\#(entry.createdAt),"is_read":\#(entry.isRead)}"# + }.joined(separator: ",") + return try MobileNotificationFeedListResponse.decode(Data( + #"{"revision":\#(revision),"notifications":[\#(notifications)]}"#.utf8 + )) + } + } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/NotificationResponseEntry.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/NotificationResponseEntry.swift new file mode 100644 index 000000000000..9878ab2031b6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/NotificationResponseEntry.swift @@ -0,0 +1,5 @@ +struct NotificationResponseEntry { + let id: String + let createdAt: Double + let isRead: Bool +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift index 9b6363559f29..899ed53c1b80 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift @@ -2,6 +2,13 @@ import Foundation /// Produces one deterministic cross-Mac feed from per-Mac notification snapshots. public struct MobileNotificationFeedAggregation: Sendable { + /// Upper bound for retained notification-feed rows on the phone. + /// + /// The Mac keeps the same total history cap, but this defensive cap keeps + /// newer phones bounded when paired with older Macs that only capped read + /// history and could return an unbounded unread feed. + public static let maxItemCount = 2_000 + /// Creates a stateless feed aggregator. public init() {} @@ -9,21 +16,92 @@ public struct MobileNotificationFeedAggregation: Sendable { /// /// Equal timestamps use ``MobileNotificationFeedItemID`` as a deterministic /// tie-breaker, so list order never flickers across repeated refreshes. - /// - Parameter snapshots: Per-Mac item arrays. + /// - Parameter snapshots: Per-Mac item arrays in any order. /// - Returns: A stable, reverse-chronological cross-Mac feed. public func items(from snapshots: [[MobileNotificationFeedItem]]) -> [MobileNotificationFeedItem] { + guard Self.maxItemCount > 0 else { return [] } + var newestByIdentity: [MobileNotificationFeedItemID: MobileNotificationFeedItem] = [:] + newestByIdentity.reserveCapacity(min( + Self.maxItemCount, + snapshots.reduce(0) { partialResult, items in + partialResult + items.count + } + )) + for item in snapshots.joined() { - if let existing = newestByIdentity[item.id], existing.createdAt > item.createdAt { + if let existing = newestByIdentity[item.id], + !mobileNotificationFeedItemPrecedes(item, existing) { continue } newestByIdentity[item.id] = item } - return newestByIdentity.values.sorted { lhs, rhs in - if lhs.createdAt != rhs.createdAt { - return lhs.createdAt > rhs.createdAt + + let sorted = newestByIdentity.values.sorted(by: mobileNotificationFeedItemPrecedes) + guard sorted.count > Self.maxItemCount else { return sorted } + return Array(sorted.prefix(Self.maxItemCount)) + } + + /// Lazily merges newest-first per-Mac snapshots and applies optional + /// connection-status projection only to rows retained by the global cap. + /// + /// This keeps refresh and read-state updates bounded by the phone feed size + /// instead of materializing and sorting every retained row from every Mac. + /// - Parameter snapshots: Per-Mac sources, each ordered newest first. + /// - Returns: A stable, reverse-chronological cross-Mac feed. + public func items( + from snapshots: [MobileNotificationFeedSourceSnapshot] + ) -> [MobileNotificationFeedItem] { + guard Self.maxItemCount > 0 else { return [] } + + var frontier = MobileNotificationFeedAggregationCandidateHeap() + for (sourceIndex, snapshot) in snapshots.enumerated() { + guard let item = snapshot.items.first else { continue } + frontier.insert(MobileNotificationFeedAggregationCandidate( + item: item, + sourceIndex: sourceIndex, + itemIndex: 0, + connectionStatus: snapshot.connectionStatus + )) + } + + var result: [MobileNotificationFeedItem] = [] + result.reserveCapacity(Self.maxItemCount) + var emittedIDs = Set() + emittedIDs.reserveCapacity(Self.maxItemCount) + + while result.count < Self.maxItemCount, + let candidate = frontier.pop() { + if emittedIDs.insert(candidate.item.id).inserted { + if let connectionStatus = candidate.connectionStatus { + result.append(candidate.item.updating(connectionStatus: connectionStatus)) + } else { + result.append(candidate.item) + } + } + + let nextIndex = candidate.itemIndex + 1 + let snapshot = snapshots[candidate.sourceIndex] + if nextIndex < snapshot.items.count { + frontier.insert(MobileNotificationFeedAggregationCandidate( + item: snapshot.items[nextIndex], + sourceIndex: candidate.sourceIndex, + itemIndex: nextIndex, + connectionStatus: snapshot.connectionStatus + )) } - return lhs.id < rhs.id } + + return result + } +} + +func mobileNotificationFeedItemPrecedes( + _ lhs: MobileNotificationFeedItem, + _ rhs: MobileNotificationFeedItem +) -> Bool { + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt } + return lhs.id < rhs.id } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift new file mode 100644 index 000000000000..e6468acaa4b3 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift @@ -0,0 +1,6 @@ +struct MobileNotificationFeedAggregationCandidate: Sendable { + var item: MobileNotificationFeedItem + var sourceIndex: Int + var itemIndex: Int + var connectionStatus: MobileMacConnectionStatus? +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift new file mode 100644 index 000000000000..2367a21860ab --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift @@ -0,0 +1,59 @@ +struct MobileNotificationFeedAggregationCandidateHeap: Sendable { + private var storage: [MobileNotificationFeedAggregationCandidate] = [] + + mutating func insert(_ candidate: MobileNotificationFeedAggregationCandidate) { + storage.append(candidate) + siftUp(from: storage.count - 1) + } + + mutating func pop() -> MobileNotificationFeedAggregationCandidate? { + guard !storage.isEmpty else { return nil } + guard storage.count > 1 else { return storage.removeLast() } + let candidate = storage[0] + storage[0] = storage.removeLast() + siftDown(from: 0) + return candidate + } + + private mutating func siftUp(from startIndex: Int) { + var child = startIndex + while child > 0 { + let parent = (child - 1) / 2 + guard candidatePrecedes(storage[child], storage[parent]) else { return } + storage.swapAt(child, parent) + child = parent + } + } + + private mutating func siftDown(from startIndex: Int) { + var parent = startIndex + while true { + let left = parent * 2 + 1 + let right = left + 1 + var candidate = parent + if left < storage.count, candidatePrecedes(storage[left], storage[candidate]) { + candidate = left + } + if right < storage.count, candidatePrecedes(storage[right], storage[candidate]) { + candidate = right + } + guard candidate != parent else { return } + storage.swapAt(parent, candidate) + parent = candidate + } + } + + private func candidatePrecedes( + _ lhs: MobileNotificationFeedAggregationCandidate, + _ rhs: MobileNotificationFeedAggregationCandidate + ) -> Bool { + if !mobileNotificationFeedItemPrecedes(lhs.item, rhs.item), + !mobileNotificationFeedItemPrecedes(rhs.item, lhs.item) { + if lhs.sourceIndex != rhs.sourceIndex { + return lhs.sourceIndex < rhs.sourceIndex + } + return lhs.itemIndex < rhs.itemIndex + } + return mobileNotificationFeedItemPrecedes(lhs.item, rhs.item) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift new file mode 100644 index 000000000000..a422e73e6772 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift @@ -0,0 +1,17 @@ +/// One per-Mac notification source plus the connection status to project onto +/// retained rows during aggregation. +public struct MobileNotificationFeedSourceSnapshot: Sendable { + /// The source Mac's retained notifications, newest first. + public let items: [MobileNotificationFeedItem] + /// The source Mac's current connection status, when one should be projected onto retained rows. + public let connectionStatus: MobileMacConnectionStatus? + + /// Creates one source snapshot for cross-Mac notification aggregation. + public init( + items: [MobileNotificationFeedItem], + connectionStatus: MobileMacConnectionStatus? = nil + ) { + self.items = items + self.connectionStatus = connectionStatus + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift index 39a2c6a6fd1a..466eb9400185 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift @@ -17,6 +17,57 @@ struct MobileNotificationFeedAggregationTests { #expect(Set(result.map(\.id)).count == 3) } + @Test("Global cap keeps newest rows across many newest-first sources") + func globalCapAcrossManySources() { + let cap = MobileNotificationFeedAggregation.maxItemCount + let olderSources = (0..<25).map { offset in + [ + item( + mac: "old-\(offset)", + id: "old-\(offset)", + createdAt: Date(timeIntervalSince1970: Double(offset)) + ) + ] + } + let newerItems = (0.. MobileNotificationFeedItem { MobileNotificationFeedItem( macDeviceID: mac, @@ -41,7 +93,7 @@ struct MobileNotificationFeedAggregationTests { body: "Body", createdAt: createdAt, isRead: isRead, - connectionStatus: .connected + connectionStatus: connectionStatus ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryNotificationNavigationRoute.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryNotificationNavigationRoute.swift new file mode 100644 index 000000000000..3f091f2f14bf --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryNotificationNavigationRoute.swift @@ -0,0 +1,7 @@ +#if os(iOS) +enum MobilePrimaryNotificationNavigationRoute: Equatable { + case mountedNotificationSearch + case notificationTabAfterSearchDismissal + case mountedNotificationTab +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift new file mode 100644 index 000000000000..55f4520a8aa7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchCoordinator.swift @@ -0,0 +1,231 @@ +#if os(iOS) +import Observation + +/// Owns the native Search tab's scope, presentation lifecycle, platform draft +/// text, and committed filters. The shell keeps one coordinator above the tab +/// scaffold so search edits do not invalidate shell-wide presentation state. +@MainActor +@Observable +final class MobilePrimarySearchCoordinator { + var scope: MobilePrimarySearchScope + var isPresented = false + var workspaces = "" { + didSet { normalizeCommittedSearchText(for: .workspaces, oldValue: oldValue) } + } + var notifications = "" { + didSet { normalizeCommittedSearchText(for: .notifications, oldValue: oldValue) } + } + private(set) var activationGeneration: UInt64 = 0 + + private var phase: MobilePrimarySearchPhase = .inactive + private var platformSearchingScope: MobilePrimarySearchScope? + private var workspaceNativeSearchText = "" + private var notificationNativeSearchText = "" + private let searchQueryBounds = MobileSearchQueryBounds() + + init(initialScope: MobilePrimarySearchScope = .workspaces) { + scope = initialScope + } + + func synchronizeSelection(_ selection: MobilePrimaryTab) { + guard let selectedScope = selection.searchScope else { return } + guard scope != selectedScope else { return } + scope = selectedScope + syncNativeSearchText(fromCommittedQueryFor: selectedScope) + } + + func setPresentation(_ presented: Bool) { + if isPresented, !presented { + commitNativeDraft(for: scope) + beginDeactivation(for: scope) + } + isPresented = presented + if presented { + activate(scope: scope) + } + } + + func commitSubmit() -> MobilePrimaryTab { + let submittedScope = scope + commitNativeDraft(for: submittedScope) + beginDeactivation(for: submittedScope) + isPresented = false + return submittedScope.primaryTab + } + + func deactivateCurrentSearch() { + commitNativeDraft(for: scope) + beginDeactivation(for: scope) + isPresented = false + } + + func updateLifecycle(scope: MobilePrimarySearchScope, isSearching: Bool) { + if isSearching { + activate(scope: scope) + platformSearchingScope = scope + } else if phase == .active(scope) { + guard platformSearchingScope == scope else { return } + commitNativeDraft(for: scope) + beginDeactivation(for: scope) + } + } + + func activeNativeSearchText() -> String { + nativeSearchText(for: scope) + } + + func nativeSearchText(for scope: MobilePrimarySearchScope) -> String { + switch scope { + case .workspaces: + workspaceNativeSearchText + case .notifications: + notificationNativeSearchText + } + } + + func updateNativeSearchText( + _ value: String, + for scope: MobilePrimarySearchScope, + activationGeneration: UInt64 + ) { + guard acceptsNativeEdit(for: scope, activationGeneration: activationGeneration) else { + if phase != .active(scope) || !isPresented { + syncNativeSearchText(fromCommittedQueryFor: scope) + } + return + } + setNativeSearchText(value, for: scope) + } + + func acceptsNativeEdit( + for scope: MobilePrimarySearchScope, + activationGeneration: UInt64 + ) -> Bool { + phase == .active(scope) + && isPresented + && self.activationGeneration == activationGeneration + } + + func searchDestinationText(for scope: MobilePrimarySearchScope) -> String { + if phase == .active(scope), isPresented { + return nativeSearchText(for: scope) + } + return committedSearchText(for: scope) + } + + func notificationFeedNavigationRoute( + selectedTab: MobilePrimaryTab + ) -> MobilePrimaryNotificationNavigationRoute { + if selectedTab == .search { + return scope == .notifications + ? .mountedNotificationSearch + : .notificationTabAfterSearchDismissal + } + if isPresented { + return .notificationTabAfterSearchDismissal + } + return .mountedNotificationTab + } + + func committedSearchText(for scope: MobilePrimarySearchScope) -> String { + switch scope { + case .workspaces: + workspaces + case .notifications: + notifications + } + } + + private func setNativeSearchText(_ value: String, for scope: MobilePrimarySearchScope) { + let value = searchQueryBounds.boundedEditingText(value).value + switch scope { + case .workspaces: + guard workspaceNativeSearchText != value else { return } + workspaceNativeSearchText = value + case .notifications: + guard notificationNativeSearchText != value else { return } + notificationNativeSearchText = value + } + } + + private func setCommittedSearchText(_ value: String, for scope: MobilePrimarySearchScope) { + let value = searchQueryBounds.boundedEditingText(value).value + switch scope { + case .workspaces: + guard workspaces != value else { return } + workspaces = value + case .notifications: + guard notifications != value else { return } + notifications = value + } + } + + private func normalizeCommittedSearchText( + for scope: MobilePrimarySearchScope, + oldValue: String + ) { + let normalized = searchQueryBounds.boundedEditingText(committedSearchText(for: scope)) + if normalized.didChange { + setCommittedSearchText(normalized.value, for: scope) + } + if normalized.value != oldValue { + setNativeSearchText(normalized.value, for: scope) + } + } + + private func commitNativeDraft(for scope: MobilePrimarySearchScope) { + setCommittedSearchText( + searchQueryBounds.normalizedFilterText(nativeSearchText(for: scope)).value, + for: scope + ) + } + + private func syncNativeSearchText(fromCommittedQueryFor scope: MobilePrimarySearchScope) { + setNativeSearchText(committedSearchText(for: scope), for: scope) + } + + private func beginDeactivation(for scope: MobilePrimarySearchScope) { + phase = .deactivating(scope) + platformSearchingScope = nil + syncNativeSearchText(fromCommittedQueryFor: scope) + } + + private func activate(scope: MobilePrimarySearchScope) { + let startsNewActivation = phase != .active(scope) || !isPresented + if startsNewActivation { + activationGeneration &+= 1 + platformSearchingScope = nil + } + self.scope = scope + isPresented = true + phase = .active(scope) + if startsNewActivation { + syncNativeSearchText(fromCommittedQueryFor: scope) + } + } +} + +extension MobilePrimaryTab { + var searchScope: MobilePrimarySearchScope? { + switch self { + case .workspaces: + .workspaces + case .notifications: + .notifications + case .search: + nil + } + } +} + +extension MobilePrimarySearchScope { + var primaryTab: MobilePrimaryTab { + switch self { + case .workspaces: + .workspaces + case .notifications: + .notifications + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchDestinationEnvironment.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchDestinationEnvironment.swift new file mode 100644 index 000000000000..8003f0c1c193 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchDestinationEnvironment.swift @@ -0,0 +1,7 @@ +#if os(iOS) +import SwiftUI + +extension EnvironmentValues { + @Entry var mobilePrimarySearchDestination = false +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchHosts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchHosts.swift new file mode 100644 index 000000000000..8a2fb242a193 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchHosts.swift @@ -0,0 +1,28 @@ +#if os(iOS) +import SwiftUI + +struct MobilePrimaryWorkspaceSearchHost: View { + @Bindable var searchCoordinator: MobilePrimarySearchCoordinator + let taskComposerAction: (() -> Void)? + let content: (String) -> Content + + init( + searchCoordinator: MobilePrimarySearchCoordinator, + taskComposerAction: (() -> Void)? = nil, + @ViewBuilder content: @escaping (String) -> Content + ) { + self.searchCoordinator = searchCoordinator + self.taskComposerAction = taskComposerAction + self.content = content + } + + var body: some View { + WorkspaceListSearchHost( + searchText: $searchCoordinator.workspaces, + taskComposerAction: taskComposerAction, + content: content + ) + } +} + +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchLifecycleModifier.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchLifecycleModifier.swift new file mode 100644 index 000000000000..fa2831f54a9e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchLifecycleModifier.swift @@ -0,0 +1,20 @@ +#if os(iOS) +import SwiftUI + +struct MobilePrimarySearchLifecycleModifier: ViewModifier { + @Environment(\.isSearching) private var isSearching + + let scope: MobilePrimarySearchScope + let update: (MobilePrimarySearchScope, Bool) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: isSearching, initial: true) { _, isSearching in + update(scope, isSearching) + } + .onDisappear { + update(scope, false) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchPhase.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchPhase.swift new file mode 100644 index 000000000000..0cafa067ddee --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchPhase.swift @@ -0,0 +1,7 @@ +#if os(iOS) +enum MobilePrimarySearchPhase: Equatable { + case inactive + case active(MobilePrimarySearchScope) + case deactivating(MobilePrimarySearchScope) +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchScope.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchScope.swift new file mode 100644 index 000000000000..e7954312dedc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchScope.swift @@ -0,0 +1,10 @@ +#if os(iOS) +/// The searchable primary destination that owns the persistent search tab. +/// +/// New primary tabs must explicitly choose whether they introduce a search +/// scope or preserve the most recent searchable destination. +enum MobilePrimarySearchScope: Equatable { + case workspaces + case notifications +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift new file mode 100644 index 000000000000..4dbcd66c6c9e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTab.swift @@ -0,0 +1,8 @@ +#if os(iOS) +/// The mobile app's primary destinations and transient search selection. +enum MobilePrimaryTab: Hashable { + case workspaces + case notifications + case search +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift index 9c6014870471..076a0688f869 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift @@ -2,57 +2,175 @@ import CmuxMobileSupport import SwiftUI -/// The two first-class destinations in the mobile app. -enum MobilePrimaryTab: Hashable { - case workspaces - case notifications -} - /// Native primary navigation shared by the live shell and deterministic UI /// fixtures. Keeping the tab construction here guarantees that previews exercise /// the same labels, symbols, badge behavior, and selection semantics as the app. -struct MobilePrimaryTabScaffold: View { +struct MobilePrimaryTabScaffold< + Workspaces: View, + Notifications: View, + WorkspaceSearch: View, + NotificationSearch: View +>: View { @Binding var selection: MobilePrimaryTab + @Bindable var searchCoordinator: MobilePrimarySearchCoordinator let notificationUnreadCount: Int let workspaces: Workspaces let notifications: Notifications + let workspaceSearch: WorkspaceSearch + let notificationSearch: NotificationSearch init( selection: Binding, + searchCoordinator: MobilePrimarySearchCoordinator, notificationUnreadCount: Int, @ViewBuilder workspaces: () -> Workspaces, - @ViewBuilder notifications: () -> Notifications + @ViewBuilder notifications: () -> Notifications, + @ViewBuilder workspaceSearch: () -> WorkspaceSearch, + @ViewBuilder notificationSearch: () -> NotificationSearch ) { _selection = selection + self.searchCoordinator = searchCoordinator self.notificationUnreadCount = notificationUnreadCount self.workspaces = workspaces() self.notifications = notifications() + self.workspaceSearch = workspaceSearch() + self.notificationSearch = notificationSearch() } var body: some View { - TabView(selection: $selection) { - Tab(value: MobilePrimaryTab.workspaces) { - workspaces - } label: { - Label( - L10n.string("mobile.tabs.workspaces", defaultValue: "Workspaces"), - systemImage: "rectangle.stack" - ) - .accessibilityIdentifier("MobilePrimaryTabWorkspaces") + if #available(iOS 26.0, *) { + TabView(selection: tabSelection) { + primaryTabs + + Tab(value: MobilePrimaryTab.search, role: .search) { + searchDestination + } + .accessibilityIdentifier("MobilePrimaryTabSearch") } + .searchable( + text: activeSearchText, + isPresented: searchPresentation, + prompt: activeSearchPrompt + ) + .onSubmit(of: .search) { + selection = searchCoordinator.commitSubmit() + } + .tabViewSearchActivation(.searchTabSelection) + .accessibilityIdentifier("MobilePrimaryTabs") + .onChange(of: selection, initial: true) { _, selection in + searchCoordinator.synchronizeSelection(selection) + } + } else { + TabView(selection: $selection) { + primaryTabs + } + .accessibilityIdentifier("MobilePrimaryTabs") + } + } + + private var tabSelection: Binding { + Binding( + get: { selection }, + set: { newValue in + if (selection == .search || searchCoordinator.isPresented), + newValue.searchScope != nil { + searchCoordinator.deactivateCurrentSearch() + } + selection = newValue + } + ) + } + + private var searchPresentation: Binding { + Binding( + get: { searchCoordinator.isPresented }, + set: { presented in + searchCoordinator.setPresentation(presented) + } + ) + } - Tab(value: MobilePrimaryTab.notifications) { - notifications - } label: { - Label( - L10n.string("mobile.tabs.notifications", defaultValue: "Notifications"), - systemImage: "bell" + @ViewBuilder + private var searchDestination: some View { + switch searchCoordinator.scope { + case .workspaces: + workspaceSearch + .modifier(MobilePrimarySearchLifecycleModifier( + scope: .workspaces, + update: updateSearchLifecycle + )) + .environment(\.mobilePrimarySearchDestination, true) + case .notifications: + notificationSearch + .modifier(MobilePrimarySearchLifecycleModifier( + scope: .notifications, + update: updateSearchLifecycle + )) + .environment(\.mobilePrimarySearchDestination, true) + } + } + + private var activeSearchText: Binding { + let scope = searchCoordinator.scope + let activationGeneration = searchCoordinator.activationGeneration + return Binding( + get: { searchCoordinator.nativeSearchText(for: scope) }, + set: { value in + searchCoordinator.updateNativeSearchText( + value, + for: scope, + activationGeneration: activationGeneration ) - .accessibilityIdentifier("MobilePrimaryTabNotifications") } - .badge(notificationUnreadCount) + ) + } + + private var activeSearchPrompt: Text { + switch searchCoordinator.scope { + case .workspaces: + Text( + L10n.string( + "mobile.workspaces.search.placeholder", + defaultValue: "Search workspaces" + ) + ) + case .notifications: + Text( + L10n.string( + "mobile.notificationFeed.search.placeholder", + defaultValue: "Search notifications" + ) + ) } - .accessibilityIdentifier("MobilePrimaryTabs") + } + + private func updateSearchLifecycle(scope: MobilePrimarySearchScope, isSearching: Bool) { + searchCoordinator.updateLifecycle(scope: scope, isSearching: isSearching) + } + + @TabContentBuilder + private var primaryTabs: some TabContent { + Tab(value: MobilePrimaryTab.workspaces) { + workspaces + } label: { + Label( + L10n.string("mobile.tabs.workspaces", defaultValue: "Workspaces"), + systemImage: "rectangle.stack" + ) + .accessibilityIdentifier("MobilePrimaryTabWorkspaces") + } + + Tab(value: MobilePrimaryTab.notifications) { + notifications + } label: { + Label( + L10n.string("mobile.tabs.notifications", defaultValue: "Notifications"), + systemImage: "bell" + ) + .accessibilityIdentifier("MobilePrimaryTabNotifications") + } + .badge(notificationUnreadCount) } } + #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryWorkspaceSearchContentHost.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryWorkspaceSearchContentHost.swift new file mode 100644 index 000000000000..3f631a3ab71d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryWorkspaceSearchContentHost.swift @@ -0,0 +1,20 @@ +#if os(iOS) +import SwiftUI + +struct MobilePrimaryWorkspaceSearchContentHost: View { + @Bindable var searchCoordinator: MobilePrimarySearchCoordinator + let content: (String) -> Content + + init( + searchCoordinator: MobilePrimarySearchCoordinator, + @ViewBuilder content: @escaping (String) -> Content + ) { + self.searchCoordinator = searchCoordinator + self.content = content + } + + var body: some View { + content(searchCoordinator.searchDestinationText(for: .workspaces)) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSearchQueryBounds.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSearchQueryBounds.swift new file mode 100644 index 000000000000..710a25041dfd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSearchQueryBounds.swift @@ -0,0 +1,40 @@ +import Foundation + +struct MobileSearchQueryBounds: Sendable { + let maxUnicodeScalars = 128 + let maxUTF8Bytes = 512 + + func boundedEditingText(_ value: String) -> (value: String, didChange: Bool) { + var output = String() + output.reserveCapacity(min(maxUnicodeScalars, maxUTF8Bytes)) + var scalarCount = 0 + var utf8ByteCount = 0 + var didTruncate = false + for scalar in value.unicodeScalars { + let scalarUTF8ByteCount = scalar.utf8.count + guard scalarCount < maxUnicodeScalars, + utf8ByteCount + scalarUTF8ByteCount <= maxUTF8Bytes else { + didTruncate = true + break + } + output.unicodeScalars.append(scalar) + scalarCount += 1 + utf8ByteCount += scalarUTF8ByteCount + } + + return ( + value: output, + didChange: didTruncate + ) + } + + func normalizedFilterText(_ value: String) -> (value: String, didChange: Bool) { + let bounded = boundedEditingText(value) + let output = bounded.value + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return ( + value: trimmed, + didChange: bounded.didChange || trimmed != output + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift index b7b90098be3d..d29c0ba728e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift @@ -7,26 +7,38 @@ import SwiftUI /// verification. It mounts the production tab scaffold and production feed. public struct NotificationFeedPreviewView: View { @State private var selectedTab: MobilePrimaryTab = .notifications + @State private var primarySearchCoordinator = MobilePrimarySearchCoordinator( + initialScope: .notifications + ) + @State private var referenceDate: Date @State private var items: [MobileNotificationFeedItem] - @State private var notificationNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var projection = NotificationFeedProjection() + @State private var notificationRoute: NotificationWorkspaceRoute? + @State private var pendingSearchNotificationNavigationID: MobileWorkspacePreview.ID? @State private var macSelection: WorkspaceMacSelection = .all + /// Creates a deterministic notification-feed preview fixture. public init() { - _items = State(initialValue: Self.makeFixtureItems(referenceDate: .now)) + let referenceDate = Date() + _referenceDate = State(initialValue: referenceDate) + _items = State(initialValue: makeNotificationFeedPreviewFixtureItems(referenceDate: referenceDate)) } + /// The preview fixture's production-style tab and feed body. public var body: some View { GeometryReader { geometry in MobilePrimaryTabScaffold( selection: $selectedTab, + searchCoordinator: primarySearchCoordinator, notificationUnreadCount: items.lazy.filter { !$0.isRead }.count ) { NotificationFeedPreviewWorkspacesView() } notifications: { - NavigationStack(path: $notificationNavigationPath) { + NavigationStack { NotificationFeedView( - items: items, status: .ready, + projection: projection, + refreshesOnAppear: true, actions: actions ) .toolbar { @@ -44,24 +56,75 @@ public struct NotificationFeedPreviewView: View { showAddDevice: nil ) } - .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in + .navigationDestination(isPresented: notificationRouteIsPresented) { NotificationFeedPreviewWorkspaceDestination( - workspaceName: workspaceName(for: workspaceID) + workspaceName: notificationRoute.map { workspaceName(for: $0.id) } + ?? L10n.string( + "mobile.notificationFeed.workspaceFallback", + defaultValue: "Workspace" + ) + ) + .toolbarVisibility(.hidden, for: .tabBar) + } + } + .onAppear { + consumePendingSearchNavigation(for: .notifications) + } + .onChange(of: pendingSearchNotificationNavigationID) { _, _ in + consumePendingSearchNavigation(for: .notifications) + } + } workspaceSearch: { + NotificationFeedPreviewWorkspacesView() + } notificationSearch: { + NavigationStack { + NotificationFeedView( + status: .ready, + projection: projection, + refreshesOnAppear: false, + actions: actions + ) + .navigationDestination(isPresented: notificationRouteIsPresented) { + NotificationFeedPreviewWorkspaceDestination( + workspaceName: notificationRoute.map { workspaceName(for: $0.id) } + ?? L10n.string( + "mobile.notificationFeed.workspaceFallback", + defaultValue: "Workspace" + ) ) .toolbarVisibility(.hidden, for: .tabBar) } } } + .background { + NotificationFeedSearchProjectionSync( + searchCoordinator: primarySearchCoordinator, + projection: projection + ) + } .environment(\.workspaceRootToolbarContentWidth, geometry.size.width) } + .onChange(of: primarySearchCoordinator.isPresented) { _, isPresented in + guard !isPresented else { return } + consumePendingSearchNavigation(for: selectedTab) + } + .onChange(of: items, initial: true) { _, items in + projection.update(items: items, referenceDate: referenceDate) + } } private var actions: NotificationFeedActions { NotificationFeedActions( open: { item in - notificationNavigationPath.append( - MobileWorkspacePreview.ID(rawValue: item.remoteWorkspaceID) - ) + let workspaceID = MobileWorkspacePreview.ID(rawValue: item.remoteWorkspaceID) + if selectedTab == .search { + notificationRoute = NotificationWorkspaceRoute(id: workspaceID) + } else if primarySearchCoordinator.isPresented { + pendingSearchNotificationNavigationID = workspaceID + transitionPrimaryTab(to: .notifications) + } else { + transitionPrimaryTab(to: .notifications) + notificationRoute = NotificationWorkspaceRoute(id: workspaceID) + } setRead(true, for: item.id) }, markRead: { item in @@ -82,161 +145,196 @@ public struct NotificationFeedPreviewView: View { ?? L10n.string("mobile.notificationFeed.workspaceFallback", defaultValue: "Workspace") } + private var notificationRouteIsPresented: Binding { + Binding( + get: { notificationRoute != nil }, + set: { isPresented in + if !isPresented { + notificationRoute = nil + } + } + ) + } + private func setRead(_ isRead: Bool, for id: MobileNotificationFeedItemID) { items = items.map { item in item.id == id ? item.updating(isRead: isRead) : item } } - private static func makeFixtureItems(referenceDate: Date) -> [MobileNotificationFeedItem] { - let calendar = Calendar.autoupdatingCurrent - let startOfToday = calendar.startOfDay(for: referenceDate) - let yesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? referenceDate + private func consumePendingSearchNavigation(for tab: MobilePrimaryTab) { + guard !primarySearchCoordinator.isPresented else { return } + guard tab == .notifications, + let workspaceID = pendingSearchNotificationNavigationID else { return } + pendingSearchNotificationNavigationID = nil + notificationRoute = NotificationWorkspaceRoute(id: workspaceID) + } + + @discardableResult + private func transitionPrimaryTab( + to tab: MobilePrimaryTab, + beforeSelection: () -> Void = {} + ) -> Bool { + let previousTab = selectedTab + if (selectedTab == .search || primarySearchCoordinator.isPresented), + tab.searchScope != nil { + primarySearchCoordinator.deactivateCurrentSearch() + } + beforeSelection() + selectedTab = tab + return previousTab != tab + } + +} + +private func makeNotificationFeedPreviewFixtureItems(referenceDate: Date) -> [MobileNotificationFeedItem] { + let calendar = Calendar.autoupdatingCurrent + let startOfToday = calendar.startOfDay(for: referenceDate) + let yesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? referenceDate - return [ - MobileNotificationFeedItem( - macDeviceID: "studio", - notificationID: "codex-approval", - macDisplayName: L10n.string("mobile.notificationFeed.preview.mac.studio", defaultValue: "Studio"), - remoteWorkspaceID: "workspace-ios-feed", - remoteSurfaceID: "surface-codex", - title: L10n.string( - "mobile.notificationFeed.preview.approval.title", - defaultValue: "Codex needs approval" - ), - subtitle: L10n.string( - "mobile.notificationFeed.preview.approval.subtitle", - defaultValue: "Notification feed" - ), - body: L10n.string( - "mobile.notificationFeed.preview.approval.body", - defaultValue: "The feed is ready to open in the iOS app. Review the navigation and approve the final interaction pass." - ), - createdAt: referenceDate.addingTimeInterval(-7 * 60), - isRead: false, - workspaceTitle: L10n.string( - "mobile.notificationFeed.preview.workspace.ios", - defaultValue: "cmux iOS" - ), - surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.codex", defaultValue: "Codex"), - connectionStatus: .connected + return [ + MobileNotificationFeedItem( + macDeviceID: "studio", + notificationID: "codex-approval", + macDisplayName: L10n.string("mobile.notificationFeed.preview.mac.studio", defaultValue: "Studio"), + remoteWorkspaceID: "workspace-ios-feed", + remoteSurfaceID: "surface-codex", + title: L10n.string( + "mobile.notificationFeed.preview.approval.title", + defaultValue: "Codex needs approval" ), - MobileNotificationFeedItem( - macDeviceID: "macbook", - notificationID: "tests-passed", - macDisplayName: L10n.string( - "mobile.notificationFeed.preview.mac.macBookPro", - defaultValue: "MacBook Pro" - ), - remoteWorkspaceID: "workspace-release", - remoteSurfaceID: "surface-tests", - title: L10n.string( - "mobile.notificationFeed.preview.tests.title", - defaultValue: "Tests passed" - ), - subtitle: L10n.string( - "mobile.notificationFeed.preview.tests.subtitle", - defaultValue: "Release preparation" - ), - body: L10n.string( - "mobile.notificationFeed.preview.tests.body", - defaultValue: "All focused iOS notification tests passed in 42 seconds." - ), - createdAt: referenceDate.addingTimeInterval(-34 * 60), - isRead: false, - workspaceTitle: L10n.string( - "mobile.notificationFeed.preview.workspace.release", - defaultValue: "Release" - ), - surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.tests", defaultValue: "Tests"), - connectionStatus: .connected + subtitle: L10n.string( + "mobile.notificationFeed.preview.approval.subtitle", + defaultValue: "Notification feed" ), - MobileNotificationFeedItem( - macDeviceID: "studio", - notificationID: "localization-complete", - macDisplayName: L10n.string("mobile.notificationFeed.preview.mac.studio", defaultValue: "Studio"), - remoteWorkspaceID: "workspace-localization", - remoteSurfaceID: "surface-agent", - title: L10n.string( - "mobile.notificationFeed.preview.localization.title", - defaultValue: "Localization complete" - ), - subtitle: nil, - body: L10n.string( - "mobile.notificationFeed.preview.localization.body", - defaultValue: "English and Japanese notification-feed strings are ready." - ), - createdAt: referenceDate.addingTimeInterval(-2 * 60 * 60), - isRead: true, - workspaceTitle: L10n.string( - "mobile.notificationFeed.preview.workspace.localization", - defaultValue: "Localization" - ), - surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.agent", defaultValue: "Agent"), - connectionStatus: .connected + body: L10n.string( + "mobile.notificationFeed.preview.approval.body", + defaultValue: "The feed is ready to open in the iOS app. Review the navigation and approve the final interaction pass." ), - MobileNotificationFeedItem( - macDeviceID: "build-mac", - notificationID: "input-needed", - macDisplayName: L10n.string( - "mobile.notificationFeed.preview.mac.build", - defaultValue: "Build Mac" - ), - remoteWorkspaceID: "workspace-cloud", - remoteSurfaceID: "surface-cloud", - title: L10n.string( - "mobile.notificationFeed.preview.input.title", - defaultValue: "Input needed" - ), - subtitle: L10n.string( - "mobile.notificationFeed.preview.input.subtitle", - defaultValue: "Cloud build" - ), - body: L10n.string( - "mobile.notificationFeed.preview.input.body", - defaultValue: "Choose whether to retry the unavailable builder or keep the current artifact. This longer message verifies wrapping without hiding the workspace and Mac context below it." - ), - createdAt: calendar.date(byAdding: .hour, value: 17, to: yesterday) ?? yesterday, - isRead: false, - workspaceTitle: L10n.string( - "mobile.notificationFeed.preview.workspace.cloudBuilder", - defaultValue: "Cloud Builder" - ), - surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.build", defaultValue: "Build"), - connectionStatus: .unavailable + createdAt: referenceDate.addingTimeInterval(-7 * 60), + isRead: false, + workspaceTitle: L10n.string( + "mobile.notificationFeed.preview.workspace.ios", + defaultValue: "cmux iOS" ), - MobileNotificationFeedItem( - macDeviceID: "macbook", - notificationID: "agent-finished", - macDisplayName: L10n.string( - "mobile.notificationFeed.preview.mac.macBookPro", - defaultValue: "MacBook Pro" - ), - remoteWorkspaceID: "workspace-docs", - remoteSurfaceID: "surface-docs", - title: L10n.string( - "mobile.notificationFeed.preview.finished.title", - defaultValue: "Agent finished" - ), - subtitle: L10n.string( - "mobile.notificationFeed.preview.finished.subtitle", - defaultValue: "Documentation" - ), - body: L10n.string( - "mobile.notificationFeed.preview.finished.body", - defaultValue: "The onboarding copy now explains the notification history." - ), - createdAt: calendar.date(byAdding: .hour, value: 11, to: yesterday) ?? yesterday, - isRead: true, - workspaceTitle: L10n.string( - "mobile.notificationFeed.preview.workspace.docs", - defaultValue: "Docs" - ), - surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.agent", defaultValue: "Agent"), - connectionStatus: .reconnecting + surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.codex", defaultValue: "Codex"), + connectionStatus: .connected + ), + MobileNotificationFeedItem( + macDeviceID: "macbook", + notificationID: "tests-passed", + macDisplayName: L10n.string( + "mobile.notificationFeed.preview.mac.macBookPro", + defaultValue: "MacBook Pro" ), - ] - } + remoteWorkspaceID: "workspace-release", + remoteSurfaceID: "surface-tests", + title: L10n.string( + "mobile.notificationFeed.preview.tests.title", + defaultValue: "Tests passed" + ), + subtitle: L10n.string( + "mobile.notificationFeed.preview.tests.subtitle", + defaultValue: "Release preparation" + ), + body: L10n.string( + "mobile.notificationFeed.preview.tests.body", + defaultValue: "All focused iOS notification tests passed in 42 seconds." + ), + createdAt: referenceDate.addingTimeInterval(-34 * 60), + isRead: false, + workspaceTitle: L10n.string( + "mobile.notificationFeed.preview.workspace.release", + defaultValue: "Release" + ), + surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.tests", defaultValue: "Tests"), + connectionStatus: .connected + ), + MobileNotificationFeedItem( + macDeviceID: "studio", + notificationID: "localization-complete", + macDisplayName: L10n.string("mobile.notificationFeed.preview.mac.studio", defaultValue: "Studio"), + remoteWorkspaceID: "workspace-localization", + remoteSurfaceID: "surface-agent", + title: L10n.string( + "mobile.notificationFeed.preview.localization.title", + defaultValue: "Localization complete" + ), + subtitle: nil, + body: L10n.string( + "mobile.notificationFeed.preview.localization.body", + defaultValue: "English and Japanese notification-feed strings are ready." + ), + createdAt: referenceDate.addingTimeInterval(-2 * 60 * 60), + isRead: true, + workspaceTitle: L10n.string( + "mobile.notificationFeed.preview.workspace.localization", + defaultValue: "Localization" + ), + surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.agent", defaultValue: "Agent"), + connectionStatus: .connected + ), + MobileNotificationFeedItem( + macDeviceID: "build-mac", + notificationID: "input-needed", + macDisplayName: L10n.string( + "mobile.notificationFeed.preview.mac.build", + defaultValue: "Build Mac" + ), + remoteWorkspaceID: "workspace-cloud", + remoteSurfaceID: "surface-cloud", + title: L10n.string( + "mobile.notificationFeed.preview.input.title", + defaultValue: "Input needed" + ), + subtitle: L10n.string( + "mobile.notificationFeed.preview.input.subtitle", + defaultValue: "Cloud build" + ), + body: L10n.string( + "mobile.notificationFeed.preview.input.body", + defaultValue: "Choose whether to retry the unavailable builder or keep the current artifact. This longer message verifies wrapping without hiding the workspace and Mac context below it." + ), + createdAt: calendar.date(byAdding: .hour, value: 17, to: yesterday) ?? yesterday, + isRead: false, + workspaceTitle: L10n.string( + "mobile.notificationFeed.preview.workspace.cloudBuilder", + defaultValue: "Cloud Builder" + ), + surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.build", defaultValue: "Build"), + connectionStatus: .unavailable + ), + MobileNotificationFeedItem( + macDeviceID: "macbook", + notificationID: "agent-finished", + macDisplayName: L10n.string( + "mobile.notificationFeed.preview.mac.macBookPro", + defaultValue: "MacBook Pro" + ), + remoteWorkspaceID: "workspace-docs", + remoteSurfaceID: "surface-docs", + title: L10n.string( + "mobile.notificationFeed.preview.finished.title", + defaultValue: "Agent finished" + ), + subtitle: L10n.string( + "mobile.notificationFeed.preview.finished.subtitle", + defaultValue: "Documentation" + ), + body: L10n.string( + "mobile.notificationFeed.preview.finished.body", + defaultValue: "The onboarding copy now explains the notification history." + ), + createdAt: calendar.date(byAdding: .hour, value: 11, to: yesterday) ?? yesterday, + isRead: true, + workspaceTitle: L10n.string( + "mobile.notificationFeed.preview.workspace.docs", + defaultValue: "Docs" + ), + surfaceTitle: L10n.string("mobile.notificationFeed.preview.surface.agent", defaultValue: "Agent"), + connectionStatus: .reconnecting + ), + ] } private struct NotificationFeedPreviewWorkspacesView: View { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedProjection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedProjection.swift index bdddf38d6988..9ccc288a8f1c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedProjection.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedProjection.swift @@ -15,23 +15,55 @@ struct NotificationFeedDaySection: Identifiable, Equatable, Sendable { let items: [MobileNotificationFeedItem] } +nonisolated let notificationFeedProjectionMaxSourceItemCount = MobileNotificationFeedAggregation.maxItemCount +nonisolated let notificationFeedProjectionMaxSearchQueryUnicodeScalars = MobileSearchQueryBounds().maxUnicodeScalars +nonisolated let notificationFeedProjectionMaxSearchQueryUTF8Bytes = MobileSearchQueryBounds().maxUTF8Bytes +nonisolated private let notificationFeedProjectionMaxMetadataSearchableCharactersPerField = 512 +nonisolated private let notificationFeedProjectionMaxTitleSearchableCharacters = 1_024 +nonisolated private let notificationFeedProjectionMaxSubtitleSearchableCharacters = 2_048 +nonisolated private let notificationFeedProjectionMaxBodySearchableCharacters = 8_192 + /// Prepares the user-selected, reverse-chronological day sections consumed by -/// the feed list. Filtering, sorting, and grouping run only when their inputs -/// change, never during a row or list body evaluation. +/// the feed list. The source feed is already sorted newest-first by +/// `MobileNotificationFeedAggregation`; this projection preserves that order +/// while filtering and grouping outside row or list body evaluation. @MainActor @Observable final class NotificationFeedProjection { var filter: MobileNotificationFeedFilter = .all { - didSet { rebuild() } + didSet { + guard filter != oldValue else { return } + scheduleRebuild() + } + } + var searchText = "" { + didSet { + let normalized = searchQueryBounds.boundedEditingText(searchText) + if normalized.didChange { + searchText = normalized.value + } + guard normalized.value != oldValue else { return } + scheduleRebuild( + debounce: notificationFeedProjectionNormalizedSearchQuery(normalized.value).isEmpty + ? nil + : .milliseconds(200) + ) + } } private(set) var sections: [NotificationFeedDaySection] = [] private(set) var sourceItemCount = 0 private(set) var sourceUnreadCount = 0 + private(set) var isSourceRebuilding = false + private(set) var hasStaleSourceSections = false @ObservationIgnored private var sourceItems: [MobileNotificationFeedItem] = [] @ObservationIgnored private var referenceDate: Date @ObservationIgnored private var calendar: Calendar + @ObservationIgnored private var sourceRevision = 0 + @ObservationIgnored private var rebuildRevision = 0 + @ObservationIgnored private var rebuildTask: Task? + @ObservationIgnored private let searchQueryBounds = MobileSearchQueryBounds() init(referenceDate: Date = .now, calendar: Calendar = .autoupdatingCurrent) { self.referenceDate = referenceDate @@ -42,41 +74,204 @@ final class NotificationFeedProjection { items: [MobileNotificationFeedItem], referenceDate: Date = .now ) { - guard sourceItems != items || self.referenceDate != referenceDate else { return } - sourceItems = items + let boundedItems = notificationFeedProjectionBoundedSourceItems(items) + guard sourceItems != boundedItems || self.referenceDate != referenceDate else { return } + sourceItems = boundedItems self.referenceDate = referenceDate - sourceItemCount = items.count - sourceUnreadCount = items.lazy.filter { !$0.isRead }.count - rebuild() + sourceRevision &+= 1 + sourceItemCount = boundedItems.count + sourceUnreadCount = boundedItems.lazy.filter { !$0.isRead }.count + hasStaleSourceSections = !sections.isEmpty + isSourceRebuilding = true + scheduleRebuild() } - private func rebuild() { - let visibleItems = filter.apply(to: sourceItems).sorted { lhs, rhs in - if lhs.createdAt != rhs.createdAt { - return lhs.createdAt > rhs.createdAt + func waitForPendingRebuild() async { + await rebuildTask?.value + } + + /// Debounces query changes and cancels superseded work. The last completed + /// sections stay visible during rebuilds, and results publish atomically + /// only while both captured revisions still match the current projection. + private func scheduleRebuild(debounce: Duration? = nil) { + rebuildRevision &+= 1 + let requestedRebuildRevision = rebuildRevision + let requestedSourceRevision = sourceRevision + let query = notificationFeedProjectionNormalizedSearchQuery(searchText) + let requestedFilter = filter + let requestedReferenceDate = referenceDate + let requestedCalendar = calendar + let requestedSourceItems = sourceItems + + isSourceRebuilding = true + + rebuildTask?.cancel() + rebuildTask = Task { [weak self] in + if let debounce { + do { + try await ContinuousClock().sleep(for: debounce) + } catch { + return + } } - return lhs.id < rhs.id - } - let grouped = Dictionary(grouping: visibleItems) { item in - calendar.startOfDay(for: item.createdAt) - } - let today = calendar.startOfDay(for: referenceDate) - let yesterday = calendar.date(byAdding: .day, value: -1, to: today) - - sections = grouped.keys.sorted(by: >).map { day in - let kind: NotificationFeedDaySection.Kind - if calendar.isDate(day, inSameDayAs: today) { - kind = .today - } else if let yesterday, calendar.isDate(day, inSameDayAs: yesterday) { - kind = .yesterday - } else { - kind = .dated + guard !Task.isCancelled else { return } + + let worker = Task.detached(priority: .userInitiated) { + return notificationFeedProjectionBuild( + items: requestedSourceItems, + filter: requestedFilter, + query: query, + referenceDate: requestedReferenceDate, + calendar: requestedCalendar + ) } - return NotificationFeedDaySection( - id: day, - kind: kind, - items: grouped[day] ?? [] + let output = await withTaskCancellationHandler( + operation: { await worker.value }, + onCancel: { worker.cancel() } ) + guard + !Task.isCancelled, + let output, + let self, + self.rebuildRevision == requestedRebuildRevision, + self.sourceRevision == requestedSourceRevision + else { + return + } + + self.sections = output.sections + self.hasStaleSourceSections = false + self.isSourceRebuilding = false } } + +} + +nonisolated private func notificationFeedProjectionBoundedSourceItems( + _ items: [MobileNotificationFeedItem] +) -> [MobileNotificationFeedItem] { + guard items.count > notificationFeedProjectionMaxSourceItemCount else { return items } + return Array(items.prefix(notificationFeedProjectionMaxSourceItemCount)) +} + +nonisolated private func notificationFeedProjectionNormalizedSearchQuery(_ value: String) -> String { + MobileSearchQueryBounds().normalizedFilterText(value).value +} + +nonisolated private func notificationFeedProjectionBuild( + items: [MobileNotificationFeedItem], + filter: MobileNotificationFeedFilter, + query: String, + referenceDate: Date, + calendar: Calendar +) -> NotificationFeedProjectionOutput? { + let today = calendar.startOfDay(for: referenceDate) + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) + var sections: [NotificationFeedDaySection] = [] + sections.reserveCapacity(min(items.count, 8)) + var currentDay: Date? + var currentItems: [MobileNotificationFeedItem] = [] + + func flushCurrentSection() { + guard let day = currentDay, !currentItems.isEmpty else { return } + let kind: NotificationFeedDaySection.Kind + if calendar.isDate(day, inSameDayAs: today) { + kind = .today + } else if let yesterday, calendar.isDate(day, inSameDayAs: yesterday) { + kind = .yesterday + } else { + kind = .dated + } + sections.append(NotificationFeedDaySection( + id: day, + kind: kind, + items: currentItems + )) + currentItems = [] + } + + for item in items { + guard !Task.isCancelled else { return nil } + if filter == .unread, item.isRead { + continue + } + if !query.isEmpty, !notificationFeedProjectionMatchesSearchQuery(item: item, query: query) { + continue + } + let day = calendar.startOfDay(for: item.createdAt) + if let currentDay, currentDay != day { + flushCurrentSection() + } + currentDay = day + currentItems.append(item) + } + guard !Task.isCancelled else { return nil } + flushCurrentSection() + return NotificationFeedProjectionOutput( + sections: sections + ) +} + +nonisolated private func notificationFeedProjectionMatchesSearchQuery( + item: MobileNotificationFeedItem, + query: String +) -> Bool { + for field in [ + item.workspaceTitle, + item.surfaceTitle, + item.macDisplayName, + ] { + guard !Task.isCancelled else { return false } + if notificationFeedProjectionBoundedFieldContains( + field, + query: query, + maxCharacters: notificationFeedProjectionMaxMetadataSearchableCharactersPerField + ) { + return true + } + } + + if notificationFeedProjectionBoundedFieldContains( + item.title, + query: query, + maxCharacters: notificationFeedProjectionMaxTitleSearchableCharacters + ) { + return true + } + guard !Task.isCancelled else { return false } + if notificationFeedProjectionBoundedFieldContains( + item.subtitle, + query: query, + maxCharacters: notificationFeedProjectionMaxSubtitleSearchableCharacters + ) { + return true + } + guard !Task.isCancelled else { return false } + if notificationFeedProjectionBoundedFieldContains( + item.body, + query: query, + maxCharacters: notificationFeedProjectionMaxBodySearchableCharacters + ) { + return true + } + return false +} + +nonisolated private func notificationFeedProjectionBoundedFieldContains( + _ field: String?, + query: String, + maxCharacters: Int +) -> Bool { + guard let field, maxCharacters > 0 else { return false } + let end = field.index( + field.startIndex, + offsetBy: maxCharacters, + limitedBy: field.endIndex + ) ?? field.endIndex + return field.range( + of: query, + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + range: field.startIndex.. NotificationFeedEmptyState { - if sourceItemCount > 0, filter == .unread { - return .allRead + if isSourceRebuilding { + return .loading } + if sourceItemCount > 0 { + if hasSearchQuery { + return .noSearchResults + } + if filter == .unread { + return .allRead + } + } + switch status { case .idle, .loading: return .loading @@ -72,6 +84,9 @@ enum NotificationFeedEmptyState: Equatable { case .requiresMacUpdate: return .requiresMacUpdate case .ready: + if hasSearchQuery { + return .noSearchResults + } return .empty } } @@ -131,6 +146,7 @@ struct NotificationFeedEmptyRow: View { case .loading: "arrow.triangle.2.circlepath" case .empty: "bell.badge" case .allRead: "checkmark.circle" + case .noSearchResults: "magnifyingglass" case .unavailable: "wifi.slash" case .requiresMacUpdate: "arrow.down.circle" } @@ -140,7 +156,7 @@ struct NotificationFeedEmptyRow: View { switch state { case .allRead: .green case .unavailable, .requiresMacUpdate: .orange - case .loading, .empty: .accentColor + case .loading, .empty, .noSearchResults: .accentColor } } @@ -152,6 +168,11 @@ struct NotificationFeedEmptyRow: View { L10n.string("mobile.notificationFeed.empty.title", defaultValue: "No notifications yet") case .allRead: L10n.string("mobile.notificationFeed.allRead.title", defaultValue: "You're all caught up") + case .noSearchResults: + L10n.string( + "mobile.notificationFeed.search.empty.title", + defaultValue: "No matching notifications" + ) case .unavailable: L10n.string("mobile.notificationFeed.offline.title", defaultValue: "Notifications are offline") case .requiresMacUpdate: @@ -176,6 +197,11 @@ struct NotificationFeedEmptyRow: View { "mobile.notificationFeed.allRead.body", defaultValue: "New agent alerts will appear here as they arrive." ) + case .noSearchResults: + L10n.string( + "mobile.notificationFeed.search.empty.body", + defaultValue: "Try another title, message, workspace, pane, or computer." + ) case .unavailable: L10n.string( "mobile.notificationFeed.offline.body", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift index 6a6ebba5281e..7b497f8f3c8a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift @@ -7,13 +7,17 @@ import SwiftUI /// This is the only notification-feed view that retains a store reference. struct NotificationFeedStoreView: View { @Bindable var store: CMUXMobileShellStore + @Environment(\.mobilePrimarySearchDestination) private var isSearchDestination let items: [MobileNotificationFeedItem] let status: MobileNotificationFeedStatus + let projection: NotificationFeedProjection + let selectedMacDeviceIDs: Set? var body: some View { NotificationFeedView( - items: items, status: status, + projection: projection, + refreshesOnAppear: !isSearchDestination, actions: actions ) .onDisappear { @@ -34,7 +38,7 @@ struct NotificationFeedStoreView: View { Task { await store.markNotificationFeedItemUnread(item) } }, markAllRead: { - Task { await store.markNotificationFeedItemsRead(items) } + Task { await store.markNotificationFeedItemsRead(scopedTo: selectedMacDeviceIDs) } }, refresh: { await store.refreshNotificationFeed() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift index 12d8554532a8..9b295e94c1c9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift @@ -15,12 +15,11 @@ struct NotificationFeedActions { /// Production notification-feed presentation. This view owns only UI projection /// state; rows receive immutable item snapshots plus ``NotificationFeedActions``. struct NotificationFeedView: View { - let items: [MobileNotificationFeedItem] let status: MobileNotificationFeedStatus + let projection: NotificationFeedProjection + let refreshesOnAppear: Bool let actions: NotificationFeedActions - @State private var projection = NotificationFeedProjection() - var body: some View { @Bindable var projection = projection @@ -30,7 +29,10 @@ struct NotificationFeedView: View { NotificationFeedList( sections: projection.sections, sourceItemCount: projection.sourceItemCount, + isSourceRebuilding: projection.isSourceRebuilding, + hasStaleSourceSections: projection.hasStaleSourceSections, filter: projection.filter, + hasSearchQuery: !projection.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, status: status, actions: actions ) @@ -54,10 +56,8 @@ struct NotificationFeedView: View { } } } - .onChange(of: items, initial: true) { _, items in - projection.update(items: items) - } .task { + guard refreshesOnAppear else { return } await actions.refresh() } .accessibilityIdentifier("MobileNotificationFeed") @@ -90,7 +90,10 @@ private struct NotificationFeedFilterBar: View { private struct NotificationFeedList: View { let sections: [NotificationFeedDaySection] let sourceItemCount: Int + let isSourceRebuilding: Bool + let hasStaleSourceSections: Bool let filter: MobileNotificationFeedFilter + let hasSearchQuery: Bool let status: MobileNotificationFeedStatus let actions: NotificationFeedActions @@ -111,6 +114,8 @@ private struct NotificationFeedList: View { ForEach(section.items) { item in NotificationFeedRow(item: item, actions: actions) .equatable() + .disabled(hasStaleSourceSections) + .allowsHitTesting(!hasStaleSourceSections) } } header: { NotificationFeedDayHeader(section: section) @@ -129,6 +134,8 @@ private struct NotificationFeedList: View { NotificationFeedEmptyState.resolve( sourceItemCount: sourceItemCount, filter: filter, + hasSearchQuery: hasSearchQuery, + isSourceRebuilding: isSourceRebuilding, status: status ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationWorkspaceRoute.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationWorkspaceRoute.swift new file mode 100644 index 000000000000..d3fd562d3f41 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationWorkspaceRoute.swift @@ -0,0 +1,7 @@ +#if DEBUG && os(iOS) +import CmuxMobileShellModel + +struct NotificationWorkspaceRoute: Identifiable, Hashable { + let id: MobileWorkspacePreview.ID +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListFilterState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListFilterState.swift new file mode 100644 index 000000000000..aa2d11d6ea15 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListFilterState.swift @@ -0,0 +1,13 @@ +import CmuxMobileShellModel +import Observation + +/// Session-scoped workspace filters shared by every presentation of the +/// workspace root, including the native iOS search tab. +@Observable +final class WorkspaceListFilterState { + var filter: MobileWorkspaceListFilter + + init(filter: MobileWorkspaceListFilter = .all) { + self.filter = filter + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift index 3789c48614d4..fe9b74ff0161 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift @@ -36,6 +36,11 @@ private final class WorkspaceListLayoutPreviewModel { updateLane = (updateLane + 1) % 10 } } + + func rotateForRefresh() { + let current = workspaces + workspaces = Array(current.dropFirst()) + Array(current.prefix(1)) + } } /// DEBUG-only workspace list fixture for simulator layout screenshots. @@ -49,6 +54,9 @@ public struct WorkspaceListLayoutPreviewView: View { @State private var macSelection: WorkspaceMacSelection = .all @State private var refreshGeneration = 0 @State private var model: WorkspaceListLayoutPreviewModel + @State private var selectedPrimaryTab: MobilePrimaryTab = .workspaces + @State private var primarySearchCoordinator = MobilePrimarySearchCoordinator() + @State private var filterState = WorkspaceListFilterState() // Safety: DEBUG screenshot-only presenter is owned by this preview view and // only mutates its fired flag from the SwiftUI task that requests the banner. private let notificationPresenter = ScreenshotNotificationPresenter() @@ -103,6 +111,7 @@ public struct WorkspaceListLayoutPreviewView: View { } @State private var fixtureRoute: FixtureWorkspaceRoute? + @State private var pendingSearchFixtureRoute: FixtureWorkspaceRoute? private var scrollMetricsEnabled: Bool { ProcessInfo.processInfo.environment["CMUX_UITEST_SCROLL_METRICS"] == "1" @@ -251,9 +260,68 @@ public struct WorkspaceListLayoutPreviewView: View { ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS"] == "1" } + private func performPreviewRefresh() { + model.rotateForRefresh() + refreshGeneration += 1 + } + + private func workspaceListFixture(searchText: String) -> some View { + WorkspaceListView( + workspaces: model.workspaces, + groups: groups, + selectedWorkspaceID: selectedWorkspaceID, + host: "Visual Mock Mac", + connectionStatus: .connected, + navigationStyle: .push, + wrapWorkspaceTitles: false, + previewLineLimit: MobileDisplaySettings.defaultWorkspacePreviewLineCount, + unreadIndicatorLeftShift: MobileDisplaySettings.defaultUnreadIndicatorLeftShift, + selectWorkspace: { id in + selectFixtureWorkspace(id) + }, + createWorkspace: {}, + macSelection: $macSelection, + refresh: { + await MainActor.run { + performPreviewRefresh() + } + }, + renameWorkspace: reorderEnabled ? { id, newName in + if let index = model.workspaces.firstIndex(where: { $0.id == id }) { + model.workspaces[index].name = newName + } + } : nil, + setPinned: reorderEnabled ? { id, pinned in + if let index = model.workspaces.firstIndex(where: { $0.id == id }) { + model.workspaces[index].isPinned = pinned + } + } : nil, + setUnread: reorderEnabled ? { id, unread in + if let index = model.workspaces.firstIndex(where: { $0.id == id }) { + model.workspaces[index].hasUnread = unread + } + } : nil, + closeWorkspace: reorderEnabled ? { id in + model.workspaces.removeAll { $0.id == id } + } : nil, + moveWorkspace: reorderEnabled ? { id, groupID, beforeWorkspaceID, movesGroup in + model.workspaces = model.workspaces.applyingWorkspaceMoveIntent( + MobileWorkspaceMoveIntent( + groupID: groupID, + beforeWorkspaceID: beforeWorkspaceID, + movesGroup: movesGroup + ), + movedWorkspaceID: id, + groups: groups + ) + return true + } : nil, + filterState: filterState, + searchText: searchText + ) + } + public var body: some View { - let workspacesBinding = $model.workspaces - let refreshGenerationBinding = $refreshGeneration Group { if UITestConfig.workspaceDetailCreateDelayedTerminalPreviewEnabled { WorkspaceDetailCreateDelayedTerminalPreviewView() @@ -263,78 +331,39 @@ public struct WorkspaceListLayoutPreviewView: View { WorkspaceDetailDelayedTerminalPreviewView() } else { let workspaceListStack = NavigationStack { - WorkspaceListSearchHost { searchText in - WorkspaceListView( - workspaces: model.workspaces, - groups: groups, - selectedWorkspaceID: selectedWorkspaceID, - host: "Visual Mock Mac", - connectionStatus: .connected, - navigationStyle: .push, - wrapWorkspaceTitles: false, - previewLineLimit: MobileDisplaySettings.defaultWorkspacePreviewLineCount, - unreadIndicatorLeftShift: MobileDisplaySettings.defaultUnreadIndicatorLeftShift, - selectWorkspace: { id in - selectedWorkspaceID = id - if reorderEnabled { - fixtureRoute = FixtureWorkspaceRoute(id: id) - } - }, - createWorkspace: {}, - macSelection: $macSelection, - refresh: { - await MainActor.run { - let current = workspacesBinding.wrappedValue - workspacesBinding.wrappedValue = Array(current.dropFirst()) + Array(current.prefix(1)) - refreshGenerationBinding.wrappedValue += 1 - } - }, - renameWorkspace: reorderEnabled ? { id, newName in - if let index = model.workspaces.firstIndex(where: { $0.id == id }) { - model.workspaces[index].name = newName - } - } : nil, - setPinned: reorderEnabled ? { id, pinned in - if let index = model.workspaces.firstIndex(where: { $0.id == id }) { - model.workspaces[index].isPinned = pinned - } - } : nil, - setUnread: reorderEnabled ? { id, unread in - if let index = model.workspaces.firstIndex(where: { $0.id == id }) { - model.workspaces[index].hasUnread = unread + MobilePrimaryWorkspaceSearchHost( + searchCoordinator: primarySearchCoordinator + ) { searchText in + workspaceListFixture(searchText: searchText) + } + .navigationDestination(item: $fixtureRoute) { route in + VStack(spacing: 12) { + Text( + model.workspaces.first(where: { $0.id == route.id })?.name + ?? route.id.rawValue + ) + .font(.title2) + Text("Fixture workspace detail") + .foregroundStyle(.secondary) + } + .accessibilityIdentifier("FixtureWorkspaceDetail") + .toolbarVisibility(.hidden, for: .tabBar, .bottomBar) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + WorkspaceBackButton(unreadCount: 0) { + fixtureRoute = nil } - } : nil, - closeWorkspace: reorderEnabled ? { id in - model.workspaces.removeAll { $0.id == id } - } : nil, - moveWorkspace: reorderEnabled ? { id, groupID, beforeWorkspaceID, movesGroup in - model.workspaces = model.workspaces.applyingWorkspaceMoveIntent( - MobileWorkspaceMoveIntent( - groupID: groupID, - beforeWorkspaceID: beforeWorkspaceID, - movesGroup: movesGroup - ), - movedWorkspaceID: id, - groups: groups - ) - return true - } : nil, - searchText: searchText - ) - .navigationDestination(item: $fixtureRoute) { route in - VStack(spacing: 12) { - Text( - model.workspaces.first(where: { $0.id == route.id })?.name - ?? route.id.rawValue - ) - .font(.title2) - Text("Fixture workspace detail") - .foregroundStyle(.secondary) } - .accessibilityIdentifier("FixtureWorkspaceDetail") } } } + .onAppear { + consumePendingSearchFixtureNavigation() + } + .onChange(of: pendingSearchFixtureRoute) { _, _ in + consumePendingSearchFixtureNavigation() + } .overlay(alignment: .bottomTrailing) { if scrollMetricsEnabled { WorkspaceListScrollMetricsProbe(runsSweep: scrollSweepEnabled) @@ -342,26 +371,59 @@ public struct WorkspaceListLayoutPreviewView: View { .accessibilityHidden(true) } } + if showsTabScaffold { - TabView { - Tab("Workspaces", systemImage: "rectangle.stack") { - workspaceListStack - } - Tab("Notifications", systemImage: "bell") { - Text("Notification feed fixture") - .foregroundStyle(.secondary) + MobilePrimaryTabScaffold( + selection: $selectedPrimaryTab, + searchCoordinator: primarySearchCoordinator, + notificationUnreadCount: 0 + ) { + workspaceListStack + } notifications: { + Text("Notification feed fixture") + .foregroundStyle(.secondary) + } workspaceSearch: { + NavigationStack { + MobilePrimaryWorkspaceSearchContentHost( + searchCoordinator: primarySearchCoordinator + ) { searchText in + workspaceListFixture(searchText: searchText) + } } + } notificationSearch: { + Text("Notification feed fixture") + .foregroundStyle(.secondary) } } else { workspaceListStack } } } + .onChange(of: primarySearchCoordinator.isPresented) { _, isPresented in + guard !isPresented else { return } + consumePendingSearchFixtureNavigation() + } .overlay(alignment: .topLeading) { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("MobileWorkspaceListRefreshGeneration-\(refreshGeneration)") + ZStack(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityIdentifier("MobileWorkspaceListRefreshGeneration-\(refreshGeneration)") + if showsTabScaffold { + Button { + performPreviewRefresh() + } label: { + Rectangle() + .fill(Color.primary.opacity(0.01)) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityIdentifier("MobileWorkspaceListPreviewRefresh") + .accessibilityAction { + performPreviewRefresh() + } + } + } } .task { // Fire a REAL local notification (not a drawn banner) so the system @@ -373,6 +435,37 @@ public struct WorkspaceListLayoutPreviewView: View { await model.runLiveUpdates() } } + + private func selectFixtureWorkspace(_ id: MobileWorkspacePreview.ID) { + selectedWorkspaceID = id + let route = FixtureWorkspaceRoute(id: id) + if showsTabScaffold, + selectedPrimaryTab == .search || primarySearchCoordinator.isPresented { + pendingSearchFixtureRoute = route + transitionPrimaryTab(to: .workspaces) + } else { + fixtureRoute = route + } + } + + private func consumePendingSearchFixtureNavigation() { + guard !primarySearchCoordinator.isPresented, + selectedPrimaryTab == .workspaces, + let route = pendingSearchFixtureRoute else { return } + pendingSearchFixtureRoute = nil + fixtureRoute = route + } + + @discardableResult + private func transitionPrimaryTab(to tab: MobilePrimaryTab) -> Bool { + let previousTab = selectedPrimaryTab + if (selectedPrimaryTab == .search || primarySearchCoordinator.isPresented), + tab.searchScope != nil { + primarySearchCoordinator.deactivateCurrentSearch() + } + selectedPrimaryTab = tab + return previousTab != tab + } } /// Pairing rows for the store-free workspace-list fixture. Lives in this diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListSearchHost.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListSearchHost.swift index 15268267ba6f..01151abec1ce 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListSearchHost.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListSearchHost.swift @@ -1,30 +1,71 @@ +import CmuxMobileSupport import SwiftUI -/// Owns the native search controller above workspace snapshots that are replaced -/// during refresh. Stable query and focus ownership survive live row snapshots, -/// while an explicit navigation-bar drawer keeps search at the top on iOS 26. +/// Owns workspace search state above list snapshots that are replaced during +/// refresh. The shell owns the query so it survives those replacements. @MainActor struct WorkspaceListSearchHost: View { - @State private var searchText = "" + @Binding private var searchText: String @FocusState private var searchIsFocused: Bool + private let taskComposerAction: (() -> Void)? private let content: (String) -> Content - init(@ViewBuilder content: @escaping (String) -> Content) { + init( + searchText: Binding, + taskComposerAction: (() -> Void)? = nil, + @ViewBuilder content: @escaping (String) -> Content + ) { + _searchText = searchText + self.taskComposerAction = taskComposerAction self.content = content } var body: some View { #if os(iOS) - content(searchText) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always) - ) - .searchFocused($searchIsFocused) + iOSContent #else content(searchText) .searchable(text: $searchText) .searchFocused($searchIsFocused) #endif } + + #if os(iOS) + @ViewBuilder + private var iOSContent: some View { + if #available(iOS 26.0, *) { + content(searchText) + .toolbar { + if let taskComposerAction { + ToolbarSpacer(.flexible, placement: .bottomBar) + ToolbarItem(placement: .bottomBar) { + Button(action: taskComposerAction) { + Image(systemName: "sparkles") + } + .accessibilityLabel( + L10n.string( + "mobile.taskComposer.button.accessibilityLabel", + defaultValue: "New Task" + ) + ) + .accessibilityHint( + L10n.string( + "mobile.taskComposer.button.accessibilityHint", + defaultValue: "Opens the task composer." + ) + ) + .accessibilityIdentifier("MobileTaskComposerButton") + } + } + } + } else { + content(searchText) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always) + ) + .searchFocused($searchIsFocused) + } + } + #endif } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 3b00b0c70c86..bb3aca3d9168 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -106,6 +106,9 @@ struct WorkspaceListView: View { var isInitialConnectionLoading = false var initialConnectionTimedOut = false var retryInitialConnection: (() -> Void)? + /// Shared across the normal workspace tab and its native search + /// presentation so filters compose with the active query. + let filterState: WorkspaceListFilterState /// The query is owned by ``WorkspaceListSearchHost`` so authoritative /// workspace refreshes cannot recreate the native search presentation. var searchText = "" @@ -114,9 +117,6 @@ struct WorkspaceListView: View { @State private var settingsPairingScannerHandoff = SettingsPairingScannerHandoff() @State private var showingDeviceTree = false @State private var changesSheetTarget: WorkspaceChangesSheetTarget? = nil - /// The active row filter (All / Unread), shared-model state behind the - /// toolbar ``WorkspaceListFilterMenu``. Session-transient like a search. - @State var filter: MobileWorkspaceListFilter = .all @State private var macTitlePickerSwitchTask: Task? @State private var macTitlePickerSwitchIsCancellation = false @State private var macTitlePickerSwitchGeneration: UInt64 = 0 @@ -151,6 +151,11 @@ struct WorkspaceListView: View { /// queued moves computed against overruled predictions abort unsent. @State var workspaceMoveEpoch: UInt64 = 0 + var filter: MobileWorkspaceListFilter { + get { filterState.filter } + nonmutating set { filterState.filter = newValue } + } + var trimmedQuery: String { searchText.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 9429c2aa2a45..fe86f18b5cd8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -136,6 +136,7 @@ private struct WorkspaceShellRenderPresentation { let notificationFeedItems: [MobileNotificationFeedItem] let notificationUnreadCount: Int let notificationFeedStatus: MobileNotificationFeedStatus + let selectedNotificationFeedMacDeviceIDs: Set? let toolbarMachineSnapshots: WorkspaceMachineSnapshots let canCreateWorkspaceForSelection: Bool } @@ -158,6 +159,9 @@ struct WorkspaceShellView: View { #if os(iOS) @State private var selectedPrimaryTab: MobilePrimaryTab = .workspaces @State private var notificationNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var notificationSearchNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var pendingPrimarySearchWorkspaceNavigationID: MobileWorkspacePreview.ID? + @State private var pendingPrimarySearchNotificationNavigationID: MobileWorkspacePreview.ID? @State private var showingRootSettings = false @State private var settingsPairingScannerHandoff = SettingsPairingScannerHandoff() @State private var showingRootDeviceTree = false @@ -166,6 +170,9 @@ struct WorkspaceShellView: View { @State private var rootToolbarSelectionTask: Task? @State private var rootToolbarSelectionGeneration: UInt64 = 0 #endif + @State private var primarySearchCoordinator = MobilePrimarySearchCoordinator() + @State private var workspaceListFilterState = WorkspaceListFilterState() + @State private var notificationFeedProjection = NotificationFeedProjection() @State private var hasPresentedSplitDetail = false @State private var splitColumnVisibility: NavigationSplitViewVisibility = .automatic @State private var macSelection: WorkspaceMacSelection = .all @@ -214,6 +221,7 @@ struct WorkspaceShellView: View { GeometryReader { geometry in MobilePrimaryTabScaffold( selection: $selectedPrimaryTab, + searchCoordinator: primarySearchCoordinator, notificationUnreadCount: presentation.notificationUnreadCount ) { workspaceTabContent( @@ -224,7 +232,9 @@ struct WorkspaceShellView: View { NotificationFeedStoreView( store: store, items: presentation.notificationFeedItems, - status: presentation.notificationFeedStatus + status: presentation.notificationFeedStatus, + projection: notificationFeedProjection, + selectedMacDeviceIDs: presentation.selectedNotificationFeedMacDeviceIDs ) .toolbar { if notificationNavigationPath.isEmpty { @@ -238,11 +248,38 @@ struct WorkspaceShellView: View { canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection ) .toolbarVisibility(.hidden, for: .tabBar) - } + } + } + .onAppear { + consumePendingPrimarySearchNavigation(for: .notifications) } + .onChange(of: pendingPrimarySearchNotificationNavigationID) { _, _ in + consumePendingPrimarySearchNavigation(for: .notifications) + } + } workspaceSearch: { + workspaceSearchTabContent( + canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + ) + } notificationSearch: { + notificationSearchTabContent(presentation: presentation) + } + .background { + NotificationFeedSearchProjectionSync( + searchCoordinator: primarySearchCoordinator, + projection: notificationFeedProjection + ) } .environment(\.workspaceRootToolbarContentWidth, geometry.size.width) .environment(\.workspaceRootToolbarRenderContext, toolbarRenderContext) + .onChange(of: primarySearchCoordinator.isPresented) { _, isPresented in + guard !isPresented else { return } + consumePendingPrimarySearchNavigation(for: selectedPrimaryTab) + } + .onChange(of: selectedPrimaryTab) { oldValue, newValue in + if oldValue == .search, newValue != .search { + notificationSearchNavigationPath = [] + } + } .onChange(of: store.deeplinkWorkspaceNavigationRequest) { _, request in guard request != nil else { return } consumeDeeplinkNavigationRequestIfNeeded() @@ -254,6 +291,9 @@ struct WorkspaceShellView: View { .onChange(of: presentation.toolbarMachineSnapshots) { _, snapshots in updateRootToolbarMachineSnapshots(snapshots) } + .onChange(of: presentation.notificationFeedItems, initial: true) { _, items in + notificationFeedProjection.update(items: items) + } .sheet(isPresented: $showingRootSettings, onDismiss: { settingsPairingScannerHandoff.settingsDidDismiss(startScanner: showPairingScanner) }) { @@ -272,8 +312,9 @@ struct WorkspaceShellView: View { DeviceTreeView( store: store, selectWorkspace: { id in - selectedPrimaryTab = .workspaces - selectWorkspace(id) + transitionPrimaryTab(to: .workspaces) { + selectWorkspace(id) + } }, showAddDevice: showAddDevice ) @@ -288,11 +329,44 @@ struct WorkspaceShellView: View { } private func workspaceTabContent(canCreateWorkspaceForSelection: Bool) -> some View { + workspaceActionToastOverlay { + layoutContent(canCreateWorkspaceForSelection: canCreateWorkspaceForSelection) + } + } + + private func workspaceSearchTabContent(canCreateWorkspaceForSelection: Bool) -> some View { + workspaceActionToastOverlay { + NavigationStack { + MobilePrimaryWorkspaceSearchContentHost( + searchCoordinator: primarySearchCoordinator + ) { searchText in + workspaceList( + navigationStyle: .push, + searchText: searchText, + canCreateWorkspaceForSelection: canCreateWorkspaceForSelection, + showsNavigationToolbar: true, + selectWorkspaceAction: selectWorkspaceFromSearch, + createWorkspaceAction: createWorkspaceFromSearch, + createWorkspaceInGroupAction: createWorkspaceInGroupFromSearchClosure, + createWorkspaceGroupAction: createWorkspaceGroupFromSearchClosure + ) + } + .toolbar { + rootToolbarContent + } + } + } + } + + @ViewBuilder + private func workspaceActionToastOverlay( + @ViewBuilder content: () -> Content + ) -> some View { // With the Toasts beta flag on, failures surface through the app-wide // toast layer; the legacy bottom banner below only ever receives // content while the flag is off. ZStack(alignment: .bottom) { - layoutContent(canCreateWorkspaceForSelection: canCreateWorkspaceForSelection) + content() if let workspaceActionToast { WorkspaceActionToast( content: workspaceActionToast, @@ -307,6 +381,33 @@ struct WorkspaceShellView: View { } } + private func notificationSearchTabContent( + presentation: WorkspaceShellRenderPresentation + ) -> some View { + NavigationStack(path: $notificationSearchNavigationPath) { + NotificationFeedStoreView( + store: store, + items: presentation.notificationFeedItems, + status: presentation.notificationFeedStatus, + projection: notificationFeedProjection, + selectedMacDeviceIDs: presentation.selectedNotificationFeedMacDeviceIDs + ) + .toolbar { + if notificationSearchNavigationPath.isEmpty { + rootToolbarContent + } + } + .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in + workspaceDestination( + for: workspaceID, + createWorkspace: createWorkspaceInCompactStack, + canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + ) + .toolbarVisibility(.hidden, for: .tabBar) + } + } + } + private func layoutContent(canCreateWorkspaceForSelection: Bool) -> some View { Group { if usesCompactStack { @@ -349,7 +450,10 @@ struct WorkspaceShellView: View { private func stackLayout(canCreateWorkspaceForSelection: Bool) -> some View { NavigationStack(path: $compactNavigationPath) { - WorkspaceListSearchHost { searchText in + MobilePrimaryWorkspaceSearchHost( + searchCoordinator: primarySearchCoordinator, + taskComposerAction: taskComposerAction + ) { searchText in workspaceList( navigationStyle: .push, searchText: searchText, @@ -378,7 +482,7 @@ struct WorkspaceShellView: View { ) ) #if os(iOS) - .toolbarVisibility(.hidden, for: .tabBar) + .toolbarVisibility(.hidden, for: .tabBar, .bottomBar) #endif // Only on the pushed compact stack (where a back button // exists): replace the system back button with a custom one @@ -427,12 +531,28 @@ struct WorkspaceShellView: View { } .onAppear { autoOpenSelectedWorkspaceForSoakIfNeeded() + consumePendingPrimarySearchNavigation(for: .workspaces) + } + .onChange(of: pendingPrimarySearchWorkspaceNavigationID) { _, _ in + consumePendingPrimarySearchNavigation(for: .workspaces) } } + private func openTaskComposer() { + isTaskComposerPresented = true + } + + private var taskComposerAction: (() -> Void)? { + guard displaySettings.taskComposerEnabled else { return nil } + return openTaskComposer + } + private func splitLayout(canCreateWorkspaceForSelection: Bool) -> some View { NavigationSplitView(columnVisibility: $splitColumnVisibility) { - WorkspaceListSearchHost { searchText in + MobilePrimaryWorkspaceSearchHost( + searchCoordinator: primarySearchCoordinator, + taskComposerAction: taskComposerAction + ) { searchText in workspaceList( navigationStyle: .sidebar, searchText: searchText, @@ -468,9 +588,30 @@ struct WorkspaceShellView: View { private func workspaceList( navigationStyle: WorkspaceNavigationStyle, searchText: String, - canCreateWorkspaceForSelection: Bool + canCreateWorkspaceForSelection: Bool, + showsNavigationToolbar: Bool? = nil, + selectWorkspaceAction: ((MobileWorkspacePreview.ID) -> Void)? = nil, + createWorkspaceAction: (() -> Void)? = nil, + createWorkspaceInGroupAction: ((MobileWorkspaceGroupPreview.ID) -> Void)? = nil, + createWorkspaceGroupAction: (() -> Void)? = nil ) -> some View { - WorkspaceListView( + let resolvedSelectWorkspace = selectWorkspaceAction ?? selectWorkspace + let resolvedCreateWorkspace = createWorkspaceAction ?? ( + navigationStyle == .push + ? createWorkspaceInCompactStack + : createWorkspaceIfConnected + ) + let resolvedCreateWorkspaceInGroup = createWorkspaceInGroupAction ?? ( + navigationStyle == .push + ? createWorkspaceInGroupInCompactStackClosure + : createWorkspaceInGroupIfConnectedClosure + ) + let resolvedCreateWorkspaceGroup = createWorkspaceGroupAction ?? ( + navigationStyle == .push + ? createWorkspaceGroupInCompactStackClosure + : createWorkspaceGroupIfConnectedClosure + ) + return WorkspaceListView( workspaces: store.workspaces, groups: store.workspaceGroups, selectedWorkspaceID: store.selectedWorkspaceID, @@ -482,21 +623,16 @@ struct WorkspaceShellView: View { macUpdateHintMacName: store.connectedHostName, dismissMacUpdateHint: { store.dismissMacUpdateHint() }, navigationStyle: navigationStyle, - showsNavigationToolbar: navigationStyle != .push || compactNavigationPath.isEmpty, + showsNavigationToolbar: showsNavigationToolbar + ?? (navigationStyle != .push || compactNavigationPath.isEmpty), usesExternalSharedToolbar: true, wrapWorkspaceTitles: displaySettings.wrapWorkspaceTitles, previewLineLimit: displaySettings.workspacePreviewLineCount, unreadIndicatorLeftShift: displaySettings.unreadIndicatorLeftShift, - selectWorkspace: selectWorkspace, - createWorkspace: navigationStyle == .push - ? createWorkspaceInCompactStack - : createWorkspaceIfConnected, - createWorkspaceInGroup: navigationStyle == .push - ? createWorkspaceInGroupInCompactStackClosure - : createWorkspaceInGroupIfConnectedClosure, - createWorkspaceGroup: navigationStyle == .push - ? createWorkspaceGroupInCompactStackClosure - : createWorkspaceGroupIfConnectedClosure, + selectWorkspace: resolvedSelectWorkspace, + createWorkspace: resolvedCreateWorkspace, + createWorkspaceInGroup: resolvedCreateWorkspaceInGroup, + createWorkspaceGroup: resolvedCreateWorkspaceGroup, canCreateWorkspace: canCreateWorkspaceForSelection, macSelection: $macSelection, switchMac: { macDeviceID, instanceTag in @@ -526,6 +662,7 @@ struct WorkspaceShellView: View { isInitialConnectionLoading: isInitialConnectionLoading, initialConnectionTimedOut: initialConnectionTimedOut, retryInitialConnection: retryInitialConnection, + filterState: workspaceListFilterState, searchText: searchText ) } @@ -545,10 +682,8 @@ struct WorkspaceShellView: View { private var workspaceShellRenderPresentation: WorkspaceShellRenderPresentation { let scope = macSelectionScope let selectedMachineIDs = scope.selectedMachineIDs - let allNotificationFeedItems = store.notificationFeedItems - var visibleNotificationFeedItems: [MobileNotificationFeedItem] = [] - visibleNotificationFeedItems.reserveCapacity(allNotificationFeedItems.count) - var notificationUnreadCount = 0 + let visibleNotificationFeedItems = store.notificationFeedItems(scopedTo: selectedMachineIDs) + let notificationUnreadCount = visibleNotificationFeedItems.lazy.filter { !$0.isRead }.count var names: [String: String] = [:] for workspace in store.workspaces { if let id = workspace.macDeviceID, @@ -557,15 +692,10 @@ struct WorkspaceShellView: View { names[id] = name } } - for item in allNotificationFeedItems { + for item in store.notificationFeedItems { if !item.macDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { names[item.macDeviceID] = item.macDisplayName } - guard selectedMachineIDs?.contains(item.macDeviceID) ?? true else { continue } - visibleNotificationFeedItems.append(item) - if !item.isRead { - notificationUnreadCount += 1 - } } for device in store.deviceTreeDevices { if let name = device.displayName, !name.isEmpty { @@ -594,6 +724,7 @@ struct WorkspaceShellView: View { notificationFeedItems: visibleNotificationFeedItems, notificationUnreadCount: notificationUnreadCount, notificationFeedStatus: store.notificationFeedStatus(scopedTo: selectedMachineIDs), + selectedNotificationFeedMacDeviceIDs: selectedMachineIDs, toolbarMachineSnapshots: toolbarMachineSnapshots, canCreateWorkspaceForSelection: scope.canCreateWorkspace( base: canCreateWorkspace, @@ -673,14 +804,20 @@ struct WorkspaceShellView: View { } } + private var showsTaskComposerButtonOverlay: Bool { + guard displaySettings.taskComposerEnabled else { return false } + if #available(iOS 26.0, *) { + return false + } + return true + } + @ViewBuilder private var taskComposerButtonOverlay: some View { - if displaySettings.taskComposerEnabled { - TaskComposerButton { - isTaskComposerPresented = true - } - .padding(.trailing, 20) - .padding(.bottom, 6) + if showsTaskComposerButtonOverlay { + TaskComposerButton(action: openTaskComposer) + .padding(.trailing, 20) + .padding(.bottom, 6) } } #endif @@ -694,21 +831,82 @@ struct WorkspaceShellView: View { guard let workspaceID = store.consumeDeeplinkWorkspaceNavigationRequest() else { return } #if os(iOS) if request.origin == .notificationFeed { - selectedPrimaryTab = .notifications - if notificationNavigationPath.last != workspaceID { - notificationNavigationPath = [workspaceID] + switch primarySearchCoordinator.notificationFeedNavigationRoute( + selectedTab: selectedPrimaryTab + ) { + case .mountedNotificationSearch: + if notificationSearchNavigationPath.last != workspaceID { + notificationSearchNavigationPath = [workspaceID] + } + case .notificationTabAfterSearchDismissal: + pendingPrimarySearchNotificationNavigationID = workspaceID + transitionPrimaryTab(to: .notifications) + case .mountedNotificationTab: + transitionPrimaryTab(to: .notifications) + if notificationNavigationPath.last != workspaceID { + notificationNavigationPath = [workspaceID] + } } return } - selectedPrimaryTab = .workspaces + if selectedPrimaryTab == .search || primarySearchCoordinator.isPresented { + pendingPrimarySearchWorkspaceNavigationID = workspaceID + transitionPrimaryTab(to: .workspaces) + } else { + transitionPrimaryTab(to: .workspaces) { + guard usesCompactStack, compactNavigationPath.last != workspaceID else { return } + compactNavigationPath = [workspaceID] + } + } #endif guard usesCompactStack else { return } - if compactNavigationPath.last != workspaceID { - compactNavigationPath = [workspaceID] + } + + private func consumePendingPrimarySearchNavigation(for tab: MobilePrimaryTab) { + guard !primarySearchCoordinator.isPresented else { return } + switch tab { + case .workspaces: + guard let workspaceID = pendingPrimarySearchWorkspaceNavigationID else { return } + pendingPrimarySearchWorkspaceNavigationID = nil + selectWorkspaceImmediately(workspaceID) + case .notifications: + guard let workspaceID = pendingPrimarySearchNotificationNavigationID else { return } + pendingPrimarySearchNotificationNavigationID = nil + if notificationNavigationPath.last != workspaceID { + notificationNavigationPath = [workspaceID] + } + case .search: + break } } + @discardableResult + private func transitionPrimaryTab( + to tab: MobilePrimaryTab, + beforeSelection: () -> Void = {} + ) -> Bool { + let previousTab = selectedPrimaryTab + if (selectedPrimaryTab == .search || primarySearchCoordinator.isPresented), + tab.searchScope != nil { + primarySearchCoordinator.deactivateCurrentSearch() + } + beforeSelection() + selectedPrimaryTab = tab + return previousTab != tab + } + private func selectWorkspace(_ id: MobileWorkspacePreview.ID) { + #if os(iOS) + if selectedPrimaryTab == .search || primarySearchCoordinator.isPresented { + pendingPrimarySearchWorkspaceNavigationID = id + transitionPrimaryTab(to: .workspaces) + return + } + #endif + selectWorkspaceImmediately(id) + } + + private func selectWorkspaceImmediately(_ id: MobileWorkspacePreview.ID) { pendingCompactCreateNavigationWorkspaceIDs = nil store.selectedWorkspaceID = id if usesCompactStack, compactNavigationPath.last != id { @@ -716,6 +914,43 @@ struct WorkspaceShellView: View { } } + private func selectWorkspaceFromSearch(_ id: MobileWorkspacePreview.ID) { + pendingPrimarySearchWorkspaceNavigationID = id + transitionPrimaryTab(to: .workspaces) + } + + private func createWorkspaceFromSearch() { + transitionPrimaryTab(to: .workspaces) { + if usesCompactStack { + createWorkspaceInCompactStack() + } else { + createWorkspaceIfConnected() + } + } + } + + private var createWorkspaceInGroupFromSearchClosure: ((MobileWorkspaceGroupPreview.ID) -> Void)? { + guard store.supportsWorkspaceCreateInGroup else { return nil } + return { groupID in + transitionPrimaryTab(to: .workspaces) { + if usesCompactStack { + createWorkspaceInCompactStack(inGroup: groupID) + } else { + createWorkspaceIfConnected(inGroup: groupID) + } + } + } + } + + private var createWorkspaceGroupFromSearchClosure: (() -> Void)? { + guard store.supportsWorkspaceGroupCreate else { return nil } + return { + transitionPrimaryTab(to: .workspaces) { + createWorkspaceGroupIfConnected() + } + } + } + /// Pull-to-refresh closure for the workspace list. Awaits the store's real /// `mobile.workspace.list` re-sync so the system refresh spinner reflects the /// actual round-trip. Captures `store` as a local so the closure (not a store diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePrimarySearchCoordinatorTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePrimarySearchCoordinatorTests.swift new file mode 100644 index 000000000000..01b56a21caae --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePrimarySearchCoordinatorTests.swift @@ -0,0 +1,304 @@ +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct MobilePrimarySearchCoordinatorTests { + @Test func activePresentedSearchAcceptsExplicitClear() { + let coordinator = MobilePrimarySearchCoordinator() + coordinator.synchronizeSelection(.workspaces) + coordinator.setPresentation(true) + coordinator.updateNativeSearchText( + "query", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + coordinator.updateNativeSearchText( + "", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + #expect(coordinator.workspaces == "") + #expect(coordinator.activeNativeSearchText() == "") + #expect(coordinator.searchDestinationText(for: .workspaces) == "") + } + + @Test func activePresentedSearchKeepsNonEmptyEditInDraftUntilSubmit() { + let coordinator = MobilePrimarySearchCoordinator() + coordinator.synchronizeSelection(.workspaces) + coordinator.setPresentation(true) + + coordinator.updateNativeSearchText( + "release", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + #expect(coordinator.workspaces == "") + #expect(coordinator.activeNativeSearchText() == "release") + #expect(coordinator.searchDestinationText(for: .workspaces) == "release") + + #expect(coordinator.commitSubmit() == .workspaces) + #expect(coordinator.workspaces == "release") + #expect(coordinator.activeNativeSearchText() == "release") + } + + @Test func dismissedSearchCommitsNativeDraft() { + let coordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + coordinator.updateNativeSearchText( + "alerts", + for: .notifications, + activationGeneration: coordinator.activationGeneration + ) + + coordinator.setPresentation(false) + + #expect(coordinator.notifications == "alerts") + #expect(coordinator.activeNativeSearchText() == "alerts") + #expect(coordinator.searchDestinationText(for: .notifications) == "alerts") + } + + @Test func nativeNotificationSearchBoundsDisplayedDraftAndCommittedQuery() { + let coordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + + coordinator.updateNativeSearchText( + "target" + String(repeating: "\u{0301}", count: 10_000), + for: .notifications, + activationGeneration: coordinator.activationGeneration + ) + + let displayedQuery = coordinator.activeNativeSearchText() + #expect(displayedQuery.unicodeScalars.count <= MobileSearchQueryBounds().maxUnicodeScalars) + #expect(displayedQuery.utf8.count <= MobileSearchQueryBounds().maxUTF8Bytes) + #expect(coordinator.searchDestinationText(for: .notifications) == displayedQuery) + + #expect(coordinator.commitSubmit() == .notifications) + #expect(coordinator.notifications == displayedQuery) + #expect(coordinator.activeNativeSearchText() == displayedQuery) + } + + @Test func nativeSearchPreservesTrailingSpaceWhileEditing() { + let coordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + + coordinator.updateNativeSearchText( + "Docs ", + for: .notifications, + activationGeneration: coordinator.activationGeneration + ) + #expect(coordinator.activeNativeSearchText() == "Docs ") + #expect(coordinator.searchDestinationText(for: .notifications) == "Docs ") + + coordinator.updateNativeSearchText( + "Docs m", + for: .notifications, + activationGeneration: coordinator.activationGeneration + ) + #expect(coordinator.activeNativeSearchText() == "Docs m") + + #expect(coordinator.commitSubmit() == .notifications) + #expect(coordinator.notifications == "Docs m") + } + + @Test func directCommittedSearchBindingUsesSharedBounds() { + let coordinator = MobilePrimarySearchCoordinator() + + coordinator.workspaces = "workspace" + String(repeating: "\u{0301}", count: 10_000) + + #expect(coordinator.workspaces.unicodeScalars.count <= MobileSearchQueryBounds().maxUnicodeScalars) + #expect(coordinator.workspaces.utf8.count <= MobileSearchQueryBounds().maxUTF8Bytes) + #expect(coordinator.activeNativeSearchText() == coordinator.workspaces) + } + + @Test func directCommittedSearchBindingPreservesTrailingSpaceWhileEditing() { + let coordinator = MobilePrimarySearchCoordinator() + + coordinator.workspaces = "Docs " + #expect(coordinator.workspaces == "Docs ") + #expect(coordinator.activeNativeSearchText() == "Docs ") + + coordinator.workspaces = "Docs m" + #expect(coordinator.workspaces == "Docs m") + #expect(coordinator.activeNativeSearchText() == "Docs m") + } + + @Test func deactivatingSearchRejectsPlatformCleanupWrite() { + let coordinator = MobilePrimarySearchCoordinator() + coordinator.synchronizeSelection(.workspaces) + coordinator.setPresentation(true) + coordinator.updateNativeSearchText( + "persisted", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + let owningTab = coordinator.commitSubmit() + coordinator.updateNativeSearchText( + "", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + #expect(owningTab == .workspaces) + #expect(coordinator.workspaces == "persisted") + #expect(coordinator.activeNativeSearchText() == "persisted") + } + + @Test func inactiveSearchRejectsLateNativeWrite() { + let coordinator = MobilePrimarySearchCoordinator() + + coordinator.updateNativeSearchText( + "late", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + #expect(coordinator.workspaces == "") + #expect(coordinator.activeNativeSearchText() == "") + } + + @Test func initialFalseLifecycleCallbackDoesNotEndPresentedSearch() { + let coordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + let activation = coordinator.activationGeneration + + coordinator.updateLifecycle(scope: .notifications, isSearching: false) + coordinator.updateNativeSearchText( + "alerts", + for: .notifications, + activationGeneration: activation + ) + + #expect(coordinator.notifications == "") + #expect(coordinator.activeNativeSearchText() == "alerts") + #expect(coordinator.searchDestinationText(for: .notifications) == "alerts") + } + + @Test func falseLifecycleCallbackAfterObservedSearchDismissesAndCommitsDraft() { + let coordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + coordinator.updateLifecycle(scope: .notifications, isSearching: true) + let activation = coordinator.activationGeneration + coordinator.updateNativeSearchText( + "alerts", + for: .notifications, + activationGeneration: activation + ) + + coordinator.updateLifecycle(scope: .notifications, isSearching: false) + coordinator.updateNativeSearchText( + "", + for: .notifications, + activationGeneration: activation + ) + + #expect(coordinator.notifications == "alerts") + #expect(coordinator.activeNativeSearchText() == "alerts") + #expect(coordinator.searchDestinationText(for: .notifications) == "alerts") + } + + @Test func notificationDeepLinkUsesNotificationSearchOnlyWhenThatSearchScopeIsMounted() { + let coordinator = MobilePrimarySearchCoordinator() + + #expect( + coordinator.notificationFeedNavigationRoute(selectedTab: .search) + == .notificationTabAfterSearchDismissal + ) + + coordinator.synchronizeSelection(.notifications) + + #expect( + coordinator.notificationFeedNavigationRoute(selectedTab: .search) + == .mountedNotificationSearch + ) + } + + @Test func notificationDeepLinkDismissesPresentedSearchBeforePushingNotifications() { + let coordinator = MobilePrimarySearchCoordinator() + coordinator.synchronizeSelection(.workspaces) + coordinator.setPresentation(true) + + #expect( + coordinator.notificationFeedNavigationRoute(selectedTab: .workspaces) + == .notificationTabAfterSearchDismissal + ) + + coordinator.synchronizeSelection(.notifications) + + #expect( + coordinator.notificationFeedNavigationRoute(selectedTab: .notifications) + == .notificationTabAfterSearchDismissal + ) + } + + @Test func notificationDeepLinkUsesMountedNotificationsStackWhenSearchIsInactive() { + let coordinator = MobilePrimarySearchCoordinator(initialScope: .notifications) + + #expect( + coordinator.notificationFeedNavigationRoute(selectedTab: .notifications) + == .mountedNotificationTab + ) + } + + @Test func otherScopeRejectsNativeWrite() { + let coordinator = MobilePrimarySearchCoordinator() + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + + coordinator.updateNativeSearchText( + "workspace leak", + for: .workspaces, + activationGeneration: coordinator.activationGeneration + ) + + #expect(coordinator.workspaces == "") + #expect(coordinator.notifications == "") + #expect(coordinator.activeNativeSearchText() == "") + } + + @Test func staleCleanupFromPriorActivationDoesNotEraseReopenedSearch() { + let coordinator = MobilePrimarySearchCoordinator() + coordinator.synchronizeSelection(.workspaces) + coordinator.setPresentation(true) + let firstActivation = coordinator.activationGeneration + coordinator.updateNativeSearchText( + "docs", + for: .workspaces, + activationGeneration: firstActivation + ) + _ = coordinator.commitSubmit() + + coordinator.synchronizeSelection(.notifications) + coordinator.setPresentation(true) + coordinator.updateNativeSearchText( + "alerts", + for: .notifications, + activationGeneration: coordinator.activationGeneration + ) + coordinator.updateNativeSearchText( + "", + for: .workspaces, + activationGeneration: firstActivation + ) + coordinator.updateNativeSearchText( + "", + for: .notifications, + activationGeneration: firstActivation + ) + + #expect(coordinator.workspaces == "docs") + #expect(coordinator.notifications == "") + #expect(coordinator.activeNativeSearchText() == "alerts") + #expect(coordinator.searchDestinationText(for: .notifications) == "alerts") + #expect(coordinator.commitSubmit() == .notifications) + #expect(coordinator.notifications == "alerts") + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/NotificationFeedProjectionTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/NotificationFeedProjectionTests.swift index b0c343b2c409..f1074dd4e8af 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/NotificationFeedProjectionTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/NotificationFeedProjectionTests.swift @@ -4,17 +4,18 @@ import Testing @testable import CmuxMobileShellUI @Suite struct NotificationFeedProjectionTests { - @Test @MainActor func groupsNewestFirstAcrossTodayAndYesterday() throws { + @Test @MainActor func groupsNewestFirstAcrossTodayAndYesterday() async throws { let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) projection.update(items: [ - item(id: "yesterday", createdAt: try #require(isoDate("2026-07-14T20:00:00Z")), isRead: false), - item(id: "today-older", createdAt: try #require(isoDate("2026-07-15T08:00:00Z")), isRead: true), item(id: "today-newer", createdAt: try #require(isoDate("2026-07-15T17:00:00Z")), isRead: false), + item(id: "today-older", createdAt: try #require(isoDate("2026-07-15T08:00:00Z")), isRead: true), + item(id: "yesterday", createdAt: try #require(isoDate("2026-07-14T20:00:00Z")), isRead: false), ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() #expect(projection.sections.map(\.kind) == [.today, .yesterday]) #expect(projection.sections[0].items.map(\.notificationID) == ["today-newer", "today-older"]) @@ -23,7 +24,7 @@ import Testing #expect(projection.sourceUnreadCount == 2) } - @Test @MainActor func unreadFilterPreservesChronologyAndStableItems() throws { + @Test @MainActor func unreadFilterPreservesChronologyAndStableItems() async throws { let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) @@ -32,8 +33,10 @@ import Testing item(id: "read", createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), isRead: true), item(id: "unread", createdAt: try #require(isoDate("2026-07-15T17:00:00Z")), isRead: false), ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() projection.filter = .unread + await projection.waitForPendingRebuild() #expect(projection.sections.count == 1) #expect(projection.sections[0].items.map(\.notificationID) == ["unread"]) @@ -41,6 +44,261 @@ import Testing #expect(projection.sourceUnreadCount == 1) } + @Test @MainActor func filterChangeRetainsPriorRowsUntilAsyncRebuildPublishes() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item(id: "read", createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), isRead: true), + item(id: "unread", createdAt: try #require(isoDate("2026-07-15T17:00:00Z")), isRead: false), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["read", "unread"]) + + projection.filter = .unread + + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["read", "unread"]) + #expect(projection.isSourceRebuilding) + #expect(!projection.hasStaleSourceSections) + + await projection.waitForPendingRebuild() + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["unread"]) + #expect(!projection.isSourceRebuilding) + #expect(!projection.hasStaleSourceSections) + } + + @Test @MainActor func searchMatchesNotificationContentAndComposesWithUnreadFilter() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item( + id: "approval", + createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), + isRead: false, + title: "Codex needs approval", + body: "Review the workspace changes" + ), + item( + id: "tests", + createdAt: try #require(isoDate("2026-07-15T17:00:00Z")), + isRead: true, + title: "Tests passed", + body: "Release is ready" + ), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + + projection.searchText = "release" + await projection.waitForPendingRebuild() + + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["tests"]) + + projection.filter = .unread + await projection.waitForPendingRebuild() + + #expect(projection.sections.isEmpty) + #expect(projection.sourceItemCount == 2) + #expect(projection.sourceUnreadCount == 1) + } + + @Test @MainActor func searchChangeRetainsPriorRowsUntilAsyncRebuildPublishes() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item( + id: "approval", + createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), + isRead: false, + title: "Codex needs approval" + ), + item( + id: "tests", + createdAt: try #require(isoDate("2026-07-15T17:00:00Z")), + isRead: false, + title: "Tests passed" + ), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["approval", "tests"]) + + projection.searchText = "tests" + + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["approval", "tests"]) + #expect(projection.isSourceRebuilding) + #expect(!projection.hasStaleSourceSections) + + await projection.waitForPendingRebuild() + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["tests"]) + #expect(!projection.isSourceRebuilding) + #expect(!projection.hasStaleSourceSections) + } + + @Test @MainActor func searchMatchesMetadataAfterLongBody() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item( + id: "metadata", + createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), + isRead: false, + title: "Long notification", + body: String(repeating: "noise ", count: 2_000), + workspaceTitle: "Workspace Search Target", + surfaceTitle: "Agent Pane", + macDisplayName: "Studio" + ), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + + projection.searchText = "search target" + await projection.waitForPendingRebuild() + + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["metadata"]) + } + + @Test @MainActor func rapidSearchPublishesOnlyTheLatestProjection() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item( + id: "first", + createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), + isRead: false, + title: "First result" + ), + item( + id: "latest", + createdAt: try #require(isoDate("2026-07-15T17:00:00Z")), + isRead: false, + title: "Latest result" + ), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + + projection.searchText = "first" + projection.searchText = "latest" + await projection.waitForPendingRebuild() + + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["latest"]) + } + + @Test @MainActor func searchTextIsBoundedByScalarsAndBytesBeforeRebuild() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item( + id: "target", + createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), + isRead: false, + title: "Target" + ), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + + projection.searchText = "target" + String(repeating: "\u{0301}", count: 10_000) + + #expect(projection.searchText.unicodeScalars.count <= notificationFeedProjectionMaxSearchQueryUnicodeScalars) + #expect(projection.searchText.utf8.count <= notificationFeedProjectionMaxSearchQueryUTF8Bytes) + await projection.waitForPendingRebuild() + #expect(!projection.isSourceRebuilding) + } + + @Test @MainActor func searchTextPreservesTrailingSpaceWhileFilteringWithTrimmedQuery() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + projection.update(items: [ + item( + id: "docs", + createdAt: try #require(isoDate("2026-07-15T17:30:00Z")), + isRead: false, + title: "Docs" + ), + ], referenceDate: referenceDate) + await projection.waitForPendingRebuild() + + projection.searchText = "Docs " + await projection.waitForPendingRebuild() + + #expect(projection.searchText == "Docs ") + #expect(projection.sections.flatMap(\.items).map(\.notificationID) == ["docs"]) + } + + @Test @MainActor func sourceUpdateCapsInputBeforeSearchWork() async throws { + let referenceDate = try #require(isoDate("2026-07-15T18:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let projection = NotificationFeedProjection(referenceDate: referenceDate, calendar: calendar) + let cap = notificationFeedProjectionMaxSourceItemCount + let total = cap + 25 + let items = (0.. MobileNotificationFeedItem { + private func item( + id: String, + createdAt: Date, + isRead: Bool, + title: String? = nil, + body: String = "Body", + workspaceTitle: String = "Workspace", + surfaceTitle: String = "Terminal", + macDisplayName: String = "Mac" + ) -> MobileNotificationFeedItem { MobileNotificationFeedItem( macDeviceID: id == "yesterday" ? "mac-b" : "mac-a", notificationID: id, - macDisplayName: "Mac", + macDisplayName: macDisplayName, remoteWorkspaceID: "workspace", remoteSurfaceID: "surface", - title: id, - body: "Body", + title: title ?? id, + body: body, createdAt: createdAt, isRead: isRead, - workspaceTitle: "Workspace", - surfaceTitle: "Terminal", + workspaceTitle: workspaceTitle, + surfaceTitle: surfaceTitle, connectionStatus: .connected ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift index 173ab1c952e0..1478aec12af9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift @@ -81,7 +81,8 @@ import Testing wrapWorkspaceTitles: false, selectWorkspace: { _ in }, createWorkspace: {}, - macSelection: binding(initialValue: .all) + macSelection: binding(initialValue: .all), + filterState: WorkspaceListFilterState() ) #expect(view.connectionChrome == .macStatusRow) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListFilterStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListFilterStateTests.swift new file mode 100644 index 000000000000..549de1f4a608 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListFilterStateTests.swift @@ -0,0 +1,59 @@ +import CmuxMobileShellModel +import SwiftUI +import Testing +@testable import CmuxMobileShellUI + +@Suite struct WorkspaceListFilterStateTests { + @Test @MainActor func searchPresentationUsesTheNormalPresentationFilter() { + let readWorkspace = MobileWorkspacePreview( + id: "read", + macDeviceID: "mac", + name: "Read", + hasUnread: false, + terminals: [] + ) + let unreadWorkspace = MobileWorkspacePreview( + id: "unread", + macDeviceID: "mac", + name: "Unread", + hasUnread: true, + terminals: [] + ) + let filterState = WorkspaceListFilterState() + let normalPresentation = workspaceList( + workspaces: [readWorkspace, unreadWorkspace], + searchText: "", + filterState: filterState + ) + let searchPresentation = workspaceList( + workspaces: [readWorkspace, unreadWorkspace], + searchText: "read", + filterState: filterState + ) + + filterState.filter.readState = .unread + + #expect(normalPresentation.filteredWorkspaces.map(\.id) == [unreadWorkspace.id]) + #expect(searchPresentation.filteredWorkspaces.map(\.id) == [unreadWorkspace.id]) + } + + @MainActor private func workspaceList( + workspaces: [MobileWorkspacePreview], + searchText: String, + filterState: WorkspaceListFilterState + ) -> WorkspaceListView { + WorkspaceListView( + workspaces: workspaces, + selectedWorkspaceID: nil, + host: "Test Mac", + connectionStatus: .connected, + navigationStyle: .push, + wrapWorkspaceTitles: false, + selectWorkspace: { _ in }, + createWorkspace: {}, + macSelection: .constant(.all), + filterState: filterState, + searchText: searchText + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollUpdateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollUpdateTests.swift index d9bf0a0d0907..65792399ed8a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollUpdateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollUpdateTests.swift @@ -146,6 +146,9 @@ import UIKit previewLineLimit: 2, unreadIndicatorLeftShift: 0, connectionStatus: .connected, + workspaceChangesCapable: false, + workspaceChangeChipsByWorkspaceID: [:], + openWorkspaceChanges: nil, connectionRequiresReauth: false, connectionRecoveryFailed: false, isRecoveringConnection: false, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionTests.swift index 983f5ac32ac2..6cd7ac5bb9e7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionTests.swift @@ -969,6 +969,7 @@ import Testing switchMac: switchMac, cancelMacSwitch: cancelMacSwitch, store: store, + filterState: WorkspaceListFilterState(), searchText: searchText ) } diff --git a/Sources/MobileNotificationFeedWireItem.swift b/Sources/MobileNotificationFeedWireItem.swift new file mode 100644 index 000000000000..328def5afdec --- /dev/null +++ b/Sources/MobileNotificationFeedWireItem.swift @@ -0,0 +1,38 @@ +import Foundation + +nonisolated struct MobileNotificationFeedWireItem: Sendable { + let id: String + let workspaceID: String + let surfaceID: String? + let title: String + let subtitle: String + let body: String + let createdAt: Double + let isRead: Bool + let retargetsToLiveSurfaceOwner: Bool + let workspaceTitle: String? + let surfaceTitle: String? + + var foundationPayload: [String: Any] { + var payload: [String: Any] = [ + "id": id, + "workspace_id": workspaceID, + "title": title, + "subtitle": subtitle, + "body": body, + "created_at": createdAt, + "is_read": isRead, + "retargets_to_live_surface_owner": retargetsToLiveSurfaceOwner, + ] + if let surfaceID { + payload["surface_id"] = surfaceID + } + if let workspaceTitle { + payload["workspace_title"] = workspaceTitle + } + if let surfaceTitle { + payload["surface_title"] = surfaceTitle + } + return payload + } +} diff --git a/Sources/NotificationFeedHistoryInsertionChange.swift b/Sources/NotificationFeedHistoryInsertionChange.swift new file mode 100644 index 000000000000..5211ed0e7e39 --- /dev/null +++ b/Sources/NotificationFeedHistoryInsertionChange.swift @@ -0,0 +1,7 @@ +import Foundation + +nonisolated enum NotificationFeedHistoryInsertionChange: Sendable { + case none + case insertedNew(UUID) + case replacedExisting +} diff --git a/Sources/NotificationFeedHistoryMutation.swift b/Sources/NotificationFeedHistoryMutation.swift new file mode 100644 index 000000000000..fcbb10fb9418 --- /dev/null +++ b/Sources/NotificationFeedHistoryMutation.swift @@ -0,0 +1,12 @@ +import Foundation + +nonisolated enum NotificationFeedHistoryMutation: Sendable { + case record(NotificationFeedHistoryRecord, supersededIDs: Set) + case reconcileActive([NotificationFeedHistoryRecord]) + case markReadIDs(Set) + case markReadWorkspace(UUID) + case markReadSurface(tabId: UUID, surfaceId: UUID?) + case markAllRead + case markUnreadIDs(Set) + case rebindSurface(sourceTabId: UUID, destinationTabId: UUID, surfaceId: UUID) +} diff --git a/Sources/NotificationFeedHistoryMutationResult.swift b/Sources/NotificationFeedHistoryMutationResult.swift new file mode 100644 index 000000000000..9830581f0c7f --- /dev/null +++ b/Sources/NotificationFeedHistoryMutationResult.swift @@ -0,0 +1,6 @@ +import Foundation + +nonisolated struct NotificationFeedHistoryMutationResult: Sendable { + var changed = false + var marked = 0 +} diff --git a/Sources/NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift b/Sources/NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift new file mode 100644 index 000000000000..1af1781a08cf --- /dev/null +++ b/Sources/NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift @@ -0,0 +1,238 @@ +import Foundation + +nonisolated struct NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner: Sendable { + let maxRecordBytes: Int + private var depth = 0 + private var isInString = false + private var isEscapingString = false + private var isCapturingKey = false + private var keyOverflowed = false + private var keyBytes: [UInt8] = [] + private var isExpectingKey = false + private var isExpectingValue = false + private var currentKey: String? + private var didStartNotificationsArray = false + private var didFinishNotificationsArray = false + private var recordObjectDepth = 0 + private var recordBytes = Data() + private var recordOverflowed = false + private var recordIsInString = false + private var recordIsEscapingString = false + + private static let maxTopLevelKeyByteCount = 128 + + init(maxRecordBytes: Int) { + self.maxRecordBytes = max(0, maxRecordBytes) + } + + var topLevelKeyBufferByteCountForTesting: Int { + keyBytes.count + } + + mutating func consume( + _ data: Data, + onRecord: (Data) throws -> Bool + ) rethrows -> Bool { + guard !didFinishNotificationsArray else { return false } + for byte in data { + let shouldContinue: Bool + if didStartNotificationsArray { + shouldContinue = try consumeNotificationsArrayByte( + byte, + onRecord: onRecord + ) + } else { + shouldContinue = consumeTopLevelByte(byte) + } + guard shouldContinue else { return false } + } + return true + } + + private mutating func consumeTopLevelByte(_ byte: UInt8) -> Bool { + if isInString { + if isEscapingString { + isEscapingString = false + if isCapturingKey { + appendKeyByte(byte) + } + return true + } + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.backslash { + isEscapingString = true + if isCapturingKey { + appendKeyByte(byte) + } + return true + } + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.quote { + isInString = false + if isCapturingKey, !keyOverflowed { + currentKey = String(bytes: keyBytes, encoding: .utf8) + keyBytes.removeAll(keepingCapacity: true) + isCapturingKey = false + } else if isCapturingKey { + currentKey = nil + keyBytes.removeAll(keepingCapacity: false) + isCapturingKey = false + keyOverflowed = false + } + return true + } + if isCapturingKey { + appendKeyByte(byte) + } + return true + } + + guard !NotificationFeedHistoryTopLevelSnapshotHeaderScanner.isWhitespace(byte) else { + return true + } + + switch byte { + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.leftBrace: + if depth == 0 { + isExpectingKey = true + } + depth += 1 + isExpectingValue = false + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBrace, + NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBracket: + if depth > 0 { + depth -= 1 + } + if depth == 1 { + currentKey = nil + isExpectingValue = false + } + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.leftBracket: + if depth == 1, + isExpectingValue, + currentKey == "notifications" { + didStartNotificationsArray = true + currentKey = nil + isExpectingValue = false + isExpectingKey = false + return true + } + depth += 1 + isExpectingValue = false + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.comma: + if depth == 1 { + currentKey = nil + isExpectingKey = true + isExpectingValue = false + } + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.colon: + if depth == 1, currentKey != nil { + isExpectingKey = false + isExpectingValue = true + } + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.quote: + isInString = true + if depth == 1, isExpectingKey { + isCapturingKey = true + keyOverflowed = false + keyBytes.removeAll(keepingCapacity: true) + isExpectingKey = false + } else { + isCapturingKey = false + keyOverflowed = false + isExpectingValue = false + } + default: + if depth == 1, isExpectingValue { + isExpectingValue = false + } + } + return true + } + + private mutating func appendKeyByte(_ byte: UInt8) { + guard !keyOverflowed else { return } + guard keyBytes.count < Self.maxTopLevelKeyByteCount else { + keyOverflowed = true + keyBytes.removeAll(keepingCapacity: false) + return + } + keyBytes.append(byte) + } + + private mutating func consumeNotificationsArrayByte( + _ byte: UInt8, + onRecord: (Data) throws -> Bool + ) rethrows -> Bool { + guard recordObjectDepth == 0 else { + return try consumeRecordByte(byte, onRecord: onRecord) + } + + if NotificationFeedHistoryTopLevelSnapshotHeaderScanner.isWhitespace(byte) + || byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.comma { + return true + } + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBracket { + didFinishNotificationsArray = true + return false + } + guard byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.leftBrace else { + return true + } + return try consumeRecordByte(byte, onRecord: onRecord) + } + + private mutating func consumeRecordByte( + _ byte: UInt8, + onRecord: (Data) throws -> Bool + ) rethrows -> Bool { + appendRecordByte(byte) + + if recordIsInString { + if recordIsEscapingString { + recordIsEscapingString = false + return true + } + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.backslash { + recordIsEscapingString = true + return true + } + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.quote { + recordIsInString = false + } + return true + } + + switch byte { + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.quote: + recordIsInString = true + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.leftBrace: + recordObjectDepth += 1 + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBrace: + recordObjectDepth -= 1 + guard recordObjectDepth == 0 else { return true } + defer { resetRecord() } + guard !recordOverflowed else { return true } + return try onRecord(recordBytes) + default: + break + } + return true + } + + private mutating func appendRecordByte(_ byte: UInt8) { + guard !recordOverflowed else { return } + guard recordBytes.count < maxRecordBytes else { + recordOverflowed = true + recordBytes.removeAll(keepingCapacity: false) + return + } + recordBytes.append(byte) + } + + private mutating func resetRecord() { + recordObjectDepth = 0 + recordBytes.removeAll(keepingCapacity: true) + recordOverflowed = false + recordIsInString = false + recordIsEscapingString = false + } +} diff --git a/Sources/NotificationFeedHistoryOversizedSnapshotMigrationError.swift b/Sources/NotificationFeedHistoryOversizedSnapshotMigrationError.swift new file mode 100644 index 000000000000..409039d9ac62 --- /dev/null +++ b/Sources/NotificationFeedHistoryOversizedSnapshotMigrationError.swift @@ -0,0 +1,5 @@ +import Foundation + +nonisolated enum NotificationFeedHistoryOversizedSnapshotMigrationError: Error { + case replacementValidationFailed +} diff --git a/Sources/NotificationFeedHistoryOversizedSnapshotRecovery.swift b/Sources/NotificationFeedHistoryOversizedSnapshotRecovery.swift new file mode 100644 index 000000000000..fb81cd4e6f9a --- /dev/null +++ b/Sources/NotificationFeedHistoryOversizedSnapshotRecovery.swift @@ -0,0 +1,6 @@ +import Foundation + +nonisolated struct NotificationFeedHistoryOversizedSnapshotRecovery: Sendable { + let snapshot: NotificationFeedHistorySnapshot + let shouldRetainQuarantineBackup: Bool +} diff --git a/Sources/NotificationFeedHistoryPersistence.swift b/Sources/NotificationFeedHistoryPersistence.swift index 6a682eed8af1..fd12486cef17 100644 --- a/Sources/NotificationFeedHistoryPersistence.swift +++ b/Sources/NotificationFeedHistoryPersistence.swift @@ -19,9 +19,16 @@ nonisolated enum NotificationFeedHistoryLoadOutcome: Equatable, Sendable { /// work never runs on the main actor. Writes are serialized and stale revisions /// are rejected. actor NotificationFeedHistoryPersistence { + private static let oversizedSnapshotHeaderChunkByteCount = 64 * 1024 + private static let oversizedSnapshotRecordMigrationByteLimit = 8 * 1024 * 1024 + private static let defaultOversizedSnapshotMigrationScanByteLimit = 64 * 1024 * 1024 + private let fileURL: URL? private let fileManager: FileManager private let readRetentionLimit: Int + private let totalRetentionLimit: Int + private let maxSnapshotBytes: UInt64 + private let oversizedSnapshotMigrationScanByteLimit: Int private var lastPersistedRevision = 0 private var loadOutcome: NotificationFeedHistoryLoadOutcome? private var allowsWrites = true @@ -29,16 +36,33 @@ actor NotificationFeedHistoryPersistence { init( fileURL: URL?, fileManager: FileManager, - readRetentionLimit: Int = NotificationFeedHistoryStore.readRetentionLimit + readRetentionLimit: Int = NotificationFeedHistoryStore.readRetentionLimit, + totalRetentionLimit: Int = NotificationFeedHistoryStore.totalRetentionLimit, + maxSnapshotBytes: UInt64? = nil, + oversizedSnapshotMigrationScanByteLimit: Int? = nil ) { self.fileURL = fileURL self.fileManager = fileManager self.readRetentionLimit = max(0, readRetentionLimit) + self.totalRetentionLimit = max(0, totalRetentionLimit) + self.maxSnapshotBytes = maxSnapshotBytes ?? Self.defaultMaxSnapshotBytes( + totalRetentionLimit: self.totalRetentionLimit + ) + self.oversizedSnapshotMigrationScanByteLimit = max( + 0, + oversizedSnapshotMigrationScanByteLimit ?? Self.defaultOversizedSnapshotMigrationScanByteLimit + ) } func load() -> NotificationFeedHistoryLoadOutcome { if let loadOutcome { return loadOutcome } - guard let fileURL, fileManager.fileExists(atPath: fileURL.path) else { + guard let fileURL else { + let outcome = NotificationFeedHistoryLoadOutcome.missing + loadOutcome = outcome + return outcome + } + restoreNewestQuarantineBackupIfNeeded(for: fileURL) + guard fileManager.fileExists(atPath: fileURL.path) else { let outcome = NotificationFeedHistoryLoadOutcome.missing loadOutcome = outcome return outcome @@ -46,6 +70,40 @@ actor NotificationFeedHistoryPersistence { let outcome: NotificationFeedHistoryLoadOutcome do { + guard try snapshotFileFitsLoadBudget(fileURL) else { + notificationFeedPersistenceLogger.error( + "Notification feed load rejected oversized file=\(fileURL.path, privacy: .private) limit=\(self.maxSnapshotBytes, privacy: .public)" + ) + let header = try oversizedSnapshotHeader(fileURL) + if let version = header.version, + version != NotificationFeedHistorySnapshot.currentVersion { + allowsWrites = false + outcome = .unsupportedVersion(version) + } else if header.version == NotificationFeedHistorySnapshot.currentVersion, + let revision = header.revision { + do { + let recovery = try recoverOversizedCurrentSnapshot( + fileURL, + revision: max(0, revision) + ) + try replaceOversizedSnapshotFile( + fileURL, + replacementSnapshot: recovery.snapshot, + shouldRetainQuarantineBackup: recovery.shouldRetainQuarantineBackup + ) + lastPersistedRevision = recovery.snapshot.revision + outcome = .loaded(recovery.snapshot) + } catch { + allowsWrites = false + outcome = .corrupt + } + } else { + allowsWrites = false + outcome = .corrupt + } + loadOutcome = outcome + return outcome + } let data = try Data(contentsOf: fileURL) let decoded = try JSONDecoder().decode(NotificationFeedHistorySnapshot.self, from: data) guard decoded.version == NotificationFeedHistorySnapshot.currentVersion else { @@ -54,12 +112,25 @@ actor NotificationFeedHistoryPersistence { loadOutcome = outcome return outcome } - let snapshot = NotificationFeedHistorySnapshot( + let decodedSnapshot = NotificationFeedHistorySnapshot( revision: max(0, decoded.revision), - notifications: Self.normalized( - decoded.notifications, - readRetentionLimit: readRetentionLimit + notifications: decoded.notifications + ) + guard let fitted = try snapshotAndDataFittingLoadBudget(decodedSnapshot) else { + notificationFeedPersistenceLogger.error( + "Notification feed load could not fit decoded file=\(fileURL.path, privacy: .private) limit=\(self.maxSnapshotBytes, privacy: .public)" ) + allowsWrites = false + outcome = .corrupt + loadOutcome = outcome + return outcome + } + let snapshot = fitted.snapshot + compactLoadedSnapshotIfNeeded( + snapshot, + encodedData: fitted.data, + originalSnapshot: decoded, + fileURL: fileURL ) lastPersistedRevision = snapshot.revision outcome = .loaded(snapshot) @@ -73,6 +144,349 @@ actor NotificationFeedHistoryPersistence { return outcome } + private static func defaultMaxSnapshotBytes(totalRetentionLimit: Int) -> UInt64 { + let minimumBudget = UInt64(1_048_576) + let perRecordBudget = UInt64(2_048) + let maximumWireCompatibleBudget = UInt64(4 * 1024 * 1024) + let retainedRecordBudget = UInt64(max(1, totalRetentionLimit)) * perRecordBudget + return min(max(minimumBudget, retainedRecordBudget), maximumWireCompatibleBudget) + } + + private func snapshotFileFitsLoadBudget(_ fileURL: URL) throws -> Bool { + let attributes = try fileManager.attributesOfItem(atPath: fileURL.path) + guard let size = attributes[.size] as? NSNumber else { return true } + return size.uint64Value <= maxSnapshotBytes + } + + private func oversizedSnapshotHeader(_ fileURL: URL) throws -> NotificationFeedHistorySnapshotHeader { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { + try? handle.close() + } + let fileSize = try handle.seekToEnd() + try handle.seek(toOffset: 0) + let prefixData = try handle.read( + upToCount: Self.oversizedSnapshotHeaderChunkByteCount + ) ?? Data() + var scanner = NotificationFeedHistoryTopLevelSnapshotHeaderScanner() + scanner.consume(prefixData) + var header = scanner.header + guard !header.isComplete, + fileSize > UInt64(Self.oversizedSnapshotHeaderChunkByteCount) else { + return header + } + + let tailOffset = fileSize - UInt64(Self.oversizedSnapshotHeaderChunkByteCount) + try handle.seek(toOffset: tailOffset) + let tailData = try handle.read( + upToCount: Self.oversizedSnapshotHeaderChunkByteCount + ) ?? Data() + let tailHeader = Self.topLevelTailSnapshotHeader(in: tailData) + header.version = header.version ?? tailHeader.version + header.revision = header.revision ?? tailHeader.revision + return header + } + + private static func topLevelTailSnapshotHeader(in data: Data) -> NotificationFeedHistorySnapshotHeader { + guard let suffixStart = topLevelTailSuffixStart(in: data) else { + return NotificationFeedHistorySnapshotHeader() + } + var scanner = NotificationFeedHistoryTopLevelSnapshotHeaderScanner() + scanner.consume(Data("{".utf8)) + scanner.consume(Data(data[suffixStart.. Data.Index? { + var isInString = false + var depth = 0 + var index = data.endIndex + while index > data.startIndex { + index = data.index(before: index) + let byte = data[index] + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.quote, + !isEscapedQuote(at: index, in: data) { + isInString.toggle() + continue + } + guard !isInString else { continue } + if byte == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBracket, depth == 1 { + var suffixStart = data.index(after: index) + while suffixStart < data.endIndex, + NotificationFeedHistoryTopLevelSnapshotHeaderScanner.isWhitespace(data[suffixStart]) { + suffixStart = data.index(after: suffixStart) + } + if suffixStart < data.endIndex, + data[suffixStart] == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.comma { + suffixStart = data.index(after: suffixStart) + } + return suffixStart + } + switch byte { + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBrace, + NotificationFeedHistoryTopLevelSnapshotHeaderScanner.rightBracket: + depth += 1 + case NotificationFeedHistoryTopLevelSnapshotHeaderScanner.leftBrace, + NotificationFeedHistoryTopLevelSnapshotHeaderScanner.leftBracket: + depth = max(0, depth - 1) + default: + break + } + } + return nil + } + + private static func isEscapedQuote(at index: Data.Index, in data: Data) -> Bool { + var backslashCount = 0 + var cursor = index + while cursor > data.startIndex { + let previous = data.index(before: cursor) + guard data[previous] == NotificationFeedHistoryTopLevelSnapshotHeaderScanner.backslash else { + break + } + backslashCount += 1 + cursor = previous + } + return !backslashCount.isMultiple(of: 2) + } + + private func recoverOversizedCurrentSnapshot( + _ fileURL: URL, + revision: Int + ) throws -> NotificationFeedHistoryOversizedSnapshotRecovery { + guard totalRetentionLimit > 0 else { + return NotificationFeedHistoryOversizedSnapshotRecovery( + snapshot: NotificationFeedHistorySnapshot(revision: revision, notifications: []), + shouldRetainQuarantineBackup: false + ) + } + + let handle = try FileHandle(forReadingFrom: fileURL) + defer { + try? handle.close() + } + + let decoder = JSONDecoder() + var scanner = NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner( + maxRecordBytes: Self.oversizedSnapshotRecordMigrationByteLimit + ) + var retainedRecords: [NotificationFeedHistoryRecord] = [] + retainedRecords.reserveCapacity(totalRetentionLimit) + var remainingReadSlots = readRetentionLimit + var shouldContinue = true + var scannedBytes = 0 + var scanBudgetWasExceeded = false + + while shouldContinue { + guard !Task.isCancelled else { + throw CancellationError() + } + guard let chunk = try handle.read( + upToCount: Self.oversizedSnapshotHeaderChunkByteCount + ), !chunk.isEmpty else { + break + } + scannedBytes += chunk.count + guard scannedBytes <= oversizedSnapshotMigrationScanByteLimit else { + scanBudgetWasExceeded = true + break + } + shouldContinue = scanner.consume(chunk) { recordData in + guard retainedRecords.count < totalRetentionLimit else { + return false + } + guard let record = try? decoder.decode( + NotificationFeedHistoryRecord.self, + from: recordData + ).boundedForHistory() else { + return true + } + if record.isRead { + guard remainingReadSlots > 0 else { return true } + remainingReadSlots -= 1 + } + retainedRecords.append(record) + return retainedRecords.count < totalRetentionLimit + } + } + + let normalizedRecords = Self.normalized( + retainedRecords, + readRetentionLimit: readRetentionLimit, + totalRetentionLimit: totalRetentionLimit + ) + guard let fitted = try encodedSnapshot( + revision: revision, + version: NotificationFeedHistorySnapshot.currentVersion, + records: normalizedRecords, + maxBytes: maxSnapshotBytes + ) else { + return NotificationFeedHistoryOversizedSnapshotRecovery( + snapshot: NotificationFeedHistorySnapshot(revision: revision, notifications: []), + shouldRetainQuarantineBackup: true + ) + } + return NotificationFeedHistoryOversizedSnapshotRecovery( + snapshot: fitted.snapshot, + shouldRetainQuarantineBackup: scanBudgetWasExceeded + ) + } + + private func replaceOversizedSnapshotFile( + _ fileURL: URL, + replacementSnapshot snapshot: NotificationFeedHistorySnapshot, + shouldRetainQuarantineBackup: Bool = false + ) throws { + let replacementURL = fileURL + .deletingLastPathComponent() + .appendingPathComponent( + "\(fileURL.lastPathComponent).replacement-\(UUID().uuidString).tmp", + isDirectory: false + ) + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: replacementURL, options: .atomic) + let backupURL = quarantineBackupURL(for: fileURL) + pruneQuarantineBackups(for: fileURL, keeping: nil) + try fileManager.moveItem(at: fileURL, to: backupURL) + do { + try fileManager.moveItem(at: replacementURL, to: fileURL) + try validateReplacementSnapshotFile( + fileURL, + expectedRevision: snapshot.revision + ) + } catch { + try? fileManager.removeItem(at: fileURL) + restoreQuarantineBackup(backupURL, to: fileURL) + throw error + } + pruneQuarantineBackups( + for: fileURL, + keeping: shouldRetainQuarantineBackup ? backupURL : nil + ) + if shouldRetainQuarantineBackup { + notificationFeedPersistenceLogger.notice( + "Notification feed oversized file replaced file=\(fileURL.path, privacy: .private) backup_retained=\(backupURL.lastPathComponent, privacy: .private) revision=\(snapshot.revision, privacy: .public)" + ) + } else { + notificationFeedPersistenceLogger.notice( + "Notification feed oversized file replaced file=\(fileURL.path, privacy: .private) backup_removed=\(backupURL.lastPathComponent, privacy: .private) revision=\(snapshot.revision, privacy: .public)" + ) + } + } catch { + try? fileManager.removeItem(at: replacementURL) + notificationFeedPersistenceLogger.error( + "Notification feed oversized file replacement failed file=\(fileURL.path, privacy: .private) revision=\(snapshot.revision, privacy: .public) error=\(error.localizedDescription, privacy: .private)" + ) + throw error + } + } + + private func validateReplacementSnapshotFile( + _ fileURL: URL, + expectedRevision: Int + ) throws { + guard try snapshotFileFitsLoadBudget(fileURL) else { + throw NotificationFeedHistoryOversizedSnapshotMigrationError.replacementValidationFailed + } + let data = try Data(contentsOf: fileURL) + let decoded = try JSONDecoder().decode(NotificationFeedHistorySnapshot.self, from: data) + guard decoded.version == NotificationFeedHistorySnapshot.currentVersion, + decoded.revision == expectedRevision else { + throw NotificationFeedHistoryOversizedSnapshotMigrationError.replacementValidationFailed + } + } + + private func quarantineBackupURL(for fileURL: URL) -> URL { + fileURL + .deletingLastPathComponent() + .appendingPathComponent( + "\(fileURL.lastPathComponent).oversized-latest.quarantine", + isDirectory: false + ) + } + + private func quarantineBackupURLs(for fileURL: URL) throws -> [URL] { + try fileManager.contentsOfDirectory( + at: fileURL.deletingLastPathComponent(), + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("\(fileURL.lastPathComponent).oversized-") + && $0.lastPathComponent.hasSuffix(".quarantine") + } + } + + private func pruneQuarantineBackups(for fileURL: URL, keeping keptURL: URL?) { + guard let backups = try? quarantineBackupURLs(for: fileURL) else { return } + let keptName = keptURL?.lastPathComponent + for backup in backups where backup.lastPathComponent != keptName { + try? fileManager.removeItem(at: backup) + } + } + + private func newestQuarantineBackupURL(for fileURL: URL) throws -> URL? { + try quarantineBackupURLs(for: fileURL).max { lhs, rhs in + let lhsValues = try? lhs.resourceValues(forKeys: [.contentModificationDateKey]) + let rhsValues = try? rhs.resourceValues(forKeys: [.contentModificationDateKey]) + let lhsDate = lhsValues?.contentModificationDate ?? .distantPast + let rhsDate = rhsValues?.contentModificationDate ?? .distantPast + return lhsDate < rhsDate + } + } + + private func restoreNewestQuarantineBackupIfNeeded(for fileURL: URL) { + guard !fileManager.fileExists(atPath: fileURL.path), + let backupURL = try? newestQuarantineBackupURL(for: fileURL) else { + return + } + do { + try fileManager.moveItem(at: backupURL, to: fileURL) + pruneQuarantineBackups(for: fileURL, keeping: nil) + notificationFeedPersistenceLogger.notice( + "Notification feed quarantine restored missing canonical file=\(fileURL.path, privacy: .private) backup=\(backupURL.path, privacy: .private)" + ) + } catch { + notificationFeedPersistenceLogger.error( + "Notification feed quarantine restore failed missing canonical file=\(fileURL.path, privacy: .private) backup=\(backupURL.path, privacy: .private) error=\(error.localizedDescription, privacy: .private)" + ) + } + } + + private func restoreQuarantineBackup(_ backupURL: URL, to fileURL: URL) { + guard !fileManager.fileExists(atPath: fileURL.path), + fileManager.fileExists(atPath: backupURL.path) else { + return + } + do { + try fileManager.moveItem(at: backupURL, to: fileURL) + notificationFeedPersistenceLogger.notice( + "Notification feed quarantine restored after replacement failure source=\(backupURL.path, privacy: .private) destination=\(fileURL.path, privacy: .private)" + ) + } catch { + notificationFeedPersistenceLogger.error( + "Notification feed quarantine restore failed source=\(backupURL.path, privacy: .private) destination=\(fileURL.path, privacy: .private) error=\(error.localizedDescription, privacy: .private)" + ) + } + } + + private func compactLoadedSnapshotIfNeeded( + _ snapshot: NotificationFeedHistorySnapshot, + encodedData: Data, + originalSnapshot: NotificationFeedHistorySnapshot, + fileURL: URL + ) { + guard snapshot != originalSnapshot else { return } + do { + try encodedData.write(to: fileURL, options: .atomic) + } catch { + notificationFeedPersistenceLogger.error( + "Notification feed compaction failed file=\(fileURL.path, privacy: .private) revision=\(snapshot.revision) error=\(error.localizedDescription, privacy: .private)" + ) + } + } + func persist(_ snapshot: NotificationFeedHistorySnapshot) { _ = load() guard allowsWrites, @@ -80,9 +494,24 @@ actor NotificationFeedHistoryPersistence { snapshot.revision > lastPersistedRevision else { return } + let fitted: (snapshot: NotificationFeedHistorySnapshot, data: Data) + do { + guard let resolved = try snapshotAndDataFittingLoadBudget(snapshot) else { + notificationFeedPersistenceLogger.error( + "Notification feed persist skipped because snapshot cannot fit load budget revision=\(snapshot.revision, privacy: .public) limit=\(self.maxSnapshotBytes, privacy: .public)" + ) + return + } + fitted = resolved + } catch { + notificationFeedPersistenceLogger.error( + "Notification feed persist encode failed revision=\(snapshot.revision, privacy: .public) error=\(error.localizedDescription, privacy: .private)" + ) + return + } guard let fileURL else { - lastPersistedRevision = snapshot.revision - loadOutcome = .loaded(snapshot) + lastPersistedRevision = fitted.snapshot.revision + loadOutcome = .loaded(fitted.snapshot) return } @@ -92,12 +521,10 @@ actor NotificationFeedHistoryPersistence { withIntermediateDirectories: true, attributes: nil ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(snapshot) - try data.write(to: fileURL, options: .atomic) - lastPersistedRevision = snapshot.revision - loadOutcome = .loaded(snapshot) + try fitted.data.write(to: fileURL, options: .atomic) + pruneQuarantineBackups(for: fileURL, keeping: nil) + lastPersistedRevision = fitted.snapshot.revision + loadOutcome = .loaded(fitted.snapshot) } catch { notificationFeedPersistenceLogger.error( "Notification feed persist failed file=\(fileURL.path, privacy: .private) revision=\(snapshot.revision) error=\(error.localizedDescription, privacy: .private)" @@ -105,18 +532,86 @@ actor NotificationFeedHistoryPersistence { } } + private func snapshotAndDataFittingLoadBudget( + _ snapshot: NotificationFeedHistorySnapshot + ) throws -> (snapshot: NotificationFeedHistorySnapshot, data: Data)? { + let normalizedRecords = Self.normalized( + snapshot.notifications.map { $0.boundedForHistory() }, + readRetentionLimit: readRetentionLimit, + totalRetentionLimit: totalRetentionLimit + ) + return try encodedSnapshot( + revision: snapshot.revision, + version: snapshot.version, + records: normalizedRecords, + maxBytes: maxSnapshotBytes + ) + } + + private func encodedSnapshot( + revision: Int, + version: Int, + records: [NotificationFeedHistoryRecord], + maxBytes: UInt64 + ) throws -> (snapshot: NotificationFeedHistorySnapshot, data: Data)? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let prefix = Data(#"{"notifications":["#.utf8) + let separator = Data(",".utf8) + let suffix = Data(#"],"revision":\#(revision),"version":\#(version)}"#.utf8) + var data = prefix + let reserveLimit = Int(min(maxBytes, UInt64(Int.max))) + data.reserveCapacity(min( + reserveLimit, + prefix.count + suffix.count + records.count * 512 + )) + var retainedRecords: [NotificationFeedHistoryRecord] = [] + retainedRecords.reserveCapacity(records.count) + + for record in records { + let recordData = try encoder.encode(record) + let separatorByteCount = retainedRecords.isEmpty ? 0 : separator.count + let candidateByteCount = data.count + separatorByteCount + recordData.count + suffix.count + guard UInt64(candidateByteCount) <= maxBytes else { break } + if !retainedRecords.isEmpty { + data.append(separator) + } + data.append(recordData) + retainedRecords.append(record) + } + data.append(suffix) + guard UInt64(data.count) <= maxBytes else { return nil } + return ( + NotificationFeedHistorySnapshot( + revision: revision, + notifications: retainedRecords, + version: version + ), + data + ) + } + private static func normalized( _ records: [NotificationFeedHistoryRecord], - readRetentionLimit: Int + readRetentionLimit: Int, + totalRetentionLimit: Int ) -> [NotificationFeedHistoryRecord] { + guard totalRetentionLimit > 0 else { return [] } let sorted = records.sorted(by: recordPrecedes) var remainingReadSlots = readRetentionLimit - return sorted.filter { record in - guard record.isRead else { return true } - guard remainingReadSlots > 0 else { return false } - remainingReadSlots -= 1 - return true + var normalized: [NotificationFeedHistoryRecord] = [] + normalized.reserveCapacity(min(sorted.count, totalRetentionLimit)) + for record in sorted { + if record.isRead { + guard remainingReadSlots > 0 else { continue } + remainingReadSlots -= 1 + } + normalized.append(record) + if normalized.count >= totalRetentionLimit { + break + } } + return normalized } private static func recordPrecedes( diff --git a/Sources/NotificationFeedHistoryRecord.swift b/Sources/NotificationFeedHistoryRecord.swift index cc44c69a3aad..50bced43751f 100644 --- a/Sources/NotificationFeedHistoryRecord.swift +++ b/Sources/NotificationFeedHistoryRecord.swift @@ -2,6 +2,10 @@ import Foundation /// One durable cmux notification in the cross-device chronological feed. struct NotificationFeedHistoryRecord: Codable, Equatable, Identifiable, Sendable { + static let historyTitleByteLimit = 512 + static let historySubtitleByteLimit = 512 + static let historyBodyByteLimit = 2_048 + let id: UUID var tabId: UUID var surfaceId: UUID? @@ -13,6 +17,30 @@ struct NotificationFeedHistoryRecord: Codable, Equatable, Identifiable, Sendable let createdAt: Date var isRead: Bool + init( + id: UUID, + tabId: UUID, + surfaceId: UUID?, + panelId: UUID?, + retargetsToLiveSurfaceOwner: Bool, + title: String, + subtitle: String, + body: String, + createdAt: Date, + isRead: Bool + ) { + self.id = id + self.tabId = tabId + self.surfaceId = surfaceId + self.panelId = panelId + self.retargetsToLiveSurfaceOwner = retargetsToLiveSurfaceOwner + self.title = title + self.subtitle = subtitle + self.body = body + self.createdAt = createdAt + self.isRead = isRead + } + init(notification: TerminalNotification) { id = notification.id tabId = notification.tabId @@ -33,4 +61,33 @@ struct NotificationFeedHistoryRecord: Codable, Equatable, Identifiable, Sendable } return surfaceId == targetSurfaceId || panelId == targetSurfaceId } + + func boundedForHistory() -> NotificationFeedHistoryRecord { + NotificationFeedHistoryRecord( + id: id, + tabId: tabId, + surfaceId: surfaceId, + panelId: panelId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, + title: Self.string(title, limitedToUTF8Bytes: Self.historyTitleByteLimit), + subtitle: Self.string(subtitle, limitedToUTF8Bytes: Self.historySubtitleByteLimit), + body: Self.string(body, limitedToUTF8Bytes: Self.historyBodyByteLimit), + createdAt: createdAt, + isRead: isRead + ) + } + + private static func string(_ value: String, limitedToUTF8Bytes maxBytes: Int) -> String { + guard maxBytes >= 0, value.utf8.count > maxBytes else { return value } + var byteCount = 0 + var endIndex = value.startIndex + while endIndex < value.endIndex { + let nextIndex = value.index(after: endIndex) + let characterByteCount = value[endIndex.. 0 else { return } + guard storage.count >= limit else { + storage.append(notification) + siftUp(from: storage.count - 1) + return + } + guard let oldest = storage.first, + notificationFeedHistoryActiveNotificationPrecedes(notification, oldest) else { + return + } + storage[0] = notification + siftDown(from: 0) + } + + func sortedNewestFirst() -> [TerminalNotification] { + storage.sorted(by: notificationFeedHistoryActiveNotificationPrecedes) + } + + private mutating func siftUp(from startIndex: Int) { + var child = startIndex + while child > 0 { + let parent = (child - 1) / 2 + guard notificationFeedHistoryActiveNotificationIsOlder( + storage[child], + than: storage[parent] + ) else { return } + storage.swapAt(child, parent) + child = parent + } + } + + private mutating func siftDown(from startIndex: Int) { + var parent = startIndex + while true { + let left = parent * 2 + 1 + let right = left + 1 + var candidate = parent + if left < storage.count, + notificationFeedHistoryActiveNotificationIsOlder(storage[left], than: storage[candidate]) { + candidate = left + } + if right < storage.count, + notificationFeedHistoryActiveNotificationIsOlder(storage[right], than: storage[candidate]) { + candidate = right + } + guard candidate != parent else { return } + storage.swapAt(parent, candidate) + parent = candidate + } + } +} + +nonisolated func notificationFeedHistoryActiveNotificationPrecedes( + _ lhs: TerminalNotification, + _ rhs: TerminalNotification +) -> Bool { + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id.uuidString > rhs.id.uuidString +} + +nonisolated private func notificationFeedHistoryActiveNotificationIsOlder( + _ lhs: TerminalNotification, + than rhs: TerminalNotification +) -> Bool { + notificationFeedHistoryActiveNotificationPrecedes(rhs, lhs) +} diff --git a/Sources/NotificationFeedHistorySnapshotHeader.swift b/Sources/NotificationFeedHistorySnapshotHeader.swift new file mode 100644 index 000000000000..6be4d769990b --- /dev/null +++ b/Sources/NotificationFeedHistorySnapshotHeader.swift @@ -0,0 +1,10 @@ +import Foundation + +nonisolated struct NotificationFeedHistorySnapshotHeader: Sendable { + var version: Int? + var revision: Int? + + var isComplete: Bool { + version != nil && revision != nil + } +} diff --git a/Sources/NotificationFeedHistoryStore.swift b/Sources/NotificationFeedHistoryStore.swift index 6062132197d2..d6ca51e04c98 100644 --- a/Sources/NotificationFeedHistoryStore.swift +++ b/Sources/NotificationFeedHistoryStore.swift @@ -4,51 +4,42 @@ import Foundation @MainActor final class NotificationFeedHistoryStore { nonisolated static let readRetentionLimit = 1_000 - - private enum Mutation { - case record(NotificationFeedHistoryRecord, supersededIDs: Set) - case reconcileActive([NotificationFeedHistoryRecord]) - case markReadIDs(Set) - case markReadWorkspace(UUID) - case markReadSurface(tabId: UUID, surfaceId: UUID?) - case markAllRead - case markUnreadIDs(Set) - case rebindSurface(sourceTabId: UUID, destinationTabId: UUID, surfaceId: UUID) - } - - private struct MutationResult { - var changed = false - var marked = 0 - } + nonisolated static let totalRetentionLimit = 2_000 private(set) var revision = 0 private(set) var notifications: [NotificationFeedHistoryRecord] = [] private let readRetentionLimit: Int + private let totalRetentionLimit: Int private let persistence: NotificationFeedHistoryPersistence private let persistsToDisk: Bool private let onChange: (Int) -> Void private var didFinishLoading = false private var persistenceAllowsWrites = true - private var pendingMutations: [Mutation] = [] + private var pendingMutations: [NotificationFeedHistoryMutation] = [] private var readRecordCount = 0 private(set) var loadingTask: Task? private var persistenceTask: Task? + private var pendingPersistenceSnapshot: NotificationFeedHistorySnapshot? init( fileURL: URL?, fileManager: FileManager = .default, readRetentionLimit: Int = NotificationFeedHistoryStore.readRetentionLimit, + totalRetentionLimit: Int = NotificationFeedHistoryStore.totalRetentionLimit, onChange: @escaping (Int) -> Void = { _ in } ) { let resolvedReadRetentionLimit = max(0, readRetentionLimit) + let resolvedTotalRetentionLimit = max(0, totalRetentionLimit) let persistence = NotificationFeedHistoryPersistence( fileURL: fileURL, fileManager: fileManager, - readRetentionLimit: resolvedReadRetentionLimit + readRetentionLimit: resolvedReadRetentionLimit, + totalRetentionLimit: resolvedTotalRetentionLimit ) self.readRetentionLimit = resolvedReadRetentionLimit + self.totalRetentionLimit = resolvedTotalRetentionLimit self.persistence = persistence persistsToDisk = fileURL != nil self.onChange = onChange @@ -73,7 +64,7 @@ final class NotificationFeedHistoryStore { ) { _ = commit( .record( - NotificationFeedHistoryRecord(notification: notification), + NotificationFeedHistoryRecord(notification: notification).boundedForHistory(), supersededIDs: supersededIDs ) ) @@ -83,10 +74,16 @@ final class NotificationFeedHistoryStore { /// durable history. Existing historical rows remain unchanged; only missing /// UUIDs are inserted. func reconcileActiveNotifications(_ activeNotifications: [TerminalNotification]) { + let activeNotifications = Self.retainableActiveNotifications( + activeNotifications, + totalRetentionLimit: totalRetentionLimit + ) guard !activeNotifications.isEmpty else { return } _ = commit( .reconcileActive( - activeNotifications.map(NotificationFeedHistoryRecord.init(notification:)) + activeNotifications.map { + NotificationFeedHistoryRecord(notification: $0).boundedForHistory() + } ) ) } @@ -165,7 +162,7 @@ final class NotificationFeedHistoryStore { ) } - private func commit(_ mutation: Mutation) -> MutationResult { + private func commit(_ mutation: NotificationFeedHistoryMutation) -> NotificationFeedHistoryMutationResult { if !didFinishLoading { pendingMutations.append(mutation) } @@ -174,7 +171,8 @@ final class NotificationFeedHistoryStore { mutation, to: ¬ifications, readRecordCount: &readRecordCount, - readRetentionLimit: readRetentionLimit + readRetentionLimit: readRetentionLimit, + totalRetentionLimit: totalRetentionLimit ) guard result.changed else { return result } @@ -195,7 +193,7 @@ final class NotificationFeedHistoryStore { switch outcome { case .loaded(let snapshot): loadedRevision = snapshot.revision - loadedNotifications = snapshot.notifications + loadedNotifications = snapshot.notifications.map { $0.boundedForHistory() } case .missing, .corrupt: loadedRevision = 0 loadedNotifications = [] @@ -212,7 +210,8 @@ final class NotificationFeedHistoryStore { mutation, to: &loadedNotifications, readRecordCount: &loadedReadRecordCount, - readRetentionLimit: readRetentionLimit + readRetentionLimit: readRetentionLimit, + totalRetentionLimit: totalRetentionLimit ) if result.changed { replayedChanges += 1 @@ -239,36 +238,72 @@ final class NotificationFeedHistoryStore { private func schedulePersistence() { guard persistsToDisk, persistenceAllowsWrites else { return } - let persistedSnapshot = snapshot - persistenceTask = Task { [persistence] in - await persistence.persist(persistedSnapshot) + pendingPersistenceSnapshot = snapshot + guard persistenceTask == nil else { return } + persistenceTask = Task { [weak self, persistence] in + while !Task.isCancelled { + guard let snapshot = await self?.consumePendingPersistenceSnapshot() else { + break + } + await persistence.persist(snapshot) + } + await self?.finishPersistenceTask() + } + } + + private func consumePendingPersistenceSnapshot() -> NotificationFeedHistorySnapshot? { + let snapshot = pendingPersistenceSnapshot + pendingPersistenceSnapshot = nil + return snapshot + } + + private func finishPersistenceTask() { + persistenceTask = nil + if pendingPersistenceSnapshot != nil { + schedulePersistence() } } private static func apply( - _ mutation: Mutation, + _ mutation: NotificationFeedHistoryMutation, to records: inout [NotificationFeedHistoryRecord], readRecordCount: inout Int, - readRetentionLimit: Int - ) -> MutationResult { - var result = MutationResult() + readRetentionLimit: Int, + totalRetentionLimit: Int + ) -> NotificationFeedHistoryMutationResult { + var result = NotificationFeedHistoryMutationResult() + var insertedNewIDs = Set() + var changedExistingState = false switch mutation { case .record(let record, let supersededIDs): + let record = record.boundedForHistory() for index in records.indices where supersededIDs.contains(records[index].id) && !records[index].isRead { records[index].isRead = true readRecordCount += 1 result.changed = true + changedExistingState = true } - if insertOrReplace(record, in: &records, readRecordCount: &readRecordCount) { + switch insertOrReplace(record, in: &records, readRecordCount: &readRecordCount) { + case .none: + break + case .insertedNew(let id): + insertedNewIDs.insert(id) + result.changed = true + case .replacedExisting: + changedExistingState = true result.changed = true } case .reconcileActive(let activeRecords): var knownIDs = Set(records.map(\.id)) - for record in activeRecords where knownIDs.insert(record.id).inserted { + for record in retainableActiveRecords( + activeRecords, + totalRetentionLimit: totalRetentionLimit + ) where knownIDs.insert(record.id).inserted { insert(record, in: &records) if record.isRead { readRecordCount += 1 } + insertedNewIDs.insert(record.id) result.changed = true } @@ -279,6 +314,7 @@ final class NotificationFeedHistoryStore { result.marked += 1 } result.changed = result.marked > 0 + changedExistingState = result.changed case .markReadWorkspace(let tabId): for index in records.indices where records[index].tabId == tabId && !records[index].isRead { @@ -287,6 +323,7 @@ final class NotificationFeedHistoryStore { result.marked += 1 } result.changed = result.marked > 0 + changedExistingState = result.changed case .markReadSurface(let tabId, let surfaceId): for index in records.indices @@ -296,6 +333,7 @@ final class NotificationFeedHistoryStore { result.marked += 1 } result.changed = result.marked > 0 + changedExistingState = result.changed case .markAllRead: for index in records.indices where !records[index].isRead { @@ -304,6 +342,7 @@ final class NotificationFeedHistoryStore { result.marked += 1 } result.changed = result.marked > 0 + changedExistingState = result.changed case .markUnreadIDs(let ids): for index in records.indices where ids.contains(records[index].id) && records[index].isRead { @@ -312,6 +351,7 @@ final class NotificationFeedHistoryStore { result.marked += 1 } result.changed = result.marked > 0 + changedExistingState = result.changed case .rebindSurface(let sourceTabId, let destinationTabId, let surfaceId): for index in records.indices { @@ -321,6 +361,7 @@ final class NotificationFeedHistoryStore { } records[index].tabId = destinationTabId result.changed = true + changedExistingState = true } } @@ -330,24 +371,59 @@ final class NotificationFeedHistoryStore { readRecordCount: &readRecordCount, readRetentionLimit: readRetentionLimit ) + trimOldestRecords( + in: &records, + readRecordCount: &readRecordCount, + totalRetentionLimit: totalRetentionLimit + ) + if !changedExistingState, + !insertedNewIDs.isEmpty, + !records.contains(where: { insertedNewIDs.contains($0.id) }) { + result.changed = false + } } return result } + private static func retainableActiveNotifications( + _ notifications: [TerminalNotification], + totalRetentionLimit: Int + ) -> [TerminalNotification] { + guard totalRetentionLimit > 0 else { return [] } + guard notifications.count > totalRetentionLimit else { return notifications } + var heap = NotificationFeedHistoryRetainedActiveNotificationHeap(limit: totalRetentionLimit) + for notification in notifications { + heap.insert(notification) + } + return heap.sortedNewestFirst() + } + + private static func retainableActiveRecords( + _ records: [NotificationFeedHistoryRecord], + totalRetentionLimit: Int + ) -> [NotificationFeedHistoryRecord] { + guard totalRetentionLimit > 0 else { return [] } + guard records.count > totalRetentionLimit else { return records } + return Array(records.sorted(by: recordPrecedes).prefix(totalRetentionLimit)) + } + private static func insertOrReplace( _ record: NotificationFeedHistoryRecord, in records: inout [NotificationFeedHistoryRecord], readRecordCount: inout Int - ) -> Bool { + ) -> NotificationFeedHistoryInsertionChange { if let existingIndex = records.firstIndex(where: { $0.id == record.id }) { let existing = records[existingIndex] - guard existing != record else { return false } + guard existing != record else { return .none } records.remove(at: existingIndex) if existing.isRead { readRecordCount -= 1 } + insert(record, in: &records) + if record.isRead { readRecordCount += 1 } + return .replacedExisting } insert(record, in: &records) if record.isRead { readRecordCount += 1 } - return true + return .insertedNew(record.id) } private static func insert( @@ -381,6 +457,19 @@ final class NotificationFeedHistoryStore { } } + private static func trimOldestRecords( + in records: inout [NotificationFeedHistoryRecord], + readRecordCount: inout Int, + totalRetentionLimit: Int + ) { + while records.count > totalRetentionLimit { + let removed = records.removeLast() + if removed.isRead { + readRecordCount -= 1 + } + } + } + private static func recordPrecedes( _ lhs: NotificationFeedHistoryRecord, _ rhs: NotificationFeedHistoryRecord @@ -390,4 +479,5 @@ final class NotificationFeedHistoryStore { } return lhs.id.uuidString > rhs.id.uuidString } + } diff --git a/Sources/NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift b/Sources/NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift new file mode 100644 index 000000000000..ae6a608b5535 --- /dev/null +++ b/Sources/NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift @@ -0,0 +1,180 @@ +import Foundation + +nonisolated struct NotificationFeedHistoryTopLevelSnapshotHeaderScanner: Sendable { + var header = NotificationFeedHistorySnapshotHeader() + private var depth = 0 + private var isInString = false + private var isEscapingString = false + private var isCapturingKey = false + private var keyBytes: [UInt8] = [] + private var isExpectingKey = false + private var isExpectingValue = false + private var currentKey: String? + private var numberKey: String? + private var numberBytes: [UInt8] = [] + private var numberOverflowed = false + + mutating func consume(_ data: Data) { + for byte in data { + consume(byte) + if header.isComplete { return } + } + } + + private mutating func consume(_ byte: UInt8) { + if numberKey != nil { + if consumeNumberByte(byte) { + return + } + finishNumber() + consume(byte) + return + } + + if isInString { + if isEscapingString { + isEscapingString = false + if isCapturingKey { + keyBytes.append(byte) + } + return + } + if byte == Self.backslash { + isEscapingString = true + if isCapturingKey { + keyBytes.append(byte) + } + return + } + if byte == Self.quote { + isInString = false + if isCapturingKey { + currentKey = String(bytes: keyBytes, encoding: .utf8) + keyBytes.removeAll(keepingCapacity: true) + isCapturingKey = false + } + return + } + if isCapturingKey { + keyBytes.append(byte) + } + return + } + + guard !Self.isWhitespace(byte) else { return } + + switch byte { + case Self.leftBrace, Self.leftBracket: + if depth == 0, byte == Self.leftBrace { + isExpectingKey = true + } + depth += 1 + isExpectingValue = false + case Self.rightBrace, Self.rightBracket: + if depth > 0 { + depth -= 1 + } + if depth == 1 { + currentKey = nil + isExpectingValue = false + } + case Self.comma: + if depth == 1 { + currentKey = nil + isExpectingKey = true + isExpectingValue = false + } + case Self.colon: + if depth == 1, currentKey != nil { + isExpectingKey = false + isExpectingValue = true + } + case Self.quote: + isInString = true + if depth == 1, isExpectingKey { + isCapturingKey = true + keyBytes.removeAll(keepingCapacity: true) + isExpectingKey = false + } else { + isCapturingKey = false + isExpectingValue = false + } + default: + guard depth == 1, + isExpectingValue, + let currentKey, + currentKey == "revision" || currentKey == "version", + (Self.isDigit(byte) || byte == Self.minus) else { + if depth == 1, isExpectingValue { + isExpectingValue = false + } + return + } + startNumber(key: currentKey) + _ = consumeNumberByte(byte) + } + } + + private mutating func startNumber(key: String) { + numberKey = key + numberBytes.removeAll(keepingCapacity: true) + numberOverflowed = false + isExpectingValue = false + } + + private mutating func consumeNumberByte(_ byte: UInt8) -> Bool { + if byte == Self.minus, numberBytes.isEmpty { + numberBytes.append(byte) + return true + } + guard Self.isDigit(byte) else { return false } + if numberBytes.count < Self.maxIntegerLiteralByteCount { + numberBytes.append(byte) + } else { + numberOverflowed = true + } + return true + } + + private mutating func finishNumber() { + guard let numberKey else { return } + defer { + self.numberKey = nil + numberBytes.removeAll(keepingCapacity: true) + numberOverflowed = false + currentKey = nil + } + guard !numberOverflowed, + let literal = String(bytes: numberBytes, encoding: .utf8), + let value = Int(literal) else { + return + } + if numberKey == "revision" { + header.revision = value + } else if numberKey == "version" { + header.version = value + } + } + + static let backslash = UInt8(ascii: "\\") + static let colon = UInt8(ascii: ":") + static let comma = UInt8(ascii: ",") + static let leftBrace = UInt8(ascii: "{") + static let leftBracket = UInt8(ascii: "[") + static let minus = UInt8(ascii: "-") + static let quote = UInt8(ascii: "\"") + static let rightBrace = UInt8(ascii: "}") + static let rightBracket = UInt8(ascii: "]") + private static let maxIntegerLiteralByteCount = 20 + + private static func isDigit(_ byte: UInt8) -> Bool { + byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9") + } + + static func isWhitespace(_ byte: UInt8) -> Bool { + byte == UInt8(ascii: " ") + || byte == UInt8(ascii: "\n") + || byte == UInt8(ascii: "\r") + || byte == UInt8(ascii: "\t") + } +} diff --git a/Sources/TerminalController+MobileNotificationSync.swift b/Sources/TerminalController+MobileNotificationSync.swift index 2c7d3fa32aa2..63b8ebcbd979 100644 --- a/Sources/TerminalController+MobileNotificationSync.swift +++ b/Sources/TerminalController+MobileNotificationSync.swift @@ -1,21 +1,122 @@ +import CMUXMobileCore import Foundation /// Mobile-host notification verbs (cross-device dismiss-sync): the /// `notification.dismiss` and `notification.reconcile` RPC handlers dispatched /// from `mobileHostHandleRPC(_:)`. extension TerminalController { + private static let mobileNotificationFeedResponseByteLimit = + MobileSyncFrameCodec.defaultMaximumFrameByteCount - (64 * 1024) + private static let mobileNotificationFeedTitleByteLimit = 512 + private static let mobileNotificationFeedSubtitleByteLimit = 512 + private static let mobileNotificationFeedBodyByteLimit = 4_096 + private static let mobileNotificationFeedMetadataByteLimit = 512 + /// Returns the Mac-owned notification history, newest first. The paired /// phone merges snapshots from all connected Macs into its global feed. - func v2MobileNotificationFeedList(params _: [String: Any]) -> V2CallResult { + func v2MobileNotificationFeedList( + params _: [String: Any], + responseID: String? = "notification.feed.list" + ) async -> V2CallResult { let store = TerminalNotificationStore.shared store.notificationFeedHistory.reconcileActiveNotifications(store.notifications) let snapshot = store.notificationFeedHistory.snapshot + let items = snapshot.notifications.map(mobileNotificationFeedWireItem) + let fittedItems = await Self.mobileNotificationFeedItemsFittingFrame( + responseID: responseID, + revision: snapshot.revision, + items: items + ) return .ok([ "revision": snapshot.revision, - "notifications": snapshot.notifications.map(mobileNotificationFeedPayload), + "notifications": fittedItems.map(\.foundationPayload), ]) } + private nonisolated static func mobileNotificationFeedItemsFittingFrame( + responseID: String?, + revision: Int, + items: [MobileNotificationFeedWireItem] + ) async -> [MobileNotificationFeedWireItem] { + let worker = Task.detached(priority: .utility) { + mobileNotificationFeedItemsFittingFrameOnWorker( + responseID: responseID, + revision: revision, + items: items + ) + } + return await withTaskCancellationHandler { + await worker.value + } onCancel: { + worker.cancel() + } + } + + private nonisolated static func mobileNotificationFeedItemsFittingFrameOnWorker( + responseID: String?, + revision: Int, + items: [MobileNotificationFeedWireItem] + ) -> [MobileNotificationFeedWireItem] { + guard !Task.isCancelled else { return [] } + guard !items.isEmpty else { + return items + } + + let emptyResponseByteCount = mobileNotificationFeedEmptyResponseByteCount( + responseID: responseID, + revision: revision + ) + guard emptyResponseByteCount <= mobileNotificationFeedResponseByteLimit else { + return [] + } + + var responseByteCount = emptyResponseByteCount - 2 + var fittedCount = 0 + for item in items { + guard !Task.isCancelled else { + return Array(items.prefix(fittedCount)) + } + let rowByteCount = mobileNotificationFeedRowByteCount(item) + let separatorByteCount = fittedCount == 0 ? 0 : 1 + let remainingByteCount = mobileNotificationFeedResponseByteLimit + - responseByteCount + - separatorByteCount + guard rowByteCount <= remainingByteCount else { + break + } + responseByteCount += separatorByteCount + rowByteCount + fittedCount += 1 + } + guard fittedCount < items.count else { return items } + return Array(items.prefix(fittedCount)) + } + + private nonisolated static func mobileNotificationFeedEmptyResponseByteCount( + responseID: String?, + revision: Int + ) -> Int { + let payload: [String: Any] = [ + "revision": revision, + "notifications": [], + ] + let encoded = MobileHostRPCEnvelope.encodeResponse( + id: responseID, + result: .ok(payload) + ) + return encoded.count + } + + private nonisolated static func mobileNotificationFeedRowByteCount( + _ item: MobileNotificationFeedWireItem + ) -> Int { + let payload = item.foundationPayload + guard JSONSerialization.isValidJSONObject(payload), + let encoded = try? JSONSerialization.data(withJSONObject: payload) else { + return Int.max + } + return encoded.count + } + /// Marks the supplied feed records read and mirrors matching active /// notifications through the desktop store's existing mutation path. func v2MobileNotificationFeedMarkRead(params: [String: Any]) -> V2CallResult { @@ -147,9 +248,9 @@ extension TerminalController { ]) } - private func mobileNotificationFeedPayload( + private func mobileNotificationFeedWireItem( _ record: NotificationFeedHistoryRecord - ) -> [String: Any] { + ) -> MobileNotificationFeedWireItem { let targetSurfaceID = record.panelId ?? record.surfaceId var targetWorkspaceID = record.tabId if record.retargetsToLiveSurfaceOwner, @@ -157,32 +258,69 @@ extension TerminalController { let liveTarget = AppDelegate.shared?.agentNotificationDeliveryTarget( claimedTabId: record.tabId, surfaceId: targetSurfaceID - ) { + ) { targetWorkspaceID = liveTarget.tabId } - var payload: [String: Any] = [ - "id": record.id.uuidString, - "workspace_id": targetWorkspaceID.uuidString, - "title": record.title, - "subtitle": record.subtitle, - "body": record.body, - "created_at": record.createdAt.timeIntervalSince1970, - "is_read": record.isRead, - "retargets_to_live_surface_owner": record.retargetsToLiveSurfaceOwner, - ] - if let targetSurfaceID { - payload["surface_id"] = targetSurfaceID.uuidString - } + var workspaceTitle: String? + var surfaceTitle: String? if let workspace = AppDelegate.shared? .tabManagerFor(tabId: targetWorkspaceID)? .workspacesById[targetWorkspaceID] { - payload["workspace_title"] = workspace.title + workspaceTitle = workspace.title if let targetSurfaceID, - let surfaceTitle = workspace.panelTitle(panelId: targetSurfaceID) { - payload["surface_title"] = surfaceTitle + let resolvedSurfaceTitle = workspace.panelTitle(panelId: targetSurfaceID) { + surfaceTitle = resolvedSurfaceTitle + } + } + return MobileNotificationFeedWireItem( + id: record.id.uuidString, + workspaceID: targetWorkspaceID.uuidString, + surfaceID: targetSurfaceID?.uuidString, + title: Self.mobileNotificationFeedString( + record.title, + limitedToUTF8Bytes: Self.mobileNotificationFeedTitleByteLimit + ), + subtitle: Self.mobileNotificationFeedString( + record.subtitle, + limitedToUTF8Bytes: Self.mobileNotificationFeedSubtitleByteLimit + ), + body: Self.mobileNotificationFeedString( + record.body, + limitedToUTF8Bytes: Self.mobileNotificationFeedBodyByteLimit + ), + createdAt: record.createdAt.timeIntervalSince1970, + isRead: record.isRead, + retargetsToLiveSurfaceOwner: record.retargetsToLiveSurfaceOwner, + workspaceTitle: workspaceTitle.map { + Self.mobileNotificationFeedString( + $0, + limitedToUTF8Bytes: Self.mobileNotificationFeedMetadataByteLimit + ) + }, + surfaceTitle: surfaceTitle.map { + Self.mobileNotificationFeedString( + $0, + limitedToUTF8Bytes: Self.mobileNotificationFeedMetadataByteLimit + ) } + ) + } + + private nonisolated static func mobileNotificationFeedString( + _ value: String, + limitedToUTF8Bytes maxBytes: Int + ) -> String { + guard maxBytes >= 0, value.utf8.count > maxBytes else { return value } + var byteCount = 0 + var endIndex = value.startIndex + while endIndex < value.endIndex { + let nextIndex = value.index(after: endIndex) + let characterByteCount = value[endIndex.. Set? { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c39757b08f93..f25a1c323097 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -14032,7 +14032,10 @@ class TerminalController { case "notification.reconcile": result = v2MobileNotificationReconcile(params: request.params) case "notification.feed.list": - result = v2MobileNotificationFeedList(params: request.params) + result = await v2MobileNotificationFeedList( + params: request.params, + responseID: request.id.map { String(describing: $0) } + ) case "notification.feed.mark_read": result = v2MobileNotificationFeedMarkRead(params: request.params) case "notification.feed.mark_unread": diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3c07dcfed45b..efa73427fa8b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1138,6 +1138,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 7A1B2C3D4E5F60718293A4B6 /* MobileHostTerminalThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4B5 /* MobileHostTerminalThemeTests.swift */; }; C1A070000000000000000005 /* MobileHostTransportAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000015 /* MobileHostTransportAuthorization.swift */; }; C0DE73840000000000000001 /* MobileHostWorkspaceTicketAuthorizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE73840000000000000002 /* MobileHostWorkspaceTicketAuthorizationTests.swift */; }; + C0DEFEF90000000000000001 /* MobileNotificationFeedWireItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF90000000000000002 /* MobileNotificationFeedWireItem.swift */; }; 9B08F916D7FF7626C6820727 /* MobilePairingConnectionTransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4880BD05924400E5279D632 /* MobilePairingConnectionTransitionTests.swift */; }; 0099C2865D9D468747D14593 /* MobilePairingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CE82AE8B39EA0C1395F696 /* MobilePairingModel.swift */; }; 325AF8814443BDA97AC3CC98 /* MobilePairingQRImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76EC5E03C345BEAC25828A7 /* MobilePairingQRImageView.swift */; }; @@ -1179,11 +1180,20 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06552010000000000000004 /* NotificationBurstCoalescer.swift */; }; 7490C00D7490C00D7490C00D /* NotificationCacheMemoryPressureResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D00D7490D00D7490D00D /* NotificationCacheMemoryPressureResponder.swift */; }; D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */; }; + C0DEFEF00000000000000001 /* NotificationFeedHistoryInsertionChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF00000000000000002 /* NotificationFeedHistoryInsertionChange.swift */; }; + C0DEFEF10000000000000001 /* NotificationFeedHistoryMutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF10000000000000002 /* NotificationFeedHistoryMutation.swift */; }; + C0DEFEF20000000000000001 /* NotificationFeedHistoryMutationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF20000000000000002 /* NotificationFeedHistoryMutationResult.swift */; }; + C0DEFEF30000000000000001 /* NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF30000000000000002 /* NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift */; }; + C0DEFEF40000000000000001 /* NotificationFeedHistoryOversizedSnapshotMigrationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF40000000000000002 /* NotificationFeedHistoryOversizedSnapshotMigrationError.swift */; }; + C0DEFEF50000000000000001 /* NotificationFeedHistoryOversizedSnapshotRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF50000000000000002 /* NotificationFeedHistoryOversizedSnapshotRecovery.swift */; }; C0DEFEE30000000000000001 /* NotificationFeedHistoryPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE30000000000000002 /* NotificationFeedHistoryPersistence.swift */; }; C0DEFEE10000000000000001 /* NotificationFeedHistoryRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE10000000000000002 /* NotificationFeedHistoryRecord.swift */; }; + C0DEFEF60000000000000001 /* NotificationFeedHistoryRetainedActiveNotificationHeap.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF60000000000000002 /* NotificationFeedHistoryRetainedActiveNotificationHeap.swift */; }; C0DEFEE20000000000000001 /* NotificationFeedHistorySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE20000000000000002 /* NotificationFeedHistorySnapshot.swift */; }; + C0DEFEF70000000000000001 /* NotificationFeedHistorySnapshotHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF70000000000000002 /* NotificationFeedHistorySnapshotHeader.swift */; }; C0DEFEE40000000000000001 /* NotificationFeedHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE40000000000000002 /* NotificationFeedHistoryStore.swift */; }; C0DEFEE50000000000000001 /* NotificationFeedHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE50000000000000002 /* NotificationFeedHistoryTests.swift */; }; + C0DEFEF80000000000000001 /* NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF80000000000000002 /* NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift */; }; D7769000000000000000000B /* NotificationPopoverHoverTrackingRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7769000000000000000000C /* NotificationPopoverHoverTrackingRepresentable.swift */; }; D7769000000000000000000D /* NotificationPopoverHoverTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7769000000000000000000E /* NotificationPopoverHoverTrackingView.swift */; }; D77690000000000000000003 /* NotificationPopoverRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000004 /* NotificationPopoverRow.swift */; }; @@ -3383,6 +3393,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7A1B2C3D4E5F60718293A4B5 /* MobileHostTerminalThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostTerminalThemeTests.swift; sourceTree = ""; }; C1A070000000000000000015 /* MobileHostTransportAuthorization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostTransportAuthorization.swift; sourceTree = ""; }; C0DE73840000000000000002 /* MobileHostWorkspaceTicketAuthorizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileHostWorkspaceTicketAuthorizationTests.swift; sourceTree = ""; }; + C0DEFEF90000000000000002 /* MobileNotificationFeedWireItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileNotificationFeedWireItem.swift; sourceTree = ""; }; A4880BD05924400E5279D632 /* MobilePairingConnectionTransitionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobilePairingConnectionTransitionTests.swift; sourceTree = ""; }; 04CE82AE8B39EA0C1395F696 /* MobilePairingModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingModel.swift"; sourceTree = ""; }; B76EC5E03C345BEAC25828A7 /* MobilePairingQRImageView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingQRImageView.swift"; sourceTree = ""; }; @@ -3424,11 +3435,20 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C06552010000000000000004 /* NotificationBurstCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBurstCoalescer.swift; sourceTree = ""; }; 7490D00D7490D00D7490D00D /* NotificationCacheMemoryPressureResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/NotificationCacheMemoryPressureResponder.swift; sourceTree = ""; }; D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationDismissSyncTests.swift"; sourceTree = ""; }; + C0DEFEF00000000000000002 /* NotificationFeedHistoryInsertionChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryInsertionChange.swift; sourceTree = ""; }; + C0DEFEF10000000000000002 /* NotificationFeedHistoryMutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryMutation.swift; sourceTree = ""; }; + C0DEFEF20000000000000002 /* NotificationFeedHistoryMutationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryMutationResult.swift; sourceTree = ""; }; + C0DEFEF30000000000000002 /* NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift; sourceTree = ""; }; + C0DEFEF40000000000000002 /* NotificationFeedHistoryOversizedSnapshotMigrationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryOversizedSnapshotMigrationError.swift; sourceTree = ""; }; + C0DEFEF50000000000000002 /* NotificationFeedHistoryOversizedSnapshotRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryOversizedSnapshotRecovery.swift; sourceTree = ""; }; C0DEFEE30000000000000002 /* NotificationFeedHistoryPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryPersistence.swift; sourceTree = ""; }; C0DEFEE10000000000000002 /* NotificationFeedHistoryRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryRecord.swift; sourceTree = ""; }; + C0DEFEF60000000000000002 /* NotificationFeedHistoryRetainedActiveNotificationHeap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryRetainedActiveNotificationHeap.swift; sourceTree = ""; }; C0DEFEE20000000000000002 /* NotificationFeedHistorySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistorySnapshot.swift; sourceTree = ""; }; + C0DEFEF70000000000000002 /* NotificationFeedHistorySnapshotHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistorySnapshotHeader.swift; sourceTree = ""; }; C0DEFEE40000000000000002 /* NotificationFeedHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryStore.swift; sourceTree = ""; }; C0DEFEE50000000000000002 /* NotificationFeedHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryTests.swift; sourceTree = ""; }; + C0DEFEF80000000000000002 /* NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift; sourceTree = ""; }; D7769000000000000000000C /* NotificationPopoverHoverTrackingRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationPopoverHoverTrackingRepresentable.swift; sourceTree = ""; }; D7769000000000000000000E /* NotificationPopoverHoverTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationPopoverHoverTrackingView.swift; sourceTree = ""; }; D77690000000000000000004 /* NotificationPopoverRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationPopoverRow.swift; sourceTree = ""; }; @@ -5608,10 +5628,20 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1F0A00500000000000000E2 /* MacPresenceDecisionCache.swift */, D1F0A00600000000000000F2 /* ScreenLockObserver.swift */, B7F00001 /* NotificationSoundSettings.swift */, + C0DEFEF90000000000000002 /* MobileNotificationFeedWireItem.swift */, + C0DEFEF00000000000000002 /* NotificationFeedHistoryInsertionChange.swift */, + C0DEFEF10000000000000002 /* NotificationFeedHistoryMutation.swift */, + C0DEFEF20000000000000002 /* NotificationFeedHistoryMutationResult.swift */, + C0DEFEF30000000000000002 /* NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift */, + C0DEFEF40000000000000002 /* NotificationFeedHistoryOversizedSnapshotMigrationError.swift */, + C0DEFEF50000000000000002 /* NotificationFeedHistoryOversizedSnapshotRecovery.swift */, C0DEFEE30000000000000002 /* NotificationFeedHistoryPersistence.swift */, C0DEFEE10000000000000002 /* NotificationFeedHistoryRecord.swift */, + C0DEFEF60000000000000002 /* NotificationFeedHistoryRetainedActiveNotificationHeap.swift */, + C0DEFEF70000000000000002 /* NotificationFeedHistorySnapshotHeader.swift */, C0DEFEE20000000000000002 /* NotificationFeedHistorySnapshot.swift */, C0DEFEE40000000000000002 /* NotificationFeedHistoryStore.swift */, + C0DEFEF80000000000000002 /* NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift */, A5F10000000000000000000C /* TerminalNotification.swift */, D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -7961,6 +7991,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 4C1A7E10B2D34F56A8C90011 /* MobileHostStatusVerificationLimiter.swift in Sources */, C7A50B000000000000000020 /* MobileHostTerminalTheme.swift in Sources */, C1A070000000000000000005 /* MobileHostTransportAuthorization.swift in Sources */, + C0DEFEF90000000000000001 /* MobileNotificationFeedWireItem.swift in Sources */, 0099C2865D9D468747D14593 /* MobilePairingModel.swift in Sources */, 325AF8814443BDA97AC3CC98 /* MobilePairingQRImageView.swift in Sources */, C1A070000000000000000006 /* MobilePairingView+Connected.swift in Sources */, @@ -7989,10 +8020,19 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE77060000000000000003 /* NewWorkspaceMenuModel.swift in Sources */, C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */, 7490C00D7490C00D7490C00D /* NotificationCacheMemoryPressureResponder.swift in Sources */, + C0DEFEF00000000000000001 /* NotificationFeedHistoryInsertionChange.swift in Sources */, + C0DEFEF10000000000000001 /* NotificationFeedHistoryMutation.swift in Sources */, + C0DEFEF20000000000000001 /* NotificationFeedHistoryMutationResult.swift in Sources */, + C0DEFEF30000000000000001 /* NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner.swift in Sources */, + C0DEFEF40000000000000001 /* NotificationFeedHistoryOversizedSnapshotMigrationError.swift in Sources */, + C0DEFEF50000000000000001 /* NotificationFeedHistoryOversizedSnapshotRecovery.swift in Sources */, C0DEFEE30000000000000001 /* NotificationFeedHistoryPersistence.swift in Sources */, C0DEFEE10000000000000001 /* NotificationFeedHistoryRecord.swift in Sources */, + C0DEFEF60000000000000001 /* NotificationFeedHistoryRetainedActiveNotificationHeap.swift in Sources */, C0DEFEE20000000000000001 /* NotificationFeedHistorySnapshot.swift in Sources */, + C0DEFEF70000000000000001 /* NotificationFeedHistorySnapshotHeader.swift in Sources */, C0DEFEE40000000000000001 /* NotificationFeedHistoryStore.swift in Sources */, + C0DEFEF80000000000000001 /* NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift in Sources */, D7769000000000000000000B /* NotificationPopoverHoverTrackingRepresentable.swift in Sources */, D7769000000000000000000D /* NotificationPopoverHoverTrackingView.swift in Sources */, D77690000000000000000003 /* NotificationPopoverRow.swift in Sources */, diff --git a/cmuxTests/NotificationFeedHistoryTests.swift b/cmuxTests/NotificationFeedHistoryTests.swift index 30c304a82624..02c1104dd40f 100644 --- a/cmuxTests/NotificationFeedHistoryTests.swift +++ b/cmuxTests/NotificationFeedHistoryTests.swift @@ -81,6 +81,760 @@ struct NotificationFeedHistoryTests { #expect(history.notifications.count == 5) } + @Test func totalRetentionCapsUnreadHistoryAtNewestRecords() { + let history = NotificationFeedHistoryStore( + fileURL: nil, + readRetentionLimit: 10, + totalRetentionLimit: 3 + ) + let workspaceID = UUID() + let baseDate = Date(timeIntervalSince1970: 1_000) + for offset in 0..<5 { + history.record( + notification( + workspaceID: workspaceID, + title: "Unread \(offset)", + date: baseDate.addingTimeInterval(Double(offset)), + isRead: false + ), + supersededIDs: [] + ) + } + + #expect(history.notifications.count == 3) + #expect(history.notifications.map(\.title) == ["Unread 4", "Unread 3", "Unread 2"]) + #expect(history.notifications.allSatisfy { !$0.isRead }) + } + + @Test func liveHistoryIngressNormalizesOversizedTextBeforeSnapshot() throws { + let history = NotificationFeedHistoryStore( + fileURL: nil, + readRetentionLimit: 10, + totalRetentionLimit: 2 + ) + history.record( + notification( + workspaceID: UUID(), + title: String(repeating: "t", count: NotificationFeedHistoryRecord.historyTitleByteLimit * 4), + body: String(repeating: "b", count: NotificationFeedHistoryRecord.historyBodyByteLimit * 4), + date: Date(timeIntervalSince1970: 1_260), + isRead: false + ), + supersededIDs: [] + ) + + let record = try #require(history.notifications.first) + #expect(record.title.utf8.count == NotificationFeedHistoryRecord.historyTitleByteLimit) + #expect(record.body.utf8.count == NotificationFeedHistoryRecord.historyBodyByteLimit) + #expect(history.snapshot.notifications.first?.body == record.body) + } + + @Test func oversizedActiveReconcileDoesNotChurnRevisionAfterRetentionTrim() { + var revisions: [Int] = [] + let history = NotificationFeedHistoryStore( + fileURL: nil, + readRetentionLimit: 10, + totalRetentionLimit: 3 + ) { revision in + revisions.append(revision) + } + let workspaceID = UUID() + let baseDate = Date(timeIntervalSince1970: 1_200) + let active = (0..<5).map { offset in + notification( + workspaceID: workspaceID, + title: "Active \(offset)", + date: baseDate.addingTimeInterval(Double(offset)), + isRead: false + ) + } + + history.reconcileActiveNotifications(active) + let retainedTitles = history.notifications.map(\.title) + let retainedRevision = history.revision + history.reconcileActiveNotifications(active) + + #expect(retainedTitles == ["Active 4", "Active 3", "Active 2"]) + #expect(history.notifications.map(\.title) == retainedTitles) + #expect(history.revision == retainedRevision) + #expect(revisions == [1]) + } + + @Test func activeReconcileCapsBeforeMaterializingHistoryRecords() { + let history = NotificationFeedHistoryStore( + fileURL: nil, + readRetentionLimit: 10, + totalRetentionLimit: 2 + ) + let workspaceID = UUID() + let dropped = notification( + workspaceID: workspaceID, + title: "Dropped oversized active", + body: String(repeating: "x", count: NotificationFeedHistoryRecord.historyBodyByteLimit * 8), + date: Date(timeIntervalSince1970: 1_250), + isRead: false + ) + let retainedOlder = notification( + workspaceID: workspaceID, + title: "Retained older", + date: Date(timeIntervalSince1970: 1_251), + isRead: false + ) + let retainedNewer = notification( + workspaceID: workspaceID, + title: "Retained newer", + date: Date(timeIntervalSince1970: 1_252), + isRead: false + ) + + history.reconcileActiveNotifications([dropped, retainedOlder, retainedNewer]) + + #expect(history.notifications.map(\.title) == ["Retained newer", "Retained older"]) + #expect(history.notifications.allSatisfy { + $0.body.utf8.count <= NotificationFeedHistoryRecord.historyBodyByteLimit + }) + } + + @Test func loadingOversizedHistoryPersistsCompactedSnapshot() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-compaction-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("history.json") + let workspaceID = UUID() + let baseDate = Date(timeIntervalSince1970: 1_500) + let records = (0..<5).map { offset in + NotificationFeedHistoryRecord(notification: notification( + workspaceID: workspaceID, + title: "Persisted unread \(offset)", + date: baseDate.addingTimeInterval(Double(offset)), + isRead: false + )) + } + _ = try write( + NotificationFeedHistorySnapshot( + revision: 4, + notifications: records + ), + to: fileURL + ) + + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3 + ) + let outcome = await persistence.load() + guard case .loaded(let loaded) = outcome else { + Issue.record("Expected compacted persisted notification feed") + return + } + + let loadedTitles = loaded.notifications.map(\.title) + #expect(loaded.revision == 4) + #expect(loadedTitles == ["Persisted unread 4", "Persisted unread 3", "Persisted unread 2"]) + + let persisted = try JSONDecoder().decode( + NotificationFeedHistorySnapshot.self, + from: Data(contentsOf: fileURL) + ) + #expect(persisted.revision == 4) + #expect(persisted.notifications.map(\.title) == loadedTitles) + } + + @Test func loadedLegacyHistoryNormalizesOversizedTextBeforeSnapshot() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-legacy-text-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("history.json") + let legacyRecord = NotificationFeedHistoryRecord(notification: notification( + workspaceID: UUID(), + title: String(repeating: "l", count: NotificationFeedHistoryRecord.historyTitleByteLimit * 4), + body: String(repeating: "g", count: NotificationFeedHistoryRecord.historyBodyByteLimit * 4), + date: Date(timeIntervalSince1970: 1_560), + isRead: false + )) + try write( + NotificationFeedHistorySnapshot( + revision: 11, + notifications: [legacyRecord] + ), + to: fileURL + ) + let history = NotificationFeedHistoryStore(fileURL: fileURL) + + try await waitUntil { + history.notifications.first?.body.utf8.count == NotificationFeedHistoryRecord.historyBodyByteLimit + } + let record = try #require(history.notifications.first) + #expect(record.title.utf8.count == NotificationFeedHistoryRecord.historyTitleByteLimit) + #expect(record.body.utf8.count == NotificationFeedHistoryRecord.historyBodyByteLimit) + #expect(history.snapshot.notifications.first?.title == record.title) + } + + @Test func oversizedHistoryFileIsQuarantinedWithMonotonicRevisionAndWritesRecover() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-size-limit-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("history.json") + let staleBackupURL = directory.appendingPathComponent( + "history.json.oversized-stale.quarantine", + isDirectory: false + ) + let workspaceID = UUID() + let baseDate = Date(timeIntervalSince1970: 1_600) + let records = (0..<5).reversed().map { offset in + NotificationFeedHistoryRecord(notification: notification( + workspaceID: workspaceID, + title: "Recovered \(offset)", + body: String(repeating: "x", count: 128), + date: baseDate.addingTimeInterval(Double(offset)), + isRead: false + )) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("stale backup".utf8).write(to: staleBackupURL) + let data = try write( + NotificationFeedHistorySnapshot( + revision: 6, + notifications: records + ), + to: fileURL, + sortedKeys: true + ) + let compactEncoder = JSONEncoder() + compactEncoder.outputFormatting = [.sortedKeys] + let compactData = try compactEncoder.encode(NotificationFeedHistorySnapshot( + revision: 6, + notifications: Array(records.prefix(3)) + )) + + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(compactData.count) + ) + #expect(data.count > compactData.count) + + let outcome = await persistence.load() + guard case .loaded(let loaded) = outcome else { + Issue.record("Expected oversized current-version notification feed to preserve revision") + return + } + #expect(loaded.revision == 6) + let loadedTitles = loaded.notifications.map(\.title) + #expect(loadedTitles == ["Recovered 4", "Recovered 3", "Recovered 2"]) + let replacement = try JSONDecoder().decode( + NotificationFeedHistorySnapshot.self, + from: Data(contentsOf: fileURL) + ) + #expect(replacement.revision == 6) + #expect(replacement.notifications.map(\.title) == loadedTitles) + let replacementQuarantines = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + #expect(replacementQuarantines.isEmpty) + #expect(!FileManager.default.fileExists(atPath: staleBackupURL.path)) + await persistence.persist(NotificationFeedHistorySnapshot( + revision: 7, + notifications: loaded.notifications + )) + let remainingQuarantines = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + #expect(remainingQuarantines.isEmpty) + let recovered = try JSONDecoder().decode( + NotificationFeedHistorySnapshot.self, + from: Data(contentsOf: fileURL) + ) + #expect(recovered.revision == 7) + #expect(recovered.notifications.map(\.title) == loadedTitles) + + try FileManager.default.removeItem(at: fileURL) + let verifier = NotificationFeedHistoryPersistence(fileURL: fileURL, fileManager: .default) + #expect(await verifier.load() == .missing) + } + + @Test func oversizedFutureSnapshotIsPreservedReadOnly() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-oversized-future-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("history.json") + let workspaceID = UUID() + let futureSnapshot = NotificationFeedHistorySnapshot( + revision: 14, + notifications: [ + NotificationFeedHistoryRecord(notification: notification( + workspaceID: workspaceID, + title: "Future large", + body: String(repeating: "f", count: 1_024), + date: Date(timeIntervalSince1970: 1_650), + isRead: false + )) + ], + version: NotificationFeedHistorySnapshot.currentVersion + 1 + ) + let originalData = try write(futureSnapshot, to: fileURL, sortedKeys: true) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(originalData.count - 1) + ) + + #expect(await persistence.load() == .unsupportedVersion(futureSnapshot.version)) + await persistence.persist(NotificationFeedHistorySnapshot(revision: 15, notifications: [])) + + let finalData = try Data(contentsOf: fileURL) + #expect(finalData == originalData) + let quarantinedURLs = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized-") + } + #expect(quarantinedURLs.isEmpty) + } + + @Test func oversizedFutureSnapshotIgnoresNestedMetadataInPrefix() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-nested-metadata-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("history.json") + let id = UUID().uuidString + let workspaceID = UUID().uuidString + let body = String(repeating: "x", count: 70_000) + let rawJSON = """ + {"notifications":[{"revision":6,"version":1,"body":"\(body)","createdAt":0,"id":"\(id)","isRead":false,"retargetsToLiveSurfaceOwner":false,"subtitle":"Agent","tabId":"\(workspaceID)","title":"Nested metadata"}],"revision":14,"version":\(NotificationFeedHistorySnapshot.currentVersion + 1)} + """ + let data = Data(rawJSON.utf8) + try data.write(to: fileURL, options: .atomic) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(data.count - 1) + ) + + #expect(await persistence.load() == .unsupportedVersion(NotificationFeedHistorySnapshot.currentVersion + 1)) + #expect(try Data(contentsOf: fileURL) == data) + let quarantinedURLs = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + #expect(quarantinedURLs.isEmpty) + } + + @Test func oversizedFutureSnapshotIgnoresNestedMetadataInTail() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-tail-metadata-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("history.json") + let id = UUID().uuidString + let workspaceID = UUID().uuidString + let body = String(repeating: "x", count: 70_000) + let rawJSON = """ + {"notifications":[{"body":"\(body)","createdAt":0,"id":"\(id)","isRead":false,"retargetsToLiveSurfaceOwner":false,"subtitle":"Agent","tabId":"\(workspaceID)","title":"Tail metadata"}],"revision":18,"summary":{"version":1},"version":\(NotificationFeedHistorySnapshot.currentVersion + 1)} + """ + let data = Data(rawJSON.utf8) + try data.write(to: fileURL, options: .atomic) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(data.count - 1) + ) + + #expect(await persistence.load() == .unsupportedVersion(NotificationFeedHistorySnapshot.currentVersion + 1)) + #expect(try Data(contentsOf: fileURL) == data) + let quarantinedURLs = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + #expect(quarantinedURLs.isEmpty) + } + + @Test func oversizedCurrentSnapshotReadsMetadataFromTailAndWritesRecover() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-current-tail-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("history.json") + let id = UUID().uuidString + let workspaceID = UUID().uuidString + let body = String(repeating: "x", count: 70_000) + let rawJSON = """ + {"notifications":[{"body":"\(body)","createdAt":0,"id":"\(id)","isRead":false,"retargetsToLiveSurfaceOwner":false,"subtitle":"Agent","tabId":"\(workspaceID)","title":"Current tail"}],"revision":21,"version":\(NotificationFeedHistorySnapshot.currentVersion)} + """ + let data = Data(rawJSON.utf8) + try data.write(to: fileURL, options: .atomic) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(data.count - 1) + ) + + let outcome = await persistence.load() + guard case .loaded(let loaded) = outcome else { + Issue.record("Expected oversized current-version history to recover from tail metadata") + return + } + #expect(loaded.revision == 21) + let loadedRecord = try #require(loaded.notifications.first) + #expect(loadedRecord.title == "Current tail") + #expect(loadedRecord.body.utf8.count == NotificationFeedHistoryRecord.historyBodyByteLimit) + let migrationQuarantines = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + #expect(migrationQuarantines.isEmpty) + await persistence.persist(NotificationFeedHistorySnapshot( + revision: 22, + notifications: loaded.notifications + )) + let recovered = try JSONDecoder().decode( + NotificationFeedHistorySnapshot.self, + from: Data(contentsOf: fileURL) + ) + #expect(recovered.revision == 22) + #expect(recovered.notifications.map(\.title) == ["Current tail"]) + } + + @Test func oversizedCurrentSnapshotMigrationScanBudgetRecoversWritableSnapshot() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-scan-budget-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("history.json") + let id = UUID().uuidString + let workspaceID = UUID().uuidString + let body = String(repeating: "x", count: 70_000) + let rawJSON = """ + {"notifications":[{"body":"\(body)","createdAt":0,"id":"\(id)","isRead":false,"retargetsToLiveSurfaceOwner":false,"subtitle":"Agent","tabId":"\(workspaceID)","title":"Budget"}],"revision":22,"version":\(NotificationFeedHistorySnapshot.currentVersion)} + """ + let data = Data(rawJSON.utf8) + try data.write(to: fileURL, options: .atomic) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(data.count - 1), + oversizedSnapshotMigrationScanByteLimit: 128 + ) + + #expect(await persistence.load() == .loaded(NotificationFeedHistorySnapshot( + revision: 22, + notifications: [] + ))) + let recovered = try JSONDecoder().decode( + NotificationFeedHistorySnapshot.self, + from: Data(contentsOf: fileURL) + ) + #expect(recovered.revision == 22) + #expect(recovered.notifications.isEmpty) + let migrationQuarantines = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + let backupURL = try #require(migrationQuarantines.first) + #expect(migrationQuarantines.count == 1) + #expect(try Data(contentsOf: backupURL) == data) + + let persisted = NotificationFeedHistoryRecord(notification: notification( + workspaceID: UUID(), + title: "Recovered", + date: Date(timeIntervalSince1970: 1), + isRead: false + )) + await persistence.persist(NotificationFeedHistorySnapshot( + revision: 23, + notifications: [persisted] + )) + let verifier = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3 + ) + let verified = await verifier.load() + guard case .loaded(let verifiedSnapshot) = verified else { + Issue.record("Expected recovered persisted snapshot, got \(verified)") + return + } + #expect(verifiedSnapshot.revision == 23) + #expect(verifiedSnapshot.notifications.map(\.title) == ["Recovered"]) + } + + @Test func oversizedCurrentSnapshotMigrationScanBudgetPreservesRecoveredPrefix() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-scan-prefix-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("history.json") + let firstID = UUID().uuidString + let secondID = UUID().uuidString + let firstWorkspaceID = UUID().uuidString + let secondWorkspaceID = UUID().uuidString + let largeBody = String(repeating: "x", count: 140_000) + let rawJSON = """ + {"notifications":[{"body":"Prefix body","createdAt":3,"id":"\(firstID)","isRead":false,"retargetsToLiveSurfaceOwner":false,"subtitle":"Agent","tabId":"\(firstWorkspaceID)","title":"Prefix"},{"body":"\(largeBody)","createdAt":2,"id":"\(secondID)","isRead":false,"retargetsToLiveSurfaceOwner":false,"subtitle":"Agent","tabId":"\(secondWorkspaceID)","title":"Tail"}],"revision":33,"version":\(NotificationFeedHistorySnapshot.currentVersion)} + """ + let data = Data(rawJSON.utf8) + try data.write(to: fileURL, options: .atomic) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(data.count - 1), + oversizedSnapshotMigrationScanByteLimit: 70_000 + ) + + let outcome = await persistence.load() + guard case .loaded(let snapshot) = outcome else { + Issue.record("Expected recovered prefix snapshot, got \(outcome)") + return + } + #expect(snapshot.revision == 33) + #expect(snapshot.notifications.map(\.title) == ["Prefix"]) + let recovered = try JSONDecoder().decode( + NotificationFeedHistorySnapshot.self, + from: Data(contentsOf: fileURL) + ) + #expect(recovered.notifications.map(\.title) == ["Prefix"]) + let migrationQuarantines = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("history.json.oversized") + } + let backupURL = try #require(migrationQuarantines.first) + #expect(migrationQuarantines.count == 1) + #expect(try Data(contentsOf: backupURL) == data) + } + + @Test func oversizedCurrentSnapshotScannerCapsEscapedTopLevelKeyBytes() throws { + var scanner = NotificationFeedHistoryOversizedCurrentSnapshotRecordScanner( + maxRecordBytes: 1_024 + ) + let keepScanning: (Data) throws -> Bool = { _ in true } + + #expect(try scanner.consume(Data("{\"".utf8), onRecord: keepScanning)) + #expect(try scanner.consume( + Data(String(repeating: "\\\"", count: 512).utf8), + onRecord: keepScanning + )) + #expect(scanner.topLevelKeyBufferByteCountForTesting == 0) + } + + @Test func oversizedHistoryMetadataIntegerOverflowFailsClosed() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-overflow-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("history.json") + let data = Data( + #"{"notifications":[],"revision":999999999999999999999999999999999999,"version":1}"#.utf8 + ) + try data.write(to: fileURL, options: .atomic) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: UInt64(data.count - 1) + ) + + #expect(await persistence.load() == .corrupt) + #expect(try Data(contentsOf: fileURL) == data) + } + + @Test func missingCanonicalHistoryRestoresNewestQuarantineBackup() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-orphaned-quarantine-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("history.json") + let backupURL = directory.appendingPathComponent( + "history.json.oversized-latest.quarantine", + isDirectory: false + ) + let workspaceID = UUID() + let record = NotificationFeedHistoryRecord(notification: notification( + workspaceID: workspaceID, + title: "Restored backup", + date: Date(timeIntervalSince1970: 1_660), + isRead: false + )) + try write( + NotificationFeedHistorySnapshot( + revision: 16, + notifications: [record] + ), + to: backupURL, + sortedKeys: true + ) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3 + ) + + let outcome = await persistence.load() + guard case .loaded(let loaded) = outcome else { + Issue.record("Expected missing canonical history to restore backup") + return + } + #expect(loaded.revision == 16) + #expect(loaded.notifications.map(\.title) == ["Restored backup"]) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + #expect(!FileManager.default.fileExists(atPath: backupURL.path)) + } + + @Test func persistFitsSnapshotToLoadBudgetBeforeWriting() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("notification-feed-persist-budget-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("history.json") + let workspaceID = UUID() + let largeRecord = NotificationFeedHistoryRecord(notification: notification( + workspaceID: workspaceID, + title: String(repeating: "t", count: 10_000), + body: String(repeating: "b", count: 10_000), + date: Date(timeIntervalSince1970: 1_700), + isRead: false + )) + let persistence = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: 512 + ) + + await persistence.persist(NotificationFeedHistorySnapshot( + revision: 1, + notifications: [largeRecord] + )) + + let verifier = NotificationFeedHistoryPersistence( + fileURL: fileURL, + fileManager: .default, + readRetentionLimit: 10, + totalRetentionLimit: 3, + maxSnapshotBytes: 512 + ) + let outcome = await verifier.load() + guard case .loaded(let loaded) = outcome else { + Issue.record("Expected persisted notification feed to stay loadable") + return + } + #expect(loaded.revision == 1) + #expect(loaded.notifications.isEmpty) + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let size = try #require(attributes[.size] as? NSNumber) + #expect(size.uint64Value <= 512) + } + + @Test func feedListResponseStaysWithinMobileFrameLimit() async throws { + let store = TerminalNotificationStore.shared + let workspaceID = UUID() + let baseDate = Date(timeIntervalSince1970: 1_800) + let body = String(repeating: "x", count: 8_192) + let notifications = (0.. TerminalNotification { @@ -410,7 +1165,7 @@ struct NotificationFeedHistoryTests { surfaceId: surfaceID, title: title, subtitle: "Agent", - body: "Body", + body: body, createdAt: date, isRead: isRead ) @@ -428,13 +1183,18 @@ struct NotificationFeedHistoryTests { @discardableResult private func write( _ snapshot: NotificationFeedHistorySnapshot, - to fileURL: URL + to fileURL: URL, + sortedKeys: Bool = false ) throws -> Data { try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - let data = try JSONEncoder().encode(snapshot) + let encoder = JSONEncoder() + if sortedKeys { + encoder.outputFormatting = [.sortedKeys] + } + let data = try encoder.encode(snapshot) try data.write(to: fileURL, options: .atomic) return data } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 32540f7acb2f..292ca2051b95 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3707,6 +3707,57 @@ } } }, + "mobile.notificationFeed.search.empty.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try another title, message, workspace, pane, or computer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "別のタイトル、メッセージ、ワークスペース、ペイン、またはコンピュータをお試しください。" + } + } + } + }, + "mobile.notificationFeed.search.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致する通知はありません" + } + } + } + }, + "mobile.notificationFeed.search.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を検索" + } + } + } + }, "mobile.notificationFeed.title": { "extractionState": "manual", "localizations": { @@ -12173,6 +12224,23 @@ } } }, + "mobile.workspaces.search.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを検索" + } + } + } + }, "mobile.workspaces.settings": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 45e6918c72f5..b8f0982f2e18 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -355,72 +355,201 @@ final class cmuxUITests: XCTestCase { } @MainActor - func testWorkspaceSearchPreservesQueryAndPlacementAcrossRefresh() throws { + func testWorkspaceSearchIsMinimizedAndPreservesQueryAcrossRefresh() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("The detached workspace search control requires iOS 26.") + } let app = launchApp(mockData: false, environment: [ "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS": "1", ]) defer { app.terminate() } - let scroll = app.descendants(matching: .any)["MobileWorkspaceList"] - XCTAssertTrue(scroll.waitForExistence(timeout: 8)) + let workspaceListTables = app.tables.matching( + NSPredicate(format: "identifier == %@", "MobileWorkspaceList") + ) + guard waitForVisibleElement(in: workspaceListTables, app: app, timeout: 8) != nil else { + XCTFail("Workspace list never became visible") + return + } XCTAssertTrue( app.descendants(matching: .any)["MobileWorkspaceListRefreshGeneration-0"] .waitForExistence(timeout: 3) ) - let searchField = app.searchFields.firstMatch - XCTAssertTrue(searchField.waitForExistence(timeout: 3)) + let minimizedSearchMatches = app.tabBars.buttons + .matching(NSPredicate(format: "label == %@", "Search")) + XCTAssertEqual(minimizedSearchMatches.count, 1) + let minimizedSearch = minimizedSearchMatches + .firstMatch + XCTAssertTrue(minimizedSearch.waitForExistence(timeout: 3)) + let workspacesTab = app.tabBars.buttons["Workspaces"] + XCTAssertTrue(workspacesTab.waitForExistence(timeout: 3)) + let searchField = app.searchFields["Search workspaces"] + guard let minimizedSearchFrame = waitForUsableFrame(of: minimizedSearch, timeout: 3) else { + XCTFail("Workspace search orb had no usable frame") + return + } + XCTAssertGreaterThan( + minimizedSearchFrame.midY, + app.frame.midY, + "Workspace search should sit beside the bottom tab bar" + ) + XCTAssertGreaterThanOrEqual( + minimizedSearchFrame.height, + workspacesTab.frame.height, + "Workspace search should not be shorter than a primary tab control" + ) + XCTAssertEqual( + minimizedSearchFrame.midY, + workspacesTab.frame.midY, + accuracy: 1, + "Workspace search and primary tabs should be vertically aligned" + ) + let docsRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-docs"] + let mainRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"] + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForHittable(mainRow, timeout: 3)) + tap(minimizedSearch, in: app) + + XCTAssertTrue(waitForHittable(searchField, timeout: 3)) XCTAssertTrue(focusTextInput(searchField, in: app)) searchField.typeText("Docs") - let docsRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-docs"] - let mainRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"] - XCTAssertTrue(docsRow.waitForExistence(timeout: 3)) - XCTAssertTrue(mainRow.waitForNonExistence(timeout: 3)) + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForNotHittable(mainRow, timeout: 3)) - let searchKey = app.keyboards.buttons["Search"] - XCTAssertTrue(searchKey.waitForExistence(timeout: 3)) - searchKey.tap() - if !waitForKeyboardDismissal(in: app), searchKey.exists { - searchKey.tap() - } + tap(docsRow, in: app) + let workspaceDetail = app.descendants(matching: .any)["FixtureWorkspaceDetail"] + XCTAssertTrue(workspaceDetail.waitForExistence(timeout: 3)) + XCTAssertTrue(minimizedSearch.waitForNonExistence(timeout: 3)) + + let backButton = app.buttons["MobileWorkspaceBackButton"] + XCTAssertTrue(waitForHittable(backButton, timeout: 3)) + tap(backButton, in: app) + XCTAssertNotNil(waitForVisibleElement(in: workspaceListTables, app: app, timeout: 3)) + XCTAssertTrue(minimizedSearch.waitForExistence(timeout: 3)) XCTAssertTrue(waitForKeyboardDismissal(in: app)) + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForNotHittable(mainRow, timeout: 3)) - let beforeRefreshField = app.searchFields.firstMatch - XCTAssertEqual(beforeRefreshField.value as? String, "Docs") - guard let beforeRefreshFrame = waitForUsableFrame(of: beforeRefreshField, timeout: 3) else { - XCTFail("Search field had no usable frame before refresh") + let previewRefreshButtons = app.buttons.matching( + NSPredicate(format: "identifier == %@", "MobileWorkspaceListPreviewRefresh") + ) + guard let previewRefresh = waitForVisibleElement(in: previewRefreshButtons, app: app, timeout: 3) else { + XCTFail("Visible preview refresh trigger disappeared after leaving Search") return } - XCTAssertLessThan( - beforeRefreshFrame.midY, - app.windows.firstMatch.frame.midY, - "Workspace search must remain in the navigation bar" - ) - - let refreshStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) - let refreshEnd = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) - refreshStart.press(forDuration: 0.01, thenDragTo: refreshEnd) + tap(previewRefresh, in: app) XCTAssertTrue( app.descendants(matching: .any)["MobileWorkspaceListRefreshGeneration-1"] .waitForExistence(timeout: 5), - "Pull-to-refresh did not replace the preview workspace snapshot" + "Preview refresh did not replace the workspace snapshot" ) - let afterRefreshField = app.searchFields.firstMatch - XCTAssertTrue(afterRefreshField.waitForExistence(timeout: 3)) - XCTAssertEqual(afterRefreshField.value as? String, "Docs") - XCTAssertTrue(docsRow.waitForExistence(timeout: 3)) - XCTAssertTrue(mainRow.waitForNonExistence(timeout: 3)) - guard let afterRefreshFrame = waitForUsableFrame(of: afterRefreshField, timeout: 3) else { - XCTFail("Search field had no usable frame after refresh") - return + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForNotHittable(mainRow, timeout: 3)) + let restoredMinimizedSearchMatches = app.tabBars.buttons + .matching(NSPredicate(format: "label == %@", "Search")) + XCTAssertEqual(restoredMinimizedSearchMatches.count, 1) + XCTAssertTrue(restoredMinimizedSearchMatches.firstMatch.waitForExistence(timeout: 3)) + } + + @MainActor + func testWorkspaceSearchClearUpdatesResults() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("The detached workspace search control requires iOS 26.") } + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW_REORDER": "1", + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS": "1", + ]) + defer { app.terminate() } + + let workspaceList = app.descendants(matching: .any)["MobileWorkspaceList"] + XCTAssertTrue(workspaceList.waitForExistence(timeout: 8)) - XCTAssertEqual(afterRefreshFrame.minX, beforeRefreshFrame.minX, accuracy: 2) - XCTAssertEqual(afterRefreshFrame.minY, beforeRefreshFrame.minY, accuracy: 2) - XCTAssertEqual(afterRefreshFrame.width, beforeRefreshFrame.width, accuracy: 2) - XCTAssertEqual(afterRefreshFrame.height, beforeRefreshFrame.height, accuracy: 2) + let minimizedSearch = app.tabBars.buttons + .matching(NSPredicate(format: "label == %@", "Search")) + .firstMatch + XCTAssertTrue(minimizedSearch.waitForExistence(timeout: 3)) + + let docsRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-docs"] + let mainRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"] + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForHittable(mainRow, timeout: 3)) + + tap(minimizedSearch, in: app) + let searchField = app.searchFields["Search workspaces"] + XCTAssertTrue(waitForHittable(searchField, timeout: 3)) + XCTAssertTrue(focusTextInput(searchField, in: app)) + searchField.typeText("Docs") + + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForNotHittable(mainRow, timeout: 3)) + + XCTAssertTrue(focusTextInput(searchField, in: app)) + searchField.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 8)) + XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) + XCTAssertTrue(waitForHittable(mainRow, timeout: 3)) + } + + @MainActor + func testSearchRemainsStableAcrossPrimaryRoots() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("The detached workspace search control requires iOS 26.") + } + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW_REORDER": "1", + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS": "1", + ]) + defer { app.terminate() } + + let workspaceList = app.descendants(matching: .any)["MobileWorkspaceList"] + XCTAssertTrue(workspaceList.waitForExistence(timeout: 8)) + + let searchMatches = app.tabBars.buttons + .matching(NSPredicate(format: "label == %@", "Search")) + XCTAssertEqual(searchMatches.count, 1) + let searchButton = searchMatches.firstMatch + XCTAssertTrue(searchButton.waitForExistence(timeout: 3)) + guard let initialSearchFrame = waitForUsableFrame(of: searchButton, timeout: 3) else { + return XCTFail("Search button never acquired a usable initial frame") + } + + let workspaceRow = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"] + XCTAssertTrue(workspaceRow.waitForExistence(timeout: 3)) + workspaceRow.tap() + + let workspaceDetail = app.descendants(matching: .any)["FixtureWorkspaceDetail"] + XCTAssertTrue(workspaceDetail.waitForExistence(timeout: 3)) + XCTAssertTrue(searchButton.waitForNonExistence(timeout: 3)) + + let backButton = app.buttons["MobileWorkspaceBackButton"] + XCTAssertTrue(waitForHittable(backButton, timeout: 3)) + backButton.tap() + XCTAssertTrue(workspaceList.waitForExistence(timeout: 3)) + XCTAssertTrue(searchButton.waitForExistence(timeout: 3)) + XCTAssertEqual(searchMatches.count, 1) + + let notificationsTab = app.tabBars.buttons["Notifications"] + XCTAssertTrue(notificationsTab.waitForExistence(timeout: 3)) + notificationsTab.tap() + + XCTAssertTrue(app.staticTexts["Notification feed fixture"].waitForExistence(timeout: 3)) + XCTAssertTrue(searchButton.waitForExistence(timeout: 3)) + XCTAssertEqual(searchMatches.count, 1) + guard let notificationSearchFrame = waitForUsableFrame(of: searchButton, timeout: 3) else { + return XCTFail("Search button never acquired a usable notification frame") + } + XCTAssertEqual(notificationSearchFrame, initialSearchFrame) + + app.tabBars.buttons["Workspaces"].tap() + XCTAssertTrue(workspaceList.waitForExistence(timeout: 3)) + XCTAssertTrue(searchButton.waitForExistence(timeout: 3)) + XCTAssertEqual(searchMatches.count, 1) } @MainActor @@ -450,6 +579,63 @@ final class cmuxUITests: XCTestCase { } } + @MainActor + func testNotificationFeedSearchFiltersNotifications() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("The detached notification search control requires iOS 26.") + } + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_NOTIFICATION_FEED_PREVIEW": "1", + ]) + defer { app.terminate() } + + let feed = app.descendants(matching: .any)["MobileNotificationFeed"] + XCTAssertTrue(feed.waitForExistence(timeout: 8)) + + let matchingRow = app.descendants(matching: .any)[ + "MobileNotificationFeedRow-macbook-tests-passed" + ] + let nonmatchingRow = app.descendants(matching: .any)[ + "MobileNotificationFeedRow-studio-codex-approval" + ] + let readRow = app.descendants(matching: .any)[ + "MobileNotificationFeedRow-studio-localization-complete" + ] + XCTAssertTrue(waitForHittable(matchingRow, timeout: 3)) + XCTAssertTrue(waitForHittable(nonmatchingRow, timeout: 3)) + XCTAssertTrue(waitForHittable(readRow, timeout: 3)) + + let unreadFilter = app.descendants(matching: .any)["MobileNotificationFeedFilterUnread"] + XCTAssertTrue(waitForHittable(unreadFilter, timeout: 3)) + unreadFilter.tap() + XCTAssertTrue(unreadFilter.isSelected) + XCTAssertTrue(waitForNotHittable(readRow, timeout: 3)) + + let searchButton = app.tabBars.buttons + .matching(NSPredicate(format: "label == %@", "Search")) + .firstMatch + XCTAssertTrue(searchButton.waitForExistence(timeout: 3)) + tap(searchButton, in: app) + XCTAssertTrue(unreadFilter.isSelected) + XCTAssertTrue(waitForNotHittable(readRow, timeout: 3)) + + let searchField = app.searchFields["Search notifications"] + XCTAssertTrue(waitForHittable(searchField, timeout: 3)) + XCTAssertTrue(focusTextInput(searchField, in: app)) + searchField.typeText("Tests passed") + + XCTAssertTrue(waitForHittable(matchingRow, timeout: 3)) + XCTAssertTrue(waitForNotHittable(nonmatchingRow, timeout: 3)) + XCTAssertTrue(waitForNotHittable(readRow, timeout: 3)) + + matchingRow.tap() + let workspaceDestination = app.descendants(matching: .any)[ + "MobileNotificationFeedPreviewWorkspaceDestination" + ] + XCTAssertTrue(workspaceDestination.waitForExistence(timeout: 3)) + XCTAssertTrue(app.navigationBars["Release"].waitForExistence(timeout: 3)) + } + @MainActor func testSettingsCanDisableHapticsAndPersistThePreference() throws { var app = launchApp( @@ -3981,6 +4167,65 @@ final class cmuxUITests: XCTestCase { return app.descendants(matching: .any)["ChatComposerField"] } + @MainActor + private func waitForHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == true AND isHittable == true"), + object: element + ) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + @MainActor + private func waitForNotHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false OR isHittable == false"), + object: element + ) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + @MainActor + private func waitForVisibleElement( + identifier: String, + in app: XCUIApplication, + timeout: TimeInterval + ) -> XCUIElement? { + let query = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == %@", identifier)) + return waitForVisibleElement(in: query, app: app, timeout: timeout) + } + + @MainActor + private func waitForVisibleElement( + in query: XCUIElementQuery, + app: XCUIApplication, + timeout: TimeInterval + ) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let element = query.allElementsBoundByIndex.first(where: { element in + let frame = element.frame + return element.exists + && element.isHittable + && !frame.isNull + && !frame.isEmpty + && frame.intersects(app.frame) + }) { + return element + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return query.allElementsBoundByIndex.first(where: { element in + let frame = element.frame + return element.exists + && element.isHittable + && !frame.isNull + && !frame.isEmpty + && frame.intersects(app.frame) + }) + } + @MainActor private func waitForUsableFrame(of element: XCUIElement, timeout: TimeInterval) -> CGRect? { let deadline = Date().addingTimeInterval(timeout) @@ -5142,7 +5387,7 @@ final class cmuxUITests: XCTestCase { } let fallbackLabels = preferAddDeviceAccessoryDoneButton ? ["Done", "Return", "Next"] - : ["Done", "Next"] + : ["Done", "Return", "Search", "Next"] for label in fallbackLabels { let button = app.keyboards.buttons[label] if button.exists {