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:
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
+ }
+}