crossmate

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

commit c99a6b2c94ee9761432da1009d1102569083775b
parent 8b14ecb2be902030f1308945aa60ab7d66226861
Author: Michael Camilleri <[email protected]>
Date:   Mon,  6 Jul 2026 20:06:22 +0900

Name nudge notifications from the push payload

Nudge notifications could fall back to 'A player' even when the sender
had a display name. The visible body is rebuilt by the Notification
Service Extension from the encrypted PushPayload, and the nudge path
only supplied the event there; the sender's name was left behind in the
old clear-text body path.

This commit carries the current player name in the nudge PushPayload so
the extension can use it whenever the receiver has not set a nickname.
Nicknames still win because the existing rewrite path continues to
prefer the receiver's local nickname before falling back to
payload.playerName.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/SessionCoordinator.swift | 2+-
MTests/Unit/PushClientTests.swift | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 94 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -246,7 +246,7 @@ final class SessionCoordinator { puzzleTitle: plan.title, broadcast: true, excludeAddress: store.localPushAddress(gameID: gameID, authorID: localAuthorID), - broadcastPayload: PushPayload(event: .nudge), + broadcastPayload: PushPayload(event: .nudge, playerName: preferences.name), collapseID: PushClient.gameCollapseID(gameID), body: PuzzleNotificationText.nudgeBody( playerName: preferences.name, diff --git a/Tests/Unit/PushClientTests.swift b/Tests/Unit/PushClientTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Testing @@ -78,6 +79,58 @@ struct PushClientTests { #expect(PushClientURLProtocol.deleteAttempts == 0) } + @Test("broadcast payload includes sender name and injected puzzle title") + func broadcastPayloadCarriesSenderName() async throws { + PushClientURLProtocol.reset() + PushClientURLProtocol.setHandler { request, _ in + let status = request.url?.path == "/publish" ? 200 : 204 + return HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + } + defer { PushClientURLProtocol.reset() } + + let gameID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let contentKey = SymmetricKey(data: Data(repeating: 7, count: 32)) + let credentials = GamePushCredentials( + credID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + secret: Data(repeating: 3, count: 32).base64URLEncodedString() + ) + let client = makeClient() + client.gameCredentialResolver = { _ in credentials } + client.contentKeyResolver = { _ in contentKey } + + await client.publish( + kind: "nudge", + gameID: gameID, + addressees: [], + title: "Crossmate", + puzzleTitle: "Saturday Puzzle", + broadcast: true, + broadcastPayload: PushPayload(event: .nudge, playerName: "Alice"), + body: PuzzleNotificationText.nudgeBody( + playerName: "Alice", + puzzleTitle: "Saturday Puzzle" + ) + ) + + let publishRequest = try #require(PushClientURLProtocol.requests.last { $0.url?.path == "/publish" }) + let body = try #require(publishRequest.body) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["alertBody"] as? String == PushClient.genericAlertBody) + + let encrypted = try #require(json["enc"] as? String) + let payload = try #require(PushPayloadCipher.open(encrypted, key: contentKey)) + #expect(payload == PushPayload( + event: .nudge, + puzzleTitle: "Saturday Puzzle", + playerName: "Alice" + )) + } + private func makeClient() -> PushClient { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [PushClientURLProtocol.self] @@ -99,6 +152,10 @@ private final class PushClientURLProtocol: URLProtocol { state.deleteAttempts } + static var requests: [PushClientRecordedRequest] { + state.requests + } + static func reset() { state.reset() } @@ -129,10 +186,17 @@ private final class PushClientURLProtocol: URLProtocol { override func stopLoading() {} } +private struct PushClientRecordedRequest: Sendable { + let url: URL? + let method: String? + let body: Data? +} + private final class PushClientURLProtocolState: @unchecked Sendable { private let lock = NSLock() private var handler: (@Sendable (URLRequest, Int) -> HTTPURLResponse)? private var requestDeleteAttempts = 0 + private var recordedRequests: [PushClientRecordedRequest] = [] var deleteAttempts: Int { lock.lock() @@ -140,16 +204,24 @@ private final class PushClientURLProtocolState: @unchecked Sendable { return requestDeleteAttempts } + var requests: [PushClientRecordedRequest] { + lock.lock() + defer { lock.unlock() } + return recordedRequests + } + func reset() { lock.lock() handler = nil requestDeleteAttempts = 0 + recordedRequests = [] lock.unlock() } func resetRequests() { lock.lock() requestDeleteAttempts = 0 + recordedRequests = [] lock.unlock() } @@ -163,6 +235,11 @@ private final class PushClientURLProtocolState: @unchecked Sendable { let handler: (@Sendable (URLRequest, Int) -> HTTPURLResponse)? let deleteAttempt: Int lock.lock() + recordedRequests.append(PushClientRecordedRequest( + url: request.url, + method: request.httpMethod, + body: request.httpBody ?? request.httpBodyStream?.readAllData() + )) if request.httpMethod == "DELETE" { requestDeleteAttempts += 1 deleteAttempt = requestDeleteAttempts @@ -180,3 +257,19 @@ private final class PushClientURLProtocolState: @unchecked Sendable { )! } } + +private extension InputStream { + func readAllData() -> Data { + open() + defer { close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while hasBytesAvailable { + let count = read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + data.append(buffer, count: count) + } + return data + } +}