crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit a89d6a8f2e312bd4968b5d5cf7ad4a0c4caab2c2
parent 1eed8b9c855053a148a2f8d208b9bce93b3f0f16
Author: Michael Camilleri <[email protected]>
Date:   Fri, 12 Jun 2026 12:18:31 +0900

Log the worker's publish delivery counts in diagnostics

PushClient logged 'worker accepted' but discarded the response body,
so the new muted count — and delivered/removed/failed — were invisible
on-device, which is the only place Production delivery can often be
observed. sendAuthorized now returns the body and publish logs e.g.
'push(play): worker accepted (delivered=2 muted=1 removed=0 failed=0)'.
A pre-mutedKinds worker response or an unparseable body degrades to
the old log line.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Services/PushClient.swift | 32++++++++++++++++++++++++--------
MTests/Unit/PuzzleNotificationTextTests.swift | 20++++++++++++++++++++
2 files changed, 44 insertions(+), 8 deletions(-)

diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -206,7 +206,7 @@ final class PushClient { do { let body = try JSONSerialization.data(withJSONObject: payload) request.httpBody = body - let response = try await sendAuthorized( + let (data, response) = try await sendAuthorized( request, method: "POST", path: "/publish", @@ -215,7 +215,7 @@ final class PushClient { guard response.statusCode == 200 else { throw URLError(.badServerResponse) } - log("push(\(kind)): worker accepted") + log("push(\(kind)): worker accepted\(Self.deliverySummary(from: data))") } catch { log("push(\(kind)) failed: \(error.localizedDescription)") } @@ -242,6 +242,22 @@ final class PushClient { ) } + /// Formats the worker's publish response counts for the diagnostics log, + /// e.g. " (delivered=2 muted=1 removed=0 failed=0)". Production delivery + /// is only observable through the on-device log, so this is where "why + /// didn't that push arrive?" gets answered — `muted` in particular means + /// a recipient device turned that notification kind off in settings. + /// Returns "" for an unparseable body (e.g. a worker predating a count). + nonisolated static func deliverySummary(from data: Data) -> String { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return "" + } + let counts = ["delivered", "muted", "removed", "failed"].compactMap { key in + (json[key] as? Int).map { "\(key)=\($0)" } + } + return counts.isEmpty ? "" : " (\(counts.joined(separator: " ")))" + } + private static let iso8601: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -265,7 +281,7 @@ final class PushClient { ] let data = try JSONSerialization.data(withJSONObject: body) request.httpBody = data - let response = try await sendAuthorized( + let (_, response) = try await sendAuthorized( request, method: "POST", path: "/register", @@ -283,7 +299,7 @@ final class PushClient { ])) ?? Data() request.httpBody = data do { - let response = try await sendAuthorized( + let (_, response) = try await sendAuthorized( request, method: "DELETE", path: "/register", @@ -300,7 +316,7 @@ final class PushClient { method: String, path: String, body: Data - ) async throws -> HTTPURLResponse { + ) async throws -> (Data, HTTPURLResponse) { try await sendAuthorized( request, method: method, @@ -316,7 +332,7 @@ final class PushClient { path: String, body: Data, retryAfterRegistrationReset: Bool - ) async throws -> HTTPURLResponse { + ) async throws -> (Data, HTTPURLResponse) { var request = request request.setValue("application/json", forHTTPHeaderField: "Content-Type") let headers = try await authenticator.signedHeaders( @@ -327,7 +343,7 @@ final class PushClient { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } - let (_, response) = try await session.data(for: request) + let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -341,7 +357,7 @@ final class PushClient { retryAfterRegistrationReset: false ) } - return http + return (data, http) } private func assert204(_ response: HTTPURLResponse) throws { diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -197,4 +197,24 @@ struct NotificationMutedKindsTests { #expect(!muted.contains("accountJoined")) #expect(!muted.contains("accountSeen")) } + + @Test("Publish delivery summary formats the worker counts") + func deliverySummaryFormatsCounts() { + let body = Data(#"{"delivered":2,"removed":0,"muted":1,"failed":0}"#.utf8) + + #expect(PushClient.deliverySummary(from: body) == " (delivered=2 muted=1 removed=0 failed=0)") + } + + @Test("Publish delivery summary tolerates a pre-muted worker response") + func deliverySummaryOldWorker() { + let body = Data(#"{"delivered":3,"removed":1,"failed":0}"#.utf8) + + #expect(PushClient.deliverySummary(from: body) == " (delivered=3 removed=1 failed=0)") + } + + @Test("Publish delivery summary is empty for an unparseable body") + func deliverySummaryUnparseable() { + #expect(PushClient.deliverySummary(from: Data()) == "") + #expect(PushClient.deliverySummary(from: Data("ok".utf8)) == "") + } }