Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
012885e
feat(ios): add workspace-only bottom search
Jul 22, 2026
8288249
fix(ios): use native toolbar workspace search
Jul 22, 2026
2c2287b
fix(ios): integrate workspace search with tab bar
Jul 22, 2026
7c71bab
test(ios): cover persistent contextual search
azooz2003-bit Jul 23, 2026
eaecf92
fix(ios): keep search stable across primary tabs
azooz2003-bit Jul 23, 2026
1141f72
Merge remote-tracking branch 'origin/main' into task-ios-workspace-bo…
azooz2003-bit Jul 23, 2026
2fa4df3
test(ios): focus notification search assertion
azooz2003-bit Jul 23, 2026
4dc5f9e
fix(ios): make notification search resilient
azooz2003-bit Jul 23, 2026
4022dcb
fix(ios): close contextual search review feedback
azooz2003-bit Jul 23, 2026
9755d32
fix(ios): stabilize contextual search state
azooz2003-bit Jul 25, 2026
ade0028
merge origin/main into iOS search tab PR
azooz2003-bit Jul 25, 2026
763e9cb
fix(ios): preserve search query across submit cleanup
azooz2003-bit Jul 25, 2026
eb0daa9
fix(ios): address search review lifecycle
azooz2003-bit Jul 25, 2026
7e60ac7
fix(ios): isolate search state and projection work
azooz2003-bit Jul 25, 2026
425aa01
fix(ios): split search content from root lifecycle
azooz2003-bit Jul 25, 2026
64c1eb4
fix(ios): guard search activation ownership
azooz2003-bit Jul 25, 2026
97736d2
fix(ios): deactivate search before result navigation
azooz2003-bit Jul 25, 2026
64a4038
fix(ios): preserve search result navigation ownership
azooz2003-bit Jul 25, 2026
65237a8
fix(ios): keep notification search open until commit
azooz2003-bit Jul 25, 2026
7b7d607
fix(ios): isolate notification search navigation path
azooz2003-bit Jul 25, 2026
4e8a3c0
fix(ios): show workspace action failures from search
azooz2003-bit Jul 25, 2026
37824d9
fix(ios): bound notification feed search source
azooz2003-bit Jul 25, 2026
db74dcf
fix(ios): stabilize retained notification feed revisions
azooz2003-bit Jul 25, 2026
2264ffc
fix(ios): bound notification feed aggregation work
azooz2003-bit Jul 25, 2026
3afa599
fix(ios): keep notification feed persistence loadable
azooz2003-bit Jul 25, 2026
bdc7b37
fix(ios): bound notification feed wire recovery
azooz2003-bit Jul 25, 2026
390c94c
fix(ios): preserve bounded notification feed revisions
azooz2003-bit Jul 25, 2026
44efeb1
fix(ios): make notification feed recovery transactional
azooz2003-bit Jul 25, 2026
eafedd0
fix(ios): bound notification feed recovery scan
azooz2003-bit Jul 25, 2026
1d9bb7c
fix(ios): isolate search drafts and recovery metadata
azooz2003-bit Jul 25, 2026
03d0755
fix(ios): align notification feed quarantine naming
azooz2003-bit Jul 25, 2026
e370d33
fix(ios): bound notification feed history text
azooz2003-bit Jul 25, 2026
85582f3
fix(ios): bound notification feed ingress recovery
azooz2003-bit Jul 25, 2026
7d3e771
fix(ios): coalesce notification feed persistence
azooz2003-bit Jul 25, 2026
faed67e
fix(ios): parse notification feed history metadata safely
azooz2003-bit Jul 25, 2026
67a54ca
fix(ios): recover oversized notification feed metadata from tail
azooz2003-bit Jul 25, 2026
1b8879d
fix(ios): retain notification feed projections during rebuild
azooz2003-bit Jul 25, 2026
3c83691
fix(ios): disable stale notification rows during source rebuild
azooz2003-bit Jul 25, 2026
952050f
fix(ios): bound notification feed migration and decode
azooz2003-bit Jul 25, 2026
648c094
fix(ios): fail closed on oversized feed edge cases
azooz2003-bit Jul 25, 2026
7201cae
fix(ios): cancel notification feed decode workers
azooz2003-bit Jul 25, 2026
5c946a9
fix(ios): cap notification search source state
azooz2003-bit Jul 25, 2026
228883f
fix(ios): normalize primary search queries
azooz2003-bit Jul 25, 2026
210cca0
fix(ios): preserve editable search drafts
azooz2003-bit Jul 25, 2026
d418d55
fix(ios): preserve filtered notification empty states
azooz2003-bit Jul 25, 2026
916ea31
fix(mac): retire oversized history quarantines
azooz2003-bit Jul 25, 2026
b49f210
fix(ios): route notification links by search scope
azooz2003-bit Jul 25, 2026
a6310c3
fix(ios): preserve notification source tails
azooz2003-bit Jul 25, 2026
2dfd323
fix(mac): fit notification feed frames in one pass
azooz2003-bit Jul 25, 2026
484bdc7
fix(ios): aggregate scoped notification feeds
azooz2003-bit Jul 25, 2026
8ec3425
fix(mac): recover oversized history scan exhaustion
azooz2003-bit Jul 25, 2026
dfd88bd
fix(ios): target notification feed bulk scope
azooz2003-bit Jul 25, 2026
e044eab
fix(ios): satisfy notification search policy gates
azooz2003-bit Jul 25, 2026
73331dc
fix(ios): ignore initial native search cleanup
azooz2003-bit Jul 25, 2026
0304d5f
fix(ios): use native task composer toolbar on iOS 26
azooz2003-bit Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<MobileNotificationFeedListBoundedItem.CodingKeys>,
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<MobileNotificationFeedListBoundedItem.CodingKeys>,
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<MobileNotificationFeedListBoundedItem.CodingKeys>,
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..<nextIndex].utf8.count
guard byteCount + characterByteCount <= maxBytes else { break }
byteCount += characterByteCount
endIndex = nextIndex
}
return String(value[..<endIndex])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
struct MobileNotificationFeedListBoundedResponse: Decodable {
let response: MobileNotificationFeedListResponse

private enum CodingKeys: String, CodingKey {
case revision
case notifications
}

init(from decoder: any Decoder) throws {
let options = try mobileNotificationFeedListBoundedDecodeOptions(from: decoder)
let container = try decoder.container(keyedBy: CodingKeys.self)
let revision = try container.decode(Int.self, forKey: .revision)
var notificationsContainer = try container.nestedUnkeyedContainer(forKey: .notifications)
var notifications: [MobileNotificationFeedListItem] = []
notifications.reserveCapacity(min(options.maxNotifications, notificationsContainer.count ?? options.maxNotifications))
while !notificationsContainer.isAtEnd, notifications.count < options.maxNotifications {
try Task.checkCancellation()
if let item = try notificationsContainer.decode(MobileNotificationFeedListBoundedItem.self).item {
notifications.append(item)
}
}
response = MobileNotificationFeedListResponse(
revision: revision,
notifications: notifications
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@ public struct MobileNotificationFeedListItem: Decodable, Equatable, Sendable {
case surfaceTitle = "surface_title"
}

/// Creates a notification-feed list item from already-normalized values.
public init(
id: String,
workspaceID: String,
surfaceID: String?,
title: String,
subtitle: String?,
body: String,
createdAt: Date,
isRead: Bool,
retargetsToLiveSurfaceOwner: Bool,
workspaceTitle: String?,
surfaceTitle: String?
) {
self.id = id
self.workspaceID = workspaceID
self.surfaceID = surfaceID
self.title = title
self.subtitle = subtitle
self.body = body
self.createdAt = createdAt
self.isRead = isRead
self.retargetsToLiveSurfaceOwner = retargetsToLiveSurfaceOwner
self.workspaceTitle = workspaceTitle
self.surfaceTitle = surfaceTitle
}

/// Decodes one feed item from its wire representation.
/// - Parameter decoder: The JSON decoder for the item payload.
public init(from decoder: any Decoder) throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,45 @@ public struct MobileNotificationFeedListResponse: Decodable, Equatable, Sendable
/// The Mac's retained notifications, newest first.
public let notifications: [MobileNotificationFeedListItem]

/// Creates a feed list response from a revision and retained notifications.
public init(
revision: Int,
notifications: [MobileNotificationFeedListItem]
) {
self.revision = revision
self.notifications = notifications
}

/// Decodes a notification-feed list response.
/// - Parameter data: The raw RPC result payload.
/// - Returns: The decoded response.
/// - Throws: A decoding error when the payload violates the feed contract.
public static func decode(_ data: Data) throws -> 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"
)!
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading