commit c16306cb4c966d8e0a9ccca03d7f98ef374ed41f
parent eaf7bc01cfca8e1197eb40761ab97aff71582d0c
Author: Michael Camilleri <[email protected]>
Date: Tue, 26 May 2026 01:21:02 +0900
Replace WebRTC engagements with socket rooms
This commit removes the WebRTC-based engagements in favour a WebSocket-based
approach that uses native URLSession WebSocket transport. Engagements are
boostrapped with v2 hail payloads that share short-lived room credentials and
simplify the coordinator around idle/connecting/live socket states.
This commit also adds a Cloudflare Worker/Durable Object relay for
authenticated room sockets, including HMAC verification, nonce replay checks,
room expiry and peer fanout. The debug UI is updated as is app configuration
and engagement tests.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
16 files changed, 696 insertions(+), 1837 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -24,7 +24,6 @@
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
1F6644A367B3B8DD1F9CEB7B /* EngagementDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */; };
267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; };
- 2AA16C362CE4161447C21161 /* EngagementHost.html in Resources */ = {isa = PBXBuildFile; fileRef = 1BA85E43D4C590126DE84C5A /* EngagementHost.html */; };
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; };
2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
@@ -36,7 +35,6 @@
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; };
- 3EEACF81FE625692C48DE515 /* TURNCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C86626C072FF4C0630EC47 /* TURNCredentials.swift */; };
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; };
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
@@ -142,7 +140,6 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
- 03C86626C072FF4C0630EC47 /* TURNCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TURNCredentials.swift; sourceTree = "<group>"; };
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
@@ -156,7 +153,6 @@
16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; };
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; };
18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; };
- 1BA85E43D4C590126DE84C5A /* EngagementHost.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = EngagementHost.html; sourceTree = "<group>"; };
1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; };
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
@@ -486,7 +482,6 @@
56BC76178319D0D669CD50FF /* CloudService.swift */,
16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */,
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */,
- 1BA85E43D4C590126DE84C5A /* EngagementHost.html */,
18C701DAE36000DE19F7CC95 /* EngagementHost.swift */,
400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */,
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
@@ -498,7 +493,6 @@
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */,
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
- 03C86626C072FF4C0630EC47 /* TURNCredentials.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -586,7 +580,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 2AA16C362CE4161447C21161 /* EngagementHost.html in Resources */,
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */,
8B356C953DA0FAF149C3391A /* Puzzles in Resources */,
);
@@ -722,7 +715,6 @@
740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */,
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */,
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
- 3EEACF81FE625692C48DE515 /* TURNCredentials.swift in Sources */,
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */,
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */,
);
@@ -846,7 +838,7 @@
CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
- CROSSMATE_TURN_CREDENTIALS_URL = "$(inherited)";
+ CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)";
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -866,7 +858,7 @@
CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
- CROSSMATE_TURN_CREDENTIALS_URL = "$(inherited)";
+ CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)";
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist
@@ -38,8 +38,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CKSharingSupported</key>
<true/>
- <key>CrossmateTURNCredentialsURL</key>
- <string>$(CROSSMATE_TURN_CREDENTIALS_URL)</string>
+ <key>CrossmateEngagementSocketURL</key>
+ <string>$(CROSSMATE_ENGAGEMENT_SOCKET_URL)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -693,8 +693,6 @@ final class AppServices {
private func handleEngagementEvent(_ event: EngagementHost.Event) {
switch event {
- case .signal(let engagementID, _):
- syncMonitor.note("engagement: signal generated \(engagementID.uuidString)")
case .channelOpen(let engagementID):
syncMonitor.note("engagement: channel open \(engagementID.uuidString)")
Task { [weak self] in
@@ -1638,10 +1636,10 @@ final class AppServices {
await sessionMonitor.cancel(gameID: ping.gameID, authorID: ping.authorID)
}
applyInvitePings(pings)
- // `.friend` is the friendship-bootstrap handshake and `.hail` is RTC
- // signaling. Both are system-only: no alert, no notification
- // authorization dependency. Everything else goes through the alert
- // path below.
+ // `.friend` is the friendship-bootstrap handshake and `.hail` is
+ // engagement room bootstrap. Both are system-only: no alert, no
+ // notification authorization dependency. Everything else goes
+ // through the alert path below.
let (systemPings, playerFacingPings) = pings.partitioned {
$0.kind == .friend || $0.kind == .hail
}
diff --git a/Crossmate/Services/EngagementHost.html b/Crossmate/Services/EngagementHost.html
@@ -1,409 +0,0 @@
-<!doctype html>
-<html>
-<head>
- <meta charset="utf-8">
-</head>
-<body>
-<script>
-(() => {
- const peers = new Map();
- const closedEngagementIDs = new Set();
-
- function post(message) {
- window.webkit.messageHandlers.engagement.postMessage(message);
- }
-
- function postError(engagementID, error) {
- post({
- type: "onError",
- engagementID,
- message: error && error.message ? error.message : String(error)
- });
- }
-
- function postDiagnostic(engagementID, message) {
- post({
- type: "onDiagnostic",
- engagementID,
- message
- });
- }
-
- function defaultIceServers() {
- return [{ urls: "stun:stun.cloudflare.com:3478" }];
- }
-
- function iceServers(configuredIceServers) {
- if (Array.isArray(configuredIceServers) && configuredIceServers.length > 0) {
- return configuredIceServers;
- }
- return defaultIceServers();
- }
-
- function createPeer(engagementID, configuredIceServers) {
- teardown(engagementID);
- closedEngagementIDs.delete(engagementID);
-
- const pc = new RTCPeerConnection({
- iceServers: iceServers(configuredIceServers),
- iceTransportPolicy: "relay"
- });
- const peer = { pc, channel: null, closed: false };
- peers.set(engagementID, peer);
-
- pc.ondatachannel = (event) => {
- postDiagnostic(engagementID, "data channel received label=" + event.channel.label);
- attachChannel(engagementID, event.channel);
- };
- pc.onconnectionstatechange = () => {
- if (pc.connectionState === "failed" ||
- pc.connectionState === "disconnected" ||
- pc.connectionState === "closed") {
- postClose(engagementID, "connectionState");
- }
- };
- pc.oniceconnectionstatechange = () => {
- if (pc.iceConnectionState === "failed" ||
- pc.iceConnectionState === "disconnected" ||
- pc.iceConnectionState === "closed") {
- postClose(engagementID, "iceConnectionState");
- }
- };
- return peer;
- }
-
- function attachChannel(engagementID, channel) {
- const peer = peers.get(engagementID);
- if (!peer) {
- channel.close();
- return;
- }
- peer.channel = channel;
- channel.binaryType = "arraybuffer";
- postDiagnostic(
- engagementID,
- "data channel attached label=" + channel.label + " state=" + channel.readyState
- );
- channel.onopen = () => {
- postDiagnostic(
- engagementID,
- "data channel open label=" + channel.label + " buffered=" + channel.bufferedAmount
- );
- postStatsDiagnostic(engagementID, "channel-open");
- setTimeout(() => postStatsDiagnostic(engagementID, "channel-open+1s"), 1000);
- post({ type: "onChannelOpen", engagementID });
- };
- channel.onclose = () => postClose(engagementID, "dataChannel");
- channel.onerror = (event) => postError(engagementID, event.error || "Data channel error");
- channel.onmessage = async (event) => {
- try {
- let bytes;
- if (event.data instanceof ArrayBuffer) {
- bytes = new Uint8Array(event.data);
- } else if (event.data instanceof Blob) {
- bytes = new Uint8Array(await event.data.arrayBuffer());
- } else {
- bytes = new TextEncoder().encode(String(event.data));
- }
- postDiagnostic(
- engagementID,
- "data channel receive bytes=" + bytes.byteLength + " state=" + channel.readyState
- );
- postStatsDiagnostic(engagementID, "after-receive");
- post({
- type: "onChannelMessage",
- engagementID,
- message: bytesToBase64(bytes)
- });
- } catch (error) {
- postError(engagementID, error);
- }
- };
- }
-
- async function waitForIceComplete(pc) {
- if (pc.iceGatheringState === "complete") {
- return;
- }
- await new Promise((resolve) => {
- const onChange = () => {
- if (pc.iceGatheringState === "complete") {
- pc.removeEventListener("icegatheringstatechange", onChange);
- resolve();
- }
- };
- pc.addEventListener("icegatheringstatechange", onChange);
- });
- }
-
- function signalFromLocalDescription(pc) {
- const sdp = pc.localDescription ? pc.localDescription.sdp : "";
- return {
- sdp,
- candidates: candidatesFromSDP(sdp)
- };
- }
-
- function candidateTypeCounts(sdp) {
- const counts = { host: 0, srflx: 0, prflx: 0, relay: 0, other: 0 };
- for (const line of sdp.split(/\r?\n/)) {
- if (!line.startsWith("a=candidate:")) continue;
- const match = line.match(/ typ ([a-z]+)/);
- const type = match ? match[1] : "other";
- counts[type] = (counts[type] || 0) + 1;
- }
- return counts;
- }
-
- function postCandidateDiagnostic(engagementID, pc, role) {
- const sdp = pc.localDescription ? pc.localDescription.sdp : "";
- const counts = candidateTypeCounts(sdp);
- postDiagnostic(
- engagementID,
- "local candidates (" + role + ") host=" + counts.host +
- " srflx=" + counts.srflx +
- " relay=" + counts.relay +
- " other=" + counts.other
- );
- }
-
- function candidateSummary(report) {
- if (!report) {
- return "missing";
- }
- return [
- "type=" + (report.candidateType || "unknown"),
- "protocol=" + (report.protocol || "unknown"),
- "relayProtocol=" + (report.relayProtocol || "none")
- ].join("/");
- }
-
- function numberSummary(value) {
- return typeof value === "number" && Number.isFinite(value) ? String(value) : "n/a";
- }
-
- function decimalSummary(value) {
- return typeof value === "number" && Number.isFinite(value) ? value.toFixed(3) : "n/a";
- }
-
- function selectedCandidatePair(reports) {
- for (const report of reports.values()) {
- if (report.type === "transport" && report.selectedCandidatePairId) {
- return reports.get(report.selectedCandidatePairId);
- }
- }
- let fallback = null;
- for (const report of reports.values()) {
- if (report.type !== "candidate-pair") {
- continue;
- }
- if (report.selected || (report.nominated && report.state === "succeeded")) {
- return report;
- }
- if (report.state === "succeeded" && !fallback) {
- fallback = report;
- }
- }
- return fallback;
- }
-
- function dataChannelStats(reports) {
- for (const report of reports.values()) {
- if (report.type === "data-channel") {
- return report;
- }
- }
- return null;
- }
-
- async function postStatsDiagnostic(engagementID, label) {
- try {
- const peer = peers.get(engagementID);
- if (!peer) {
- postDiagnostic(engagementID, "stats " + label + " peer=missing");
- return;
- }
- const reports = await peer.pc.getStats();
- const pair = selectedCandidatePair(reports);
- const localCandidate = pair ? reports.get(pair.localCandidateId) : null;
- const remoteCandidate = pair ? reports.get(pair.remoteCandidateId) : null;
- const dataChannel = dataChannelStats(reports);
- const channel = peer.channel;
- postDiagnostic(
- engagementID,
- [
- "stats " + label,
- "pc=" + peer.pc.connectionState,
- "ice=" + peer.pc.iceConnectionState,
- "pairState=" + (pair ? pair.state : "missing"),
- "nominated=" + (pair ? String(Boolean(pair.nominated)) : "missing"),
- "local=" + candidateSummary(localCandidate),
- "remote=" + candidateSummary(remoteCandidate),
- "bytesSent=" + numberSummary(pair ? pair.bytesSent : undefined),
- "bytesReceived=" + numberSummary(pair ? pair.bytesReceived : undefined),
- "packetsSent=" + numberSummary(pair ? pair.packetsSent : undefined),
- "packetsReceived=" + numberSummary(pair ? pair.packetsReceived : undefined),
- "rtt=" + decimalSummary(pair ? pair.currentRoundTripTime : undefined),
- "dcState=" + (dataChannel ? dataChannel.state : (channel ? channel.readyState : "missing")),
- "dcMessagesSent=" + numberSummary(dataChannel ? dataChannel.messagesSent : undefined),
- "dcMessagesReceived=" + numberSummary(dataChannel ? dataChannel.messagesReceived : undefined),
- "dcBytesSent=" + numberSummary(dataChannel ? dataChannel.bytesSent : undefined),
- "dcBytesReceived=" + numberSummary(dataChannel ? dataChannel.bytesReceived : undefined),
- "buffered=" + (channel ? channel.bufferedAmount : "missing")
- ].join(" ")
- );
- } catch (error) {
- postDiagnostic(
- engagementID,
- "stats " + label + " failed=" + (error && error.message ? error.message : String(error))
- );
- }
- }
-
- function candidatesFromSDP(sdp) {
- return sdp
- .split(/\r?\n/)
- .filter((line) => line.startsWith("a=candidate:"))
- .map((line) => line.slice("a=".length));
- }
-
- function bytesToBase64(bytes) {
- let binary = "";
- for (let i = 0; i < bytes.length; i += 1) {
- binary += String.fromCharCode(bytes[i]);
- }
- return btoa(binary);
- }
-
- function base64ToBytes(base64) {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i += 1) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes;
- }
-
- function postClose(engagementID, reason) {
- if (closedEngagementIDs.has(engagementID)) {
- return;
- }
- closedEngagementIDs.add(engagementID);
- const peer = peers.get(engagementID);
- if (peer) {
- peer.closed = true;
- }
- postStatsDiagnostic(engagementID, "before-close-" + (reason || "unknown"));
- post({
- type: "onDiagnostic",
- engagementID,
- message: closeDiagnosticMessage(peer, reason)
- });
- post({ type: "onChannelClose", engagementID });
- }
-
- function closeDiagnosticMessage(peer, reason) {
- const pc = peer ? peer.pc : null;
- const channel = peer ? peer.channel : null;
- return [
- "close reason=" + (reason || "unknown"),
- "connection=" + (pc ? pc.connectionState : "missing"),
- "ice=" + (pc ? pc.iceConnectionState : "missing"),
- "gathering=" + (pc ? pc.iceGatheringState : "missing"),
- "channel=" + (channel ? channel.readyState : "missing")
- ].join(" ");
- }
-
- async function createOffer(engagementID, _signal, iceServers) {
- try {
- const peer = createPeer(engagementID, iceServers);
- const channel = peer.pc.createDataChannel("crossmate");
- attachChannel(engagementID, channel);
- await peer.pc.setLocalDescription(await peer.pc.createOffer());
- await waitForIceComplete(peer.pc);
- postCandidateDiagnostic(engagementID, peer.pc, "offer");
- const signal = signalFromLocalDescription(peer.pc);
- post({ type: "onSignal", engagementID, signal });
- return signal;
- } catch (error) {
- postError(engagementID, error);
- throw error;
- }
- }
-
- async function acceptOfferAndReply(engagementID, signal, iceServers) {
- try {
- const peer = createPeer(engagementID, iceServers);
- await peer.pc.setRemoteDescription({ type: "offer", sdp: signal.sdp });
- await peer.pc.setLocalDescription(await peer.pc.createAnswer());
- await waitForIceComplete(peer.pc);
- postCandidateDiagnostic(engagementID, peer.pc, "reply");
- const reply = signalFromLocalDescription(peer.pc);
- post({ type: "onSignal", engagementID, signal: reply });
- return reply;
- } catch (error) {
- postError(engagementID, error);
- throw error;
- }
- }
-
- async function acceptReply(engagementID, signal) {
- try {
- const peer = peers.get(engagementID);
- if (!peer) {
- throw new Error("No peer connection for engagement " + engagementID);
- }
- await peer.pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
- return true;
- } catch (error) {
- postError(engagementID, error);
- throw error;
- }
- }
-
- function send(engagementID, base64Message) {
- const peer = peers.get(engagementID);
- if (!peer || !peer.channel || peer.channel.readyState !== "open") {
- postDiagnostic(
- engagementID,
- "data channel send rejected state=" + (peer && peer.channel ? peer.channel.readyState : "missing")
- );
- throw new Error("Engagement channel is not open");
- }
- const bytes = base64ToBytes(base64Message);
- peer.channel.send(bytes);
- postDiagnostic(
- engagementID,
- "data channel send accepted bytes=" + bytes.byteLength +
- " buffered=" + peer.channel.bufferedAmount
- );
- postStatsDiagnostic(engagementID, "after-send");
- setTimeout(() => postStatsDiagnostic(engagementID, "after-send+1s"), 1000);
- return true;
- }
-
- function teardown(engagementID) {
- const peer = peers.get(engagementID);
- if (!peer) {
- return true;
- }
- postClose(engagementID, "teardown");
- if (peer.channel) {
- peer.channel.close();
- }
- peer.pc.close();
- peers.delete(engagementID);
- return true;
- }
-
- window.crossmateEngagement = {
- createOffer,
- acceptOfferAndReply,
- acceptReply,
- send,
- teardown
- };
-})();
-</script>
-</body>
-</html>
diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift
@@ -1,12 +1,10 @@
+import CryptoKit
import Foundation
-import WebKit
+import Security
@MainActor
final class EngagementHost: NSObject {
- typealias Signal = EngagementSignal
-
- enum Event: Equatable, Sendable {
- case signal(engagementID: UUID, signal: Signal)
+ enum Event {
case channelOpen(engagementID: UUID)
case channelMessage(engagementID: UUID, message: Data)
case channelClose(engagementID: UUID)
@@ -14,300 +12,214 @@ final class EngagementHost: NSObject {
case error(engagementID: UUID?, message: String)
}
- var onEvent: (@MainActor (Event) -> Void)?
-
- private let webView: WKWebView
- private let turnCredentialProvider: TURNCredentialProvider
- private var loadTask: Task<Void, Error>?
- private var pendingSignalContinuations: [UUID: CheckedContinuation<Signal, Error>] = [:]
- private var closingEngagementIDs: Set<UUID> = []
- private var closedEngagementIDs: Set<UUID> = []
+ var onEvent: ((Event) -> Void)?
- init(turnCredentialProvider: TURNCredentialProvider = TURNCredentialProvider()) {
- let contentController = WKUserContentController()
- let configuration = WKWebViewConfiguration()
- configuration.userContentController = contentController
- configuration.websiteDataStore = .nonPersistent()
- webView = WKWebView(frame: .zero, configuration: configuration)
- self.turnCredentialProvider = turnCredentialProvider
- super.init()
- contentController.add(WeakScriptMessageHandler(target: self), name: "engagement")
- webView.navigationDelegate = self
- }
-
- func createOffer(engagementID: UUID) async throws -> Signal {
- beginEngagement(engagementID)
- let iceServers = await loadIceServers(engagementID: engagementID)
- return try await callSignalFunction(
- "createOffer",
- engagementID: engagementID,
- signal: nil,
- iceServers: iceServers
- )
- }
-
- func acceptOfferAndReply(engagementID: UUID, signal: Signal) async throws -> Signal {
- beginEngagement(engagementID)
- let iceServers = await loadIceServers(engagementID: engagementID)
- return try await callSignalFunction(
- "acceptOfferAndReply",
- engagementID: engagementID,
- signal: signal,
- iceServers: iceServers
- )
- }
+ private var sockets: [UUID: URLSessionWebSocketTask] = [:]
+ private var engagementIDsByTask: [ObjectIdentifier: UUID] = [:]
+ private lazy var session = URLSession(
+ configuration: .default,
+ delegate: self,
+ delegateQueue: nil
+ )
- func acceptReply(engagementID: UUID, signal: Signal) async throws {
- try await callVoidFunction(
- "acceptReply",
- engagementID: engagementID,
- argument: signal
- )
- }
-
- func send(engagementID: UUID, message: Data) throws {
- Task { @MainActor in
- do {
- try await callVoidFunction(
- "send",
- engagementID: engagementID,
- argument: message.base64EncodedString()
- )
- } catch {
- onEvent?(.error(
- engagementID: engagementID,
- message: error.localizedDescription
- ))
- }
+ func connect(
+ engagementID: UUID,
+ room: EngagementRoomCredentials,
+ authorID: String,
+ deviceID: String
+ ) async throws {
+ disconnect(engagementID: engagementID)
+ guard let url = try Self.socketURL(room: room, authorID: authorID, deviceID: deviceID) else {
+ throw EngagementHostError.missingEndpoint
}
+ let task = session.webSocketTask(with: url)
+ sockets[engagementID] = task
+ engagementIDsByTask[ObjectIdentifier(task)] = engagementID
+ task.resume()
+ receiveNext(engagementID: engagementID, task: task)
+ onEvent?(.diagnostic(engagementID: engagementID, message: "socket connecting \(room.roomID.uuidString)"))
}
- func teardown(engagementID: UUID) {
- closingEngagementIDs.insert(engagementID)
- Task { @MainActor in
- try? await callVoidFunction(
- "teardown",
- engagementID: engagementID,
- argument: Optional<String>.none
- )
+ func send(engagementID: UUID, message: Data) async throws {
+ guard let task = sockets[engagementID] else {
+ throw EngagementHostError.missingSocket
}
+ try await task.send(.data(message))
}
- private func beginEngagement(_ engagementID: UUID) {
- closingEngagementIDs.remove(engagementID)
- closedEngagementIDs.remove(engagementID)
- }
-
- private func noteChannelClose(_ engagementID: UUID) {
- guard closedEngagementIDs.insert(engagementID).inserted else { return }
- closingEngagementIDs.remove(engagementID)
+ func disconnect(engagementID: UUID) {
+ guard let task = sockets.removeValue(forKey: engagementID) else { return }
+ engagementIDsByTask.removeValue(forKey: ObjectIdentifier(task))
+ task.cancel(with: .goingAway, reason: nil)
onEvent?(.channelClose(engagementID: engagementID))
}
- private func loadIceServers(engagementID: UUID) async -> [RTCIceServerConfig]? {
- do {
- return try await turnCredentialProvider.iceServers()
- } catch {
- onEvent?(.error(
- engagementID: engagementID,
- message: "TURN credentials unavailable, falling back to STUN: \(error.localizedDescription)"
- ))
- return nil
- }
- }
-
- private func shouldSuppressError(engagementID: UUID?, message: String) -> Bool {
- guard let engagementID else { return false }
- guard closingEngagementIDs.contains(engagementID) || closedEngagementIDs.contains(engagementID) else {
- return false
- }
- return message.contains("Close called")
- }
-
- private func callVoidFunction<T: Encodable>(
- _ name: String,
- engagementID: UUID,
- argument: T?
- ) async throws {
- try await loadIfNeeded()
- let encodedArgument = try Self.javascriptLiteral(argument)
- let script = "window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument)); undefined"
- _ = try await webView.evaluateJavaScript(script)
- }
-
- private func callSignalFunction(
- _ name: String,
- engagementID: UUID,
- signal: Signal?,
- iceServers: [RTCIceServerConfig]?
- ) async throws -> Signal {
- try await loadIfNeeded()
- let encodedArgument = try Self.javascriptLiteral(signal)
- let encodedIceServers = try Self.javascriptLiteral(iceServers)
- let script = """
- window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument), \(encodedIceServers)); undefined
- """
-
- return try await withCheckedThrowingContinuation { continuation in
- pendingSignalContinuations[engagementID] = continuation
+ private func receiveNext(engagementID: UUID, task: URLSessionWebSocketTask) {
+ task.receive { [weak self, weak task] result in
Task { @MainActor in
- do {
- _ = try await webView.evaluateJavaScript(script)
- } catch {
- pendingSignalContinuations.removeValue(forKey: engagementID)?
- .resume(throwing: error)
+ guard let self, let task, self.sockets[engagementID] === task else { return }
+ switch result {
+ case .success(.data(let data)):
+ self.onEvent?(.channelMessage(engagementID: engagementID, message: data))
+ self.receiveNext(engagementID: engagementID, task: task)
+ case .success(.string(let string)):
+ let data = Data(string.utf8)
+ self.onEvent?(.channelMessage(engagementID: engagementID, message: data))
+ self.receiveNext(engagementID: engagementID, task: task)
+ case .failure(let error):
+ self.sockets.removeValue(forKey: engagementID)
+ self.engagementIDsByTask.removeValue(forKey: ObjectIdentifier(task))
+ self.onEvent?(.error(engagementID: engagementID, message: error.localizedDescription))
+ self.onEvent?(.channelClose(engagementID: engagementID))
+ @unknown default:
+ self.receiveNext(engagementID: engagementID, task: task)
}
}
}
}
- @discardableResult
- private func callFunction<T: Encodable>(
- _ name: String,
- engagementID: UUID,
- argument: T?
- ) async throws -> Any {
- try await loadIfNeeded()
- let encodedArgument = try Self.javascriptLiteral(argument)
- let script = "window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument))"
- return try await webView.evaluateJavaScript(script) as Any
- }
-
- private func loadIfNeeded() async throws {
- if let loadTask {
- try await loadTask.value
- return
- }
- let task = Task { @MainActor in
- guard let url = Bundle.main.url(
- forResource: "EngagementHost",
- withExtension: "html"
- ) else {
- throw EngagementHostError.missingResource
- }
- let html = try String(contentsOf: url, encoding: .utf8)
- webView.loadHTMLString(html, baseURL: Bundle.main.resourceURL)
- try await withCheckedThrowingContinuation { continuation in
- self.loadContinuation = continuation
- }
- }
- loadTask = task
- try await task.value
- }
-
- private var loadContinuation: CheckedContinuation<Void, Error>?
+ private static func socketURL(
+ room: EngagementRoomCredentials,
+ authorID: String,
+ deviceID: String
+ ) throws -> URL? {
+ guard let baseURL = endpointURL else { return nil }
+ let timestamp = String(Int(Date().timeIntervalSince1970))
+ let nonce = UUID().uuidString
+ let signaturePayload = EngagementSocketAuthenticator.signaturePayload(
+ roomID: room.roomID,
+ authorID: authorID,
+ deviceID: deviceID,
+ timestamp: timestamp,
+ nonce: nonce
+ )
+ let signature = try EngagementSocketAuthenticator.signature(
+ payload: signaturePayload,
+ secret: room.secret
+ )
- private static func javascriptLiteral<T: Encodable>(_ value: T?) throws -> String {
- guard let value else { return "null" }
- let data = try JSONEncoder().encode(value)
- guard let string = String(data: data, encoding: .utf8) else {
- throw EngagementHostError.invalidArgument
- }
- return string
+ let socketURL = baseURL
+ .appendingPathComponent("rooms")
+ .appendingPathComponent(room.roomID.uuidString)
+ .appendingPathComponent("socket")
+ var components = URLComponents(url: socketURL, resolvingAgainstBaseURL: false)
+ components?.queryItems = [
+ URLQueryItem(name: "authorID", value: authorID),
+ URLQueryItem(name: "deviceID", value: deviceID),
+ URLQueryItem(name: "timestamp", value: timestamp),
+ URLQueryItem(name: "nonce", value: nonce),
+ URLQueryItem(name: "secret", value: room.secret),
+ URLQueryItem(name: "signature", value: signature)
+ ]
+ return components?.url
+ }
+
+ private static var endpointURL: URL? {
+ guard let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateEngagementSocketURL") as? String,
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ let url = URL(string: raw)
+ else { return nil }
+ return url
}
}
-extension EngagementHost: EngagementHosting, @unchecked Sendable {}
-
-extension EngagementHost: WKNavigationDelegate {
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- loadContinuation?.resume()
- loadContinuation = nil
- }
+extension EngagementHost: EngagementTransporting, @unchecked Sendable {}
- func webView(
- _ webView: WKWebView,
- didFail navigation: WKNavigation!,
- withError error: Error
+extension EngagementHost: URLSessionWebSocketDelegate {
+ nonisolated func urlSession(
+ _ session: URLSession,
+ webSocketTask: URLSessionWebSocketTask,
+ didOpenWithProtocol protocol: String?
) {
- loadContinuation?.resume(throwing: error)
- loadContinuation = nil
+ Task { @MainActor in
+ guard let engagementID = engagementIDsByTask[ObjectIdentifier(webSocketTask)] else { return }
+ onEvent?(.channelOpen(engagementID: engagementID))
+ }
}
-}
-extension EngagementHost: WKScriptMessageHandler {
- func userContentController(
- _ userContentController: WKUserContentController,
- didReceive message: WKScriptMessage
+ nonisolated func urlSession(
+ _ session: URLSession,
+ webSocketTask: URLSessionWebSocketTask,
+ didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
+ reason: Data?
) {
- guard let body = message.body as? [String: Any],
- let type = body["type"] as? String else { return }
- let engagementID = (body["engagementID"] as? String).flatMap(UUID.init(uuidString:))
-
- switch type {
- case "onSignal":
- guard let engagementID,
- let signalObject = body["signal"],
- let data = try? JSONSerialization.data(withJSONObject: signalObject),
- let signal = try? JSONDecoder().decode(Signal.self, from: data) else { return }
- pendingSignalContinuations.removeValue(forKey: engagementID)?.resume(returning: signal)
- onEvent?(.signal(engagementID: engagementID, signal: signal))
- case "onChannelOpen":
- guard let engagementID else { return }
- onEvent?(.channelOpen(engagementID: engagementID))
- case "onChannelMessage":
- guard let engagementID,
- let base64 = body["message"] as? String,
- let data = Data(base64Encoded: base64) else { return }
- onEvent?(.channelMessage(engagementID: engagementID, message: data))
- case "onChannelClose":
- guard let engagementID else { return }
- noteChannelClose(engagementID)
- case "onDiagnostic":
- let diagnosticMessage = (body["message"] as? String) ?? "Unknown engagement host diagnostic"
- onEvent?(.diagnostic(
- engagementID: engagementID,
- message: diagnosticMessage
- ))
- case "onError":
- let errorMessage = (body["message"] as? String) ?? "Unknown engagement host error"
- if shouldSuppressError(engagementID: engagementID, message: errorMessage) {
+ Task { @MainActor in
+ guard let engagementID = engagementIDsByTask.removeValue(forKey: ObjectIdentifier(webSocketTask)) else {
return
}
- if let engagementID {
- pendingSignalContinuations.removeValue(forKey: engagementID)?
- .resume(throwing: EngagementHostError.javascriptError(
- errorMessage
- ))
- }
- onEvent?(.error(
- engagementID: engagementID,
- message: errorMessage
- ))
- default:
- break
+ sockets.removeValue(forKey: engagementID)
+ onEvent?(.channelClose(engagementID: engagementID))
}
}
}
-private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
- weak var target: WKScriptMessageHandler?
+enum EngagementHostError: LocalizedError {
+ case missingEndpoint
+ case missingSocket
+ case invalidSecret
- init(target: WKScriptMessageHandler) {
- self.target = target
+ var errorDescription: String? {
+ switch self {
+ case .missingEndpoint:
+ "CrossmateEngagementSocketURL is not configured."
+ case .missingSocket:
+ "The engagement socket is not connected."
+ case .invalidSecret:
+ "The engagement room secret is invalid."
+ }
}
+}
- func userContentController(
- _ userContentController: WKUserContentController,
- didReceive message: WKScriptMessage
- ) {
- target?.userContentController(userContentController, didReceive: message)
+enum EngagementSocketAuthenticator {
+ static func signaturePayload(
+ roomID: UUID,
+ authorID: String,
+ deviceID: String,
+ timestamp: String,
+ nonce: String
+ ) -> String {
+ [
+ roomID.uuidString,
+ authorID,
+ deviceID,
+ timestamp,
+ nonce
+ ].joined(separator: "|")
+ }
+
+ static func signature(payload: String, secret: String) throws -> String {
+ guard let secretData = Data(base64URLEncoded: secret) else {
+ throw EngagementHostError.invalidSecret
+ }
+ let key = SymmetricKey(data: secretData)
+ let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key)
+ return Data(mac).base64URLEncodedString()
}
}
-enum EngagementHostError: LocalizedError {
- case missingResource
- case invalidArgument
- case javascriptError(String)
+extension Data {
+ init?(base64URLEncoded string: String) {
+ var base64 = string
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let padding = (4 - base64.count % 4) % 4
+ base64.append(String(repeating: "=", count: padding))
+ self.init(base64Encoded: base64)
+ }
- var errorDescription: String? {
- switch self {
- case .missingResource:
- "EngagementHost.html is missing from the app bundle."
- case .invalidArgument:
- "Unable to encode the engagement host argument."
- case .javascriptError(let message):
- message
+ func base64URLEncodedString() -> String {
+ base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ static func secureRandom(count: Int) throws -> Data {
+ var bytes = [UInt8](repeating: 0, count: count)
+ let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ guard status == errSecSuccess else {
+ throw EngagementHostError.invalidSecret
}
+ return Data(bytes)
}
}
diff --git a/Crossmate/Services/TURNCredentials.swift b/Crossmate/Services/TURNCredentials.swift
@@ -1,58 +0,0 @@
-import Foundation
-
-struct RTCIceServerConfig: Codable, Equatable, Sendable {
- var urls: [String]
- var username: String?
- var credential: String?
-}
-
-struct TURNCredentialsResponse: Codable, Equatable, Sendable {
- var ttl: Int?
- var iceServers: [RTCIceServerConfig]
-}
-
-struct TURNCredentialProvider: Sendable {
- var endpoint: URL?
- var session: URLSession = .shared
-
- init(endpoint: URL? = Self.defaultEndpoint(), session: URLSession = .shared) {
- self.endpoint = endpoint
- self.session = session
- }
-
- func iceServers() async throws -> [RTCIceServerConfig]? {
- guard let endpoint else { return nil }
- var request = URLRequest(url: endpoint)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "accept")
-
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse,
- (200..<300).contains(http.statusCode) else {
- throw TURNCredentialError.badResponse
- }
-
- let decoded = try JSONDecoder().decode(TURNCredentialsResponse.self, from: data)
- return decoded.iceServers.isEmpty ? nil : decoded.iceServers
- }
-
- private static func defaultEndpoint() -> URL? {
- guard let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateTURNCredentialsURL") as? String else {
- return nil
- }
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty, !trimmed.contains("$(") else { return nil }
- return URL(string: trimmed)
- }
-}
-
-enum TURNCredentialError: LocalizedError {
- case badResponse
-
- var errorDescription: String? {
- switch self {
- case .badResponse:
- "TURN credential endpoint returned an unsuccessful response."
- }
- }
-}
diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift
@@ -1,10 +1,5 @@
import Foundation
-struct EngagementSignal: Codable, Equatable, Sendable {
- var sdp: String
- var candidates: [String]
-}
-
struct EngagementAddressee: Equatable, Sendable {
var authorID: String
var deviceID: String?
@@ -31,28 +26,25 @@ struct EngagementAddressee: Equatable, Sendable {
}
}
-struct EngagementHailPayload: Codable, Equatable, Sendable {
- enum Role: String, Codable, Sendable {
- case offer
- case reply
- }
-
- var role: Role
- var engagementID: UUID
- var sdp: String
- var candidates: [String]
+struct EngagementRoomCredentials: Codable, Equatable, Sendable {
var ver: Int
+ var roomID: UUID
+ var secret: String
+ var createdAt: Date
+ var expiresAt: Date
- init(role: Role, engagementID: UUID, signal: EngagementSignal, ver: Int = 1) {
- self.role = role
- self.engagementID = engagementID
- self.sdp = signal.sdp
- self.candidates = signal.candidates
+ init(
+ roomID: UUID = UUID(),
+ secret: String,
+ createdAt: Date = Date(),
+ expiresAt: Date,
+ ver: Int = 2
+ ) {
self.ver = ver
- }
-
- var signal: EngagementSignal {
- EngagementSignal(sdp: sdp, candidates: candidates)
+ self.roomID = roomID
+ self.secret = secret
+ self.createdAt = createdAt
+ self.expiresAt = expiresAt
}
func encoded() throws -> String {
@@ -63,9 +55,17 @@ struct EngagementHailPayload: Codable, Equatable, Sendable {
return string
}
- static func decode(_ string: String?) -> EngagementHailPayload? {
+ static func decode(_ string: String?) -> EngagementRoomCredentials? {
guard let data = string?.data(using: .utf8) else { return nil }
- return try? JSONDecoder().decode(EngagementHailPayload.self, from: data)
+ return try? JSONDecoder().decode(EngagementRoomCredentials.self, from: data)
+ }
+
+ static func fresh(now: Date = Date(), ttl: TimeInterval = 10 * 60) throws -> EngagementRoomCredentials {
+ try EngagementRoomCredentials(
+ secret: Data.secureRandom(count: 32).base64URLEncodedString(),
+ createdAt: now,
+ expiresAt: now.addingTimeInterval(ttl)
+ )
}
}
@@ -127,12 +127,15 @@ struct EngagementMessage: Codable, Equatable, Sendable {
}
@MainActor
-protocol EngagementHosting: AnyObject, Sendable {
- func createOffer(engagementID: UUID) async throws -> EngagementSignal
- func acceptOfferAndReply(engagementID: UUID, signal: EngagementSignal) async throws -> EngagementSignal
- func acceptReply(engagementID: UUID, signal: EngagementSignal) async throws
- func send(engagementID: UUID, message: Data) throws
- func teardown(engagementID: UUID)
+protocol EngagementTransporting: AnyObject, Sendable {
+ func connect(
+ engagementID: UUID,
+ room: EngagementRoomCredentials,
+ authorID: String,
+ deviceID: String
+ ) async throws
+ func send(engagementID: UUID, message: Data) async throws
+ func disconnect(engagementID: UUID)
}
actor EngagementCoordinator {
@@ -147,28 +150,21 @@ actor EngagementCoordinator {
private enum State: Equatable {
case idle
- case offered(peerAuthorID: String, engagementID: UUID, at: Date)
- /// SDP exchange complete from this device's POV — the answerer has sent
- /// its reply, or the offerer has accepted the reply — but the WebRTC
- /// data channel hasn't fired `onChannelOpen` yet. Treated as
- /// not-yet-live: sends are rejected, and the handshake-sweep can
- /// still demote it back to idle if the channel never comes up.
- case handshaking(peerAuthorID: String, engagementID: UUID, at: Date)
- case live(peerAuthorID: String, engagementID: UUID)
+ case connecting(peerAuthorID: String, engagementID: UUID, room: EngagementRoomCredentials, at: Date)
+ case live(peerAuthorID: String, engagementID: UUID, room: EngagementRoomCredentials)
var engagementID: UUID? {
switch self {
case .idle:
nil
- case .offered(_, let engagementID, _),
- .handshaking(_, let engagementID, _),
- .live(_, let engagementID):
+ case .connecting(_, let engagementID, _, _),
+ .live(_, let engagementID, _):
engagementID
}
}
}
- private let host: any EngagementHosting
+ private let host: any EngagementTransporting
private let localAuthorID: @Sendable () async -> String?
private let localDeviceID: String
private let presentPeers: PresentPeers
@@ -177,11 +173,12 @@ actor EngagementCoordinator {
private let log: Log
private let now: @Sendable () -> Date
private let hailMaxAge: TimeInterval
- private let handshakeTimeout: TimeInterval
+ private let connectionTimeout: TimeInterval
+ private let roomTTL: TimeInterval
private var states: [UUID: State] = [:]
init(
- host: any EngagementHosting,
+ host: any EngagementTransporting,
localAuthorID: @escaping @Sendable () async -> String?,
localDeviceID: String = RecordSerializer.localDeviceID,
presentPeers: @escaping PresentPeers,
@@ -190,7 +187,8 @@ actor EngagementCoordinator {
log: @escaping Log = { _ in },
now: @escaping @Sendable () -> Date = Date.init,
hailMaxAge: TimeInterval = 120,
- handshakeTimeout: TimeInterval = 30
+ connectionTimeout: TimeInterval = 30,
+ roomTTL: TimeInterval = 10 * 60
) {
self.host = host
self.localAuthorID = localAuthorID
@@ -201,67 +199,48 @@ actor EngagementCoordinator {
self.log = log
self.now = now
self.hailMaxAge = hailMaxAge
- self.handshakeTimeout = handshakeTimeout
+ self.connectionTimeout = connectionTimeout
+ self.roomTTL = roomTTL
}
func peerPresenceMayHaveChanged(gameIDs: Set<UUID>? = nil) async {
guard let localAuthorID = await localAuthorID(), !localAuthorID.isEmpty else { return }
- await sweepStaleHandshakes()
+ await sweepStaleConnections()
let peersByGame = await presentPeers(gameIDs)
for (gameID, peers) in peersByGame {
guard state(for: gameID) == .idle else { continue }
guard let peerAuthorID = peers.sorted().first(where: { localAuthorID < $0 }) else { continue }
- await createOffer(gameID: gameID, peerAuthorID: peerAuthorID)
+ await createRoom(gameID: gameID, peerAuthorID: peerAuthorID, localAuthorID: localAuthorID)
}
}
func offerEngagement(gameID: UUID) async {
guard let localAuthorID = await localAuthorID(), !localAuthorID.isEmpty else { return }
- await sweepStaleHandshakes()
+ await sweepStaleConnections()
guard state(for: gameID) == .idle else {
- await log("engagement: manual offer skipped for \(gameID.uuidString), state is not idle")
+ await log("engagement: manual connect skipped for \(gameID.uuidString), state is not idle")
return
}
let peers = await presentPeers([gameID])
let peerAuthorID = peers[gameID]?.sorted().first ?? localAuthorID
- await createOffer(gameID: gameID, peerAuthorID: peerAuthorID)
+ await createRoom(gameID: gameID, peerAuthorID: peerAuthorID, localAuthorID: localAuthorID)
}
- /// Demotes any `.offered`/`.handshaking` state that has been waiting
- /// longer than `handshakeTimeout` back to `.idle`, tearing down the
- /// host's peer connection so the next presence tick can retry cleanly.
- /// The peer-side discard window (`hailMaxAge`, 120 s) is much longer
- /// than the local handshake budget, so by the time we demote, the peer
- /// has either already replied (and we'd be `.live` or `.handshaking`)
- /// or is unreachable for reasons that a retry might fix. `.handshaking`
- /// also catches the case where SDP exchanged successfully but ICE
- /// never produced a connectable candidate pair, so `onChannelOpen`
- /// never fired.
- private func sweepStaleHandshakes() async {
+ private func sweepStaleConnections() async {
let cutoff = now()
- var demoted: [(gameID: UUID, engagementID: UUID, kind: String, age: TimeInterval)] = []
+ var demoted: [(gameID: UUID, engagementID: UUID, age: TimeInterval)] = []
for (gameID, state) in states {
- switch state {
- case .offered(_, let engagementID, let at):
- let age = cutoff.timeIntervalSince(at)
- if age > handshakeTimeout {
- demoted.append((gameID, engagementID, "offer", age))
- states[gameID] = .idle
- }
- case .handshaking(_, let engagementID, let at):
- let age = cutoff.timeIntervalSince(at)
- if age > handshakeTimeout {
- demoted.append((gameID, engagementID, "handshake", age))
- states[gameID] = .idle
- }
- case .idle, .live:
- continue
+ guard case .connecting(_, let engagementID, _, let at) = state else { continue }
+ let age = cutoff.timeIntervalSince(at)
+ if age > connectionTimeout {
+ demoted.append((gameID, engagementID, age))
+ states[gameID] = .idle
}
}
for entry in demoted {
- await host.teardown(engagementID: entry.engagementID)
+ await host.disconnect(engagementID: entry.engagementID)
await log(
- "engagement: \(entry.kind) timed out for \(entry.gameID.uuidString) " +
+ "engagement: connection timed out for \(entry.gameID.uuidString) " +
"after \(Int(entry.age))s, engagement \(entry.engagementID.uuidString)"
)
}
@@ -291,24 +270,24 @@ actor EngagementCoordinator {
await log("engagement: ignored hail \(ping.recordName), addressed to \(addressee.rawValue)")
return
}
- guard let payload = EngagementHailPayload.decode(ping.payload), payload.ver == 1 else {
+ guard let room = EngagementRoomCredentials.decode(ping.payload), room.ver == 2 else {
await log("engagement: ignored malformed hail \(ping.recordName)")
return
}
-
- switch payload.role {
- case .offer:
- await acceptOffer(ping: ping, payload: payload, localAuthorID: localAuthorID)
- case .reply:
- await acceptReply(ping: ping, payload: payload)
+ guard room.expiresAt > now() else {
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: deleted expired hail \(ping.recordName)")
+ return
}
+
+ await acceptRoom(ping: ping, room: room, localAuthorID: localAuthorID)
}
func teardown(gameID: UUID) async {
let state = state(for: gameID)
states[gameID] = .idle
if let engagementID = state.engagementID {
- await host.teardown(engagementID: engagementID)
+ await host.disconnect(engagementID: engagementID)
}
}
@@ -317,10 +296,9 @@ actor EngagementCoordinator {
switch state {
case .idle:
return nil
- case .offered(let peerAuthorID, _, _),
- .handshaking(let peerAuthorID, _, _),
- .live(let peerAuthorID, _):
- states[gameID] = .live(peerAuthorID: peerAuthorID, engagementID: engagementID)
+ case .connecting(let peerAuthorID, _, let room, _),
+ .live(let peerAuthorID, _, let room):
+ states[gameID] = .live(peerAuthorID: peerAuthorID, engagementID: engagementID, room: room)
return gameID
}
}
@@ -335,7 +313,7 @@ actor EngagementCoordinator {
}
func sendDebugMessage(gameID: UUID, text: String) async {
- guard case .live(_, let engagementID) = state(for: gameID) else {
+ guard case .live(_, let engagementID, _) = state(for: gameID) else {
await log("engagement: test message skipped for \(gameID.uuidString), channel is not live")
return
}
@@ -349,7 +327,7 @@ actor EngagementCoordinator {
}
func sendCellEdit(_ edit: RealtimeCellEdit) async {
- guard case .live(_, let engagementID) = state(for: edit.gameID) else { return }
+ guard case .live(_, let engagementID, _) = state(for: edit.gameID) else { return }
do {
let message = EngagementMessage(cellEdit: edit)
try await host.send(engagementID: engagementID, message: message.encodedData())
@@ -363,7 +341,7 @@ actor EngagementCoordinator {
}
func sendSelection(_ selection: EngagementSelectionUpdate) async {
- guard case .live(_, let engagementID) = state(for: selection.gameID) else { return }
+ guard case .live(_, let engagementID, _) = state(for: selection.gameID) else { return }
do {
let message = EngagementMessage(selection: selection)
try await host.send(engagementID: engagementID, message: message.encodedData())
@@ -376,91 +354,66 @@ actor EngagementCoordinator {
}
}
- private func createOffer(gameID: UUID, peerAuthorID: String) async {
+ private func createRoom(gameID: UUID, peerAuthorID: String, localAuthorID: String) async {
let engagementID = UUID()
- states[gameID] = .offered(peerAuthorID: peerAuthorID, engagementID: engagementID, at: now())
do {
- let signal = try await host.createOffer(engagementID: engagementID)
- let payload = try EngagementHailPayload(
- role: .offer,
+ let room = try EngagementRoomCredentials.fresh(now: now(), ttl: roomTTL)
+ states[gameID] = .connecting(
+ peerAuthorID: peerAuthorID,
engagementID: engagementID,
- signal: signal
- ).encoded()
- await sendHail(gameID, payload, EngagementAddressee(authorID: peerAuthorID).rawValue)
- await log("engagement: sent offer for \(gameID.uuidString) to \(peerAuthorID)")
+ room: room,
+ at: now()
+ )
+ await sendHail(gameID, try room.encoded(), EngagementAddressee(authorID: peerAuthorID).rawValue)
+ try await host.connect(
+ engagementID: engagementID,
+ room: room,
+ authorID: localAuthorID,
+ deviceID: localDeviceID
+ )
+ await log("engagement: sent room hail for \(gameID.uuidString) to \(peerAuthorID)")
} catch {
states[gameID] = .idle
- await log("engagement: offer failed for \(gameID.uuidString): \(error.localizedDescription)")
+ await log("engagement: room connect failed for \(gameID.uuidString): \(error.localizedDescription)")
}
}
- private func acceptOffer(ping: Ping, payload: EngagementHailPayload, localAuthorID: String) async {
+ private func acceptRoom(ping: Ping, room: EngagementRoomCredentials, localAuthorID: String) async {
let currentState = state(for: ping.gameID)
if currentState != .idle {
- if currentState.engagementID == payload.engagementID {
- await log("engagement: ignored duplicate offer \(payload.engagementID.uuidString)")
- return
- }
- if case .offered = currentState,
- !incomingOfferWinsOverLocalOffer(ping: ping, localAuthorID: localAuthorID) {
+ if case .connecting(_, _, room, _) = currentState {
+ await log("engagement: ignored duplicate room hail \(room.roomID.uuidString)")
await deletePing(ping.recordName, ping.gameID)
- await log("engagement: deleted losing offer \(payload.engagementID.uuidString)")
return
}
if let engagementID = currentState.engagementID {
- await host.teardown(engagementID: engagementID)
+ await host.disconnect(engagementID: engagementID)
}
await log(
"engagement: replacing \(currentState.engagementID?.uuidString ?? "unknown") " +
- "with offer \(payload.engagementID.uuidString)"
- )
- }
- states[ping.gameID] = .handshaking(peerAuthorID: ping.authorID, engagementID: payload.engagementID, at: now())
- do {
- let reply = try await host.acceptOfferAndReply(
- engagementID: payload.engagementID,
- signal: payload.signal
- )
- let replyPayload = try EngagementHailPayload(
- role: .reply,
- engagementID: payload.engagementID,
- signal: reply
- ).encoded()
- let addressee = EngagementAddressee(
- authorID: ping.authorID,
- deviceID: ping.deviceID.isEmpty ? nil : ping.deviceID
+ "with room \(room.roomID.uuidString)"
)
- await sendHail(ping.gameID, replyPayload, addressee.rawValue)
- await deletePing(ping.recordName, ping.gameID)
- await log("engagement: replied to offer \(payload.engagementID.uuidString)")
- } catch {
- states[ping.gameID] = .idle
- await log("engagement: reply failed for \(ping.gameID.uuidString): \(error.localizedDescription)")
}
- }
- private func acceptReply(ping: Ping, payload: EngagementHailPayload) async {
- guard case .offered(let peerAuthorID, payload.engagementID, _) = state(for: ping.gameID),
- peerAuthorID == ping.authorID else {
- await deletePing(ping.recordName, ping.gameID)
- await log("engagement: deleted unmatched reply \(payload.engagementID.uuidString)")
- return
- }
+ let engagementID = UUID()
+ states[ping.gameID] = .connecting(
+ peerAuthorID: ping.authorID,
+ engagementID: engagementID,
+ room: room,
+ at: now()
+ )
do {
- try await host.acceptReply(
- engagementID: payload.engagementID,
- signal: payload.signal
- )
- states[ping.gameID] = .handshaking(
- peerAuthorID: ping.authorID,
- engagementID: payload.engagementID,
- at: now()
+ try await host.connect(
+ engagementID: engagementID,
+ room: room,
+ authorID: localAuthorID,
+ deviceID: localDeviceID
)
await deletePing(ping.recordName, ping.gameID)
- await log("engagement: reply accepted \(payload.engagementID.uuidString), awaiting channel")
+ await log("engagement: accepted room hail \(room.roomID.uuidString)")
} catch {
states[ping.gameID] = .idle
- await log("engagement: accept reply failed for \(ping.gameID.uuidString): \(error.localizedDescription)")
+ await log("engagement: room accept failed for \(ping.gameID.uuidString): \(error.localizedDescription)")
}
}
@@ -474,13 +427,6 @@ actor EngagementCoordinator {
}
}
- private func incomingOfferWinsOverLocalOffer(ping: Ping, localAuthorID: String) -> Bool {
- if ping.authorID == localAuthorID {
- return ping.deviceID < localDeviceID
- }
- return ping.authorID < localAuthorID
- }
-
private static func eventTimestamp(from recordName: String) -> Date? {
guard let timestampString = recordName.split(separator: "-").last,
let milliseconds = TimeInterval(timestampString) else { return nil }
diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift
@@ -20,8 +20,8 @@ enum PingKind: String, Sendable {
/// Re-invite to a game. Written into a *friend* zone; carries the game's
/// share URL in `payload`. Surfaces in the "Invited" section.
case invite
- /// WebRTC engagement signaling. Written into a shared game zone; carries
- /// `{"role":"offer"|"reply","engagementID":"…","sdp":"…","candidates":[…],"ver":1}`
+ /// Engagement room bootstrap. Written into a shared game zone; carries
+ /// `{"ver":2,"roomID":"…","secret":"…","createdAt":"…","expiresAt":"…"}`
/// in `payload` and targets a specific author/device via `addressee`.
case hail
}
@@ -55,12 +55,12 @@ struct Ping: Sendable {
let kind: PingKind
let scope: PingScope?
/// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`;
- /// `.invite`: `{gameShareURL}`; `.hail` carries engagement signaling;
+ /// `.invite`: `{gameShareURL}`; `.hail` carries engagement room bootstrap;
/// `.check`/`.reveal`: `{scope}` (see PingScope). nil for join/win/resign.
let payload: String?
/// Recipient authorID for a directed ping (`.win`/`.resign`); for `.hail`
/// this is `authorID:deviceID` so only one of an author's devices acts on
- /// the engagement signal. nil ⇒ broadcast — every recipient acts on it.
+ /// the room bootstrap. nil ⇒ broadcast — every recipient acts on it.
let addressee: String?
static func parseRecord(_ record: CKRecord) -> Ping? {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -585,10 +585,10 @@ actor SyncEngine {
return deleted
}
- /// One-shot cleanup of stale WebRTC `.hail` signaling records from known
- /// game zones. Hails are ephemeral offer/reply envelopes; any records
- /// written before the current cleanup/deletion path shipped can only
- /// replay obsolete handshakes, so remove them once per device.
+ /// One-shot cleanup of stale legacy `.hail` records from known game
+ /// zones. Hails are ephemeral bootstrap envelopes; any records written
+ /// before the current cleanup/deletion path shipped can only replay
+ /// obsolete handshakes, so remove them once per device.
func purgeStaleHailPings_v1() async {
guard NotificationState.staleHailPurgeNeeded() else { return }
do {
diff --git a/Crossmate/Views/EngagementDebugView.swift b/Crossmate/Views/EngagementDebugView.swift
@@ -6,8 +6,9 @@ struct EngagementDebugView: View {
@Environment(\.engagementHost) private var host
@State private var engagementID = UUID()
- @State private var localSignalJSON = ""
- @State private var remoteSignalJSON = ""
+ @State private var authorID = "debug-author"
+ @State private var deviceID = RecordSerializer.localDeviceID
+ @State private var roomJSON = ""
@State private var outboundMessage = "hello from Crossmate"
@State private var events: [String] = []
@State private var isBusy = false
@@ -26,59 +27,58 @@ struct EngagementDebugView: View {
.textSelection(.enabled)
Button("New Engagement ID") {
+ host?.disconnect(engagementID: engagementID)
engagementID = UUID()
- localSignalJSON = ""
- remoteSignalJSON = ""
appendEvent("New engagement ID")
}
.disabled(isBusy)
}
- Section("Local Signal") {
- debugTextEditor(text: $localSignalJSON, minHeight: 150)
+ Section("Identity") {
+ TextField("Author ID", text: $authorID)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Device ID", text: $deviceID)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
- Button("Create Offer") {
- Task { await createOffer() }
- }
- .disabled(isBusy || host == nil)
+ Section("Room") {
+ debugTextEditor(text: $roomJSON, minHeight: 150)
- Button("Copy Local Signal") {
- UIPasteboard.general.string = localSignalJSON
+ Button("Create Room") {
+ createRoom()
}
- .disabled(localSignalJSON.isEmpty)
- }
-
- Section("Remote Signal") {
- debugTextEditor(text: $remoteSignalJSON, minHeight: 150)
+ .disabled(isBusy)
- Button("Paste Remote Signal") {
- remoteSignalJSON = UIPasteboard.general.string ?? ""
+ Button("Paste Room") {
+ roomJSON = UIPasteboard.general.string ?? ""
}
- Button("Accept Offer and Create Reply") {
- Task { await acceptOfferAndReply() }
+ Button("Copy Room") {
+ UIPasteboard.general.string = roomJSON
}
- .disabled(isBusy || host == nil || remoteSignalJSON.isEmpty)
+ .disabled(roomJSON.isEmpty)
- Button("Accept Reply") {
- Task { await acceptReply() }
+ Button("Connect") {
+ Task { await connect() }
}
- .disabled(isBusy || host == nil || remoteSignalJSON.isEmpty)
+ .disabled(isBusy || host == nil || roomJSON.isEmpty || authorID.isEmpty || deviceID.isEmpty)
}
- Section("Data Channel") {
+ Section("Socket") {
TextField("Message", text: $outboundMessage)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Send Message") {
- sendMessage()
+ Task { await sendMessage() }
}
.disabled(host == nil || outboundMessage.isEmpty)
- Button("Teardown") {
- host?.teardown(engagementID: engagementID)
- appendEvent("Teardown requested")
+ Button("Disconnect") {
+ host?.disconnect(engagementID: engagementID)
+ appendEvent("Disconnect requested")
}
.disabled(host == nil)
}
@@ -96,7 +96,7 @@ struct EngagementDebugView: View {
}
}
}
- .navigationTitle("WebRTC Host Test")
+ .navigationTitle("Engagement Socket Test")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
host?.onEvent = { event in
@@ -104,7 +104,7 @@ struct EngagementDebugView: View {
}
}
.onDisappear {
- host?.teardown(engagementID: engagementID)
+ host?.disconnect(engagementID: engagementID)
}
}
@@ -117,38 +117,37 @@ struct EngagementDebugView: View {
.frame(minHeight: minHeight)
}
- private func createOffer() async {
- guard let host else { return }
- await run("create offer") {
- let signal = try await host.createOffer(engagementID: engagementID)
- localSignalJSON = try encode(signal)
+ private func createRoom() {
+ do {
+ let room = try EngagementRoomCredentials.fresh()
+ let data = try encoder.encode(room)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw EngagementDebugError.invalidUTF8
+ }
+ roomJSON = string
+ appendEvent("Created room \(room.roomID.uuidString)")
+ } catch {
+ appendEvent("Create room failed: \(error.localizedDescription)")
}
}
- private func acceptOfferAndReply() async {
+ private func connect() async {
guard let host else { return }
- await run("accept offer") {
- let offer = try decodeSignal(remoteSignalJSON)
- let reply = try await host.acceptOfferAndReply(
+ await run("connect") {
+ let room = try decodeRoom(roomJSON)
+ try await host.connect(
engagementID: engagementID,
- signal: offer
+ room: room,
+ authorID: authorID,
+ deviceID: deviceID
)
- localSignalJSON = try encode(reply)
- }
- }
-
- private func acceptReply() async {
- guard let host else { return }
- await run("accept reply") {
- let reply = try decodeSignal(remoteSignalJSON)
- try await host.acceptReply(engagementID: engagementID, signal: reply)
}
}
- private func sendMessage() {
+ private func sendMessage() async {
guard let data = outboundMessage.data(using: .utf8) else { return }
do {
- try host?.send(engagementID: engagementID, message: data)
+ try await host?.send(engagementID: engagementID, message: data)
appendEvent("Sent message: \(outboundMessage)")
} catch {
appendEvent("Send failed: \(error.localizedDescription)")
@@ -171,12 +170,6 @@ struct EngagementDebugView: View {
private func handle(_ event: EngagementHost.Event) {
switch event {
- case .signal(let id, let signal):
- guard id == engagementID else { return }
- if let json = try? encode(signal) {
- localSignalJSON = json
- }
- appendEvent("Signal generated")
case .channelOpen(let id):
guard id == engagementID else { return }
appendEvent("Channel open")
@@ -197,19 +190,11 @@ struct EngagementDebugView: View {
}
}
- private func encode(_ signal: EngagementHost.Signal) throws -> String {
- let data = try encoder.encode(signal)
- guard let string = String(data: data, encoding: .utf8) else {
- throw EngagementDebugError.invalidUTF8
- }
- return string
- }
-
- private func decodeSignal(_ string: String) throws -> EngagementHost.Signal {
+ private func decodeRoom(_ string: String) throws -> EngagementRoomCredentials {
guard let data = string.data(using: .utf8) else {
throw EngagementDebugError.invalidUTF8
}
- return try JSONDecoder().decode(EngagementHost.Signal.self, from: data)
+ return try JSONDecoder().decode(EngagementRoomCredentials.self, from: data)
}
private func appendEvent(_ message: String) {
@@ -234,7 +219,7 @@ private enum EngagementDebugError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidUTF8:
- "The signal could not be encoded as UTF-8."
+ "The room could not be encoded as UTF-8."
}
}
}
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -47,7 +47,7 @@ struct SettingsView: View {
NavigationLink("Record Editor") {
RecordEditorView()
}
- NavigationLink("WebRTC Host Test") {
+ NavigationLink("Engagement Socket Test") {
EngagementDebugView()
}
diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift
@@ -5,18 +5,21 @@ import Testing
@Suite("EngagementCoordinator")
struct EngagementCoordinatorTests {
- @Test("hail payload encodes role, engagement ID, and signal")
- func hailPayloadRoundTrip() throws {
- let engagementID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
- let signal = EngagementSignal(sdp: "offer-sdp", candidates: ["candidate-a"])
- let payload = EngagementHailPayload(role: .offer, engagementID: engagementID, signal: signal)
-
- let decoded = EngagementHailPayload.decode(try payload.encoded())
-
- #expect(decoded?.role == .offer)
- #expect(decoded?.engagementID == engagementID)
- #expect(decoded?.signal == signal)
- #expect(decoded?.ver == 1)
+ @Test("room payload encodes v2 room credentials")
+ func roomPayloadRoundTrip() throws {
+ let createdAt = Date(timeIntervalSince1970: 100)
+ let expiresAt = Date(timeIntervalSince1970: 700)
+ let room = EngagementRoomCredentials(
+ roomID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
+ secret: Data(repeating: 7, count: 32).base64URLEncodedString(),
+ createdAt: createdAt,
+ expiresAt: expiresAt
+ )
+
+ let decoded = EngagementRoomCredentials.decode(try room.encoded())
+
+ #expect(decoded == room)
+ #expect(decoded?.ver == 2)
}
@Test("addressee matches author-wide and device-specific hails")
@@ -28,6 +31,25 @@ struct EngagementCoordinatorTests {
#expect(EngagementAddressee.parse(nil) == nil)
}
+ @Test("socket signatures are stable")
+ func socketSignature() throws {
+ let secret = Data(repeating: 1, count: 32).base64URLEncodedString()
+ let roomID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
+ let payload = EngagementSocketAuthenticator.signaturePayload(
+ roomID: roomID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ timestamp: "1000",
+ nonce: "nonce"
+ )
+
+ let first = try EngagementSocketAuthenticator.signature(payload: payload, secret: secret)
+ let second = try EngagementSocketAuthenticator.signature(payload: payload, secret: secret)
+
+ #expect(first == second)
+ #expect(!first.isEmpty)
+ }
+
@Test("debug message envelope round trips")
func debugMessageRoundTrip() throws {
let sentAt = Date(timeIntervalSince1970: 123)
@@ -65,30 +87,10 @@ struct EngagementCoordinatorTests {
#expect(decoded.sentAt == Date(timeIntervalSince1970: 789))
}
- @Test("selection message envelope round trips")
- func selectionMessageRoundTrip() throws {
- let gameID = UUID(uuidString: "13131313-1313-1313-1313-131313131313")!
- let update = EngagementSelectionUpdate(
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- selection: PlayerSelection(row: 3, col: 4, direction: .down),
- updatedAt: Date(timeIntervalSince1970: 321)
- )
- let message = EngagementMessage(selection: update, sentAt: Date(timeIntervalSince1970: 654))
-
- let decoded = try #require(EngagementMessage.decode(try message.encodedData()))
-
- #expect(decoded.kind == .selection)
- #expect(decoded.text == "")
- #expect(decoded.selection == update)
- #expect(decoded.sentAt == Date(timeIntervalSince1970: 654))
- }
-
- @Test("present peer with greater author ID causes a directed offer")
+ @Test("present peer with greater author ID creates room hail and connects")
@MainActor
- func presentPeerCreatesOffer() async throws {
- let gameID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
+ func presentPeerCreatesRoom() async throws {
+ let gameID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!
let host = MockEngagementHost()
let sink = EngagementCoordinatorTestSink()
let coordinator = EngagementCoordinator(
@@ -106,27 +108,29 @@ struct EngagementCoordinatorTests {
await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- #expect(host.createdOffers.count == 1)
let sent = await sink.sentHails()
#expect(sent.count == 1)
#expect(sent.first?.gameID == gameID)
#expect(sent.first?.addressee == "bob")
- let payload = EngagementHailPayload.decode(sent.first?.payload)
- #expect(payload?.role == .offer)
- #expect(payload?.signal == host.offerSignal)
+ let room = try #require(EngagementRoomCredentials.decode(sent.first?.payload))
+ #expect(host.connections.count == 1)
+ #expect(host.connections.first?.room == room)
+ #expect(host.connections.first?.authorID == "alice")
+ #expect(host.connections.first?.deviceID == "deviceA")
}
- @Test("manual offer addresses a present peer when one exists")
+ @Test("inbound room hail connects and deletes ping")
@MainActor
- func manualOfferAddressesPresentPeer() async throws {
- let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")!
+ func inboundRoomConnects() async throws {
+ let gameID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")!
+ let room = roomCredentials()
let host = MockEngagementHost()
let sink = EngagementCoordinatorTestSink()
let coordinator = EngagementCoordinator(
host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
+ localAuthorID: { "bob" },
+ localDeviceID: "deviceB",
+ presentPeers: { _ in [:] },
sendHail: { gameID, payload, addressee in
await sink.send(gameID: gameID, payload: payload, addressee: addressee)
},
@@ -135,27 +139,34 @@ struct EngagementCoordinatorTests {
}
)
- await coordinator.offerEngagement(gameID: gameID)
+ await coordinator.handle(ping(
+ recordName: "room-record",
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ payload: try room.encoded(),
+ addressee: "bob:deviceB"
+ ))
- #expect(host.createdOffers.count == 1)
- let sent = await sink.sentHails()
- #expect(sent.count == 1)
- #expect(sent.first?.gameID == gameID)
- #expect(sent.first?.addressee == "bob")
- #expect(EngagementHailPayload.decode(sent.first?.payload)?.role == .offer)
+ #expect(host.connections.count == 1)
+ #expect(host.connections.first?.room == room)
+ #expect(host.connections.first?.authorID == "bob")
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "room-record", gameID: gameID)
+ ])
}
- @Test("manual offer falls back to the local author for same-account device testing")
+ @Test("send is skipped until channel opens")
@MainActor
- func manualOfferFallsBackToLocalAuthor() async throws {
- let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")!
+ func sendIsSkippedUntilChannelOpens() async throws {
+ let gameID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")!
let host = MockEngagementHost()
let sink = EngagementCoordinatorTestSink()
let coordinator = EngagementCoordinator(
host: host,
localAuthorID: { "alice" },
localDeviceID: "deviceA",
- presentPeers: { _ in [:] },
+ presentPeers: { _ in [gameID: ["bob"]] },
sendHail: { gameID, payload, addressee in
await sink.send(gameID: gameID, payload: payload, addressee: addressee)
},
@@ -163,124 +174,54 @@ struct EngagementCoordinatorTests {
await sink.delete(recordName: recordName, gameID: gameID)
}
)
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ let engagementID = try #require(host.connections.first?.engagementID)
- await coordinator.offerEngagement(gameID: gameID)
+ await coordinator.sendDebugMessage(gameID: gameID, text: "too early")
+ #expect(host.sentMessages.isEmpty)
- #expect(host.createdOffers.count == 1)
- let sent = await sink.sentHails()
- #expect(sent.count == 1)
- #expect(sent.first?.gameID == gameID)
- #expect(sent.first?.addressee == "alice")
- #expect(EngagementHailPayload.decode(sent.first?.payload)?.role == .offer)
+ #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID)
+ await coordinator.sendDebugMessage(gameID: gameID, text: "now ok")
+ #expect(host.sentMessages.count == 1)
+ #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "now ok")
}
- @Test("inbound offer sends device-specific reply and deletes offer ping")
+ @Test("stale connecting state demotes to idle on next presence tick")
@MainActor
- func inboundOfferSendsReply() async throws {
- let gameID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!
- let engagementID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")!
+ func staleConnectingDemotesOnPresenceTick() async throws {
+ let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")!
let host = MockEngagementHost()
let sink = EngagementCoordinatorTestSink()
+ let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
let coordinator = EngagementCoordinator(
host: host,
- localAuthorID: { "bob" },
- localDeviceID: "deviceB",
- presentPeers: { _ in [:] },
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [gameID: ["bob"]] },
sendHail: { gameID, payload, addressee in
await sink.send(gameID: gameID, payload: payload, addressee: addressee)
},
deletePing: { recordName, gameID in
await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- let offer = EngagementHailPayload(
- role: .offer,
- engagementID: engagementID,
- signal: EngagementSignal(sdp: "offer-sdp", candidates: ["offer-candidate"])
- )
-
- await coordinator.handle(ping(
- recordName: "offer-record",
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- payload: try offer.encoded(),
- addressee: "bob:deviceB"
- ))
-
- #expect(host.acceptedOffers == [engagementID])
- let sent = await sink.sentHails()
- #expect(sent.count == 1)
- #expect(sent.first?.addressee == "alice:deviceA")
- #expect(EngagementHailPayload.decode(sent.first?.payload)?.role == .reply)
- #expect(EngagementHailPayload.decode(sent.first?.payload)?.signal == host.replySignal)
- #expect(await sink.deletedPings() == [
- DeletedPing(recordName: "offer-record", gameID: gameID)
- ])
- }
-
- @Test("inbound replacement offer tears down old engagement and replies")
- @MainActor
- func inboundReplacementOfferSendsReply() async throws {
- let gameID = UUID(uuidString: "ABABABAB-ABAB-ABAB-ABAB-ABABABABABAB")!
- let oldEngagementID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
- let newEngagementID = UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "bob" },
- localDeviceID: "deviceB",
- presentPeers: { _ in [:] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
},
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- let oldOffer = EngagementHailPayload(
- role: .offer,
- engagementID: oldEngagementID,
- signal: EngagementSignal(sdp: "old-offer-sdp", candidates: [])
- )
- let newOffer = EngagementHailPayload(
- role: .offer,
- engagementID: newEngagementID,
- signal: EngagementSignal(sdp: "new-offer-sdp", candidates: [])
+ now: { clock.now },
+ connectionTimeout: 30
)
- await coordinator.handle(ping(
- recordName: "old-offer-record",
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- payload: try oldOffer.encoded(),
- addressee: "bob:deviceB"
- ))
- await coordinator.handle(ping(
- recordName: "new-offer-record",
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- payload: try newOffer.encoded(),
- addressee: "bob:deviceB"
- ))
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ let firstEngagementID = try #require(host.connections.first?.engagementID)
- #expect(host.tornDown == [oldEngagementID])
- #expect(host.acceptedOffers == [oldEngagementID, newEngagementID])
- let sent = await sink.sentHails()
- #expect(sent.count == 2)
- #expect(EngagementHailPayload.decode(sent.last?.payload)?.engagementID == newEngagementID)
- #expect(await sink.deletedPings() == [
- DeletedPing(recordName: "old-offer-record", gameID: gameID),
- DeletedPing(recordName: "new-offer-record", gameID: gameID)
- ])
+ clock.advance(by: 31)
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+
+ #expect(host.disconnected == [firstEngagementID])
+ #expect(await sink.sentHails().count == 2)
+ #expect(host.connections.count == 2)
}
- @Test("stale hails are ignored before current offers")
+ @Test("stale and expired hails are deleted")
@MainActor
- func staleHailIsIgnored() async throws {
+ func staleAndExpiredHailsAreDeleted() async throws {
let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")!
let host = MockEngagementHost()
let sink = EngagementCoordinatorTestSink()
@@ -298,23 +239,15 @@ struct EngagementCoordinatorTests {
now: { Date(timeIntervalSince1970: 1_000) },
hailMaxAge: 120
)
- let staleOffer = EngagementHailPayload(
- role: .offer,
- engagementID: UUID(uuidString: "88888888-8888-8888-8888-888888888888")!,
- signal: EngagementSignal(sdp: "stale-sdp", candidates: [])
- )
- let currentOffer = EngagementHailPayload(
- role: .offer,
- engagementID: UUID(uuidString: "99999999-9999-9999-9999-999999999999")!,
- signal: EngagementSignal(sdp: "current-sdp", candidates: [])
- )
+ let stale = roomCredentials(expiresAt: Date(timeIntervalSince1970: 2_000))
+ let expired = roomCredentials(expiresAt: Date(timeIntervalSince1970: 999))
await coordinator.handle(ping(
recordName: recordName(gameID: gameID, authorID: "alice", deviceID: "deviceA", timestampMs: 800_000),
gameID: gameID,
authorID: "alice",
deviceID: "deviceA",
- payload: try staleOffer.encoded(),
+ payload: try stale.encoded(),
addressee: "bob:deviceB"
))
await coordinator.handle(ping(
@@ -322,12 +255,11 @@ struct EngagementCoordinatorTests {
gameID: gameID,
authorID: "alice",
deviceID: "deviceA",
- payload: try currentOffer.encoded(),
+ payload: try expired.encoded(),
addressee: "bob:deviceB"
))
- #expect(host.acceptedOffers == [currentOffer.engagementID])
- #expect(await sink.sentHails().count == 1)
+ #expect(host.connections.isEmpty)
#expect(await sink.deletedPings() == [
DeletedPing(
recordName: recordName(
@@ -350,556 +282,15 @@ struct EngagementCoordinatorTests {
])
}
- @Test("inbound reply accepts matching offered engagement and deletes reply ping")
- @MainActor
- func inboundReplyAcceptsOffer() async throws {
- let gameID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: offer.engagementID,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: ["reply-candidate"])
- )
-
- await coordinator.handle(ping(
- recordName: "reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
-
- #expect(host.acceptedReplies == [offer.engagementID])
- #expect(await sink.deletedPings() == [
- DeletedPing(recordName: "reply-record", gameID: gameID)
- ])
- }
-
- @Test("unmatched reply is deleted")
- @MainActor
- func unmatchedReplyIsDeleted() async throws {
- let gameID = UUID(uuidString: "CDCDCDCD-CDCD-CDCD-CDCD-CDCDCDCDCDCD")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [:] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
- )
-
- await coordinator.handle(ping(
- recordName: "unmatched-reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
-
- #expect(host.acceptedReplies.isEmpty)
- #expect(await sink.deletedPings() == [
- DeletedPing(recordName: "unmatched-reply-record", gameID: gameID)
- ])
- }
-
- @Test("same-account losing offer is deleted while local offer stays pending")
- @MainActor
- func sameAccountLosingOfferIsDeleted() async throws {
- let gameID = UUID(uuidString: "E1E1E1E1-E1E1-E1E1-E1E1-E1E1E1E1E1E1")!
- let remoteEngagementID = UUID(uuidString: "E2E2E2E2-E2E2-E2E2-E2E2-E2E2E2E2E2E2")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "aaa-device",
- presentPeers: { _ in [:] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- let remoteOffer = EngagementHailPayload(
- role: .offer,
- engagementID: remoteEngagementID,
- signal: EngagementSignal(sdp: "remote-offer-sdp", candidates: [])
- )
-
- await coordinator.offerEngagement(gameID: gameID)
- await coordinator.handle(ping(
- recordName: "remote-offer-record",
- gameID: gameID,
- authorID: "alice",
- deviceID: "zzz-device",
- payload: try remoteOffer.encoded(),
- addressee: "alice:aaa-device"
- ))
-
- #expect(host.createdOffers.count == 1)
- #expect(host.acceptedOffers.isEmpty)
- #expect(host.tornDown.isEmpty)
- #expect(await sink.sentHails().count == 1)
- #expect(await sink.deletedPings() == [
- DeletedPing(recordName: "remote-offer-record", gameID: gameID)
- ])
- }
-
- @Test("same-account winning offer replaces local offer and sends reply")
- @MainActor
- func sameAccountWinningOfferReplacesLocalOffer() async throws {
- let gameID = UUID(uuidString: "F1F1F1F1-F1F1-F1F1-F1F1-F1F1F1F1F1F1")!
- let remoteEngagementID = UUID(uuidString: "F2F2F2F2-F2F2-F2F2-F2F2-F2F2F2F2F2F2")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "zzz-device",
- presentPeers: { _ in [:] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- let remoteOffer = EngagementHailPayload(
- role: .offer,
- engagementID: remoteEngagementID,
- signal: EngagementSignal(sdp: "remote-offer-sdp", candidates: [])
- )
-
- await coordinator.offerEngagement(gameID: gameID)
- let localOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- await coordinator.handle(ping(
- recordName: "remote-offer-record",
- gameID: gameID,
- authorID: "alice",
- deviceID: "aaa-device",
- payload: try remoteOffer.encoded(),
- addressee: "alice:zzz-device"
- ))
-
- #expect(host.tornDown == [localOffer.engagementID])
- #expect(host.acceptedOffers == [remoteEngagementID])
- let sent = await sink.sentHails()
- #expect(sent.count == 2)
- #expect(sent.last?.addressee == "alice:aaa-device")
- #expect(EngagementHailPayload.decode(sent.last?.payload)?.role == .reply)
- #expect(EngagementHailPayload.decode(sent.last?.payload)?.engagementID == remoteEngagementID)
- #expect(await sink.deletedPings() == [
- DeletedPing(recordName: "remote-offer-record", gameID: gameID)
- ])
- }
-
- @Test("debug message sends over live engagement")
- @MainActor
- func debugMessageSendsOverLiveEngagement() async throws {
- let gameID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: offer.engagementID,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
- )
- await coordinator.handle(ping(
- recordName: "reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
- #expect(await coordinator.channelOpened(engagementID: offer.engagementID) == gameID)
-
- await coordinator.sendDebugMessage(gameID: gameID, text: "debug hello")
-
- #expect(host.sentMessages.count == 1)
- #expect(host.sentMessages.first?.engagementID == offer.engagementID)
- #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "debug hello")
- }
-
- @Test("selection sends over live engagement")
- @MainActor
- func selectionSendsOverLiveEngagement() async throws {
- let gameID = UUID(uuidString: "A1A1A1A1-A1A1-A1A1-A1A1-A1A1A1A1A1A1")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: offer.engagementID,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
- )
- await coordinator.handle(ping(
- recordName: "reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
- #expect(await coordinator.channelOpened(engagementID: offer.engagementID) == gameID)
- let selection = EngagementSelectionUpdate(
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- selection: PlayerSelection(row: 2, col: 3, direction: .across),
- updatedAt: Date(timeIntervalSince1970: 123)
- )
-
- await coordinator.sendSelection(selection)
-
- #expect(host.sentMessages.count == 1)
- #expect(host.sentMessages.first?.engagementID == offer.engagementID)
- #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.selection == selection)
- }
-
- @Test("reply alone does not enable sends until the channel opens")
- @MainActor
- func sendIsSkippedUntilChannelOpens() async throws {
- let gameID = UUID(uuidString: "A2A2A2A2-A2A2-A2A2-A2A2-A2A2A2A2A2A2")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: offer.engagementID,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
- )
- await coordinator.handle(ping(
- recordName: "reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
-
- await coordinator.sendDebugMessage(gameID: gameID, text: "too early")
- #expect(host.sentMessages.isEmpty)
-
- #expect(await coordinator.channelOpened(engagementID: offer.engagementID) == gameID)
- await coordinator.sendDebugMessage(gameID: gameID, text: "now ok")
- #expect(host.sentMessages.count == 1)
- }
-
- @Test("handshaking state demotes to idle if the channel never opens")
- @MainActor
- func staleHandshakingDemotesOnPresenceTick() async throws {
- let gameID = UUID(uuidString: "C0FFEE00-0000-0000-0000-000000000005")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- },
- now: { clock.now },
- handshakeTimeout: 30
- )
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let firstOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: firstOffer.engagementID,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
- )
- await coordinator.handle(ping(
- recordName: "reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
-
- clock.advance(by: 31)
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
-
- #expect(host.tornDown == [firstOffer.engagementID])
- let sent = await sink.sentHails()
- #expect(sent.count == 2)
- let secondOffer = try #require(EngagementHailPayload.decode(sent.last?.payload))
- #expect(secondOffer.role == .offer)
- #expect(secondOffer.engagementID != firstOffer.engagementID)
- }
-
- @Test("closed engagement can re-offer when peer remains present")
- @MainActor
- func channelCloseAllowsReconnectOffer() async throws {
- let gameID = UUID(uuidString: "B0B0B0B0-B0B0-B0B0-B0B0-B0B0B0B0B0B0")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- }
- )
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let firstOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- let reply = EngagementHailPayload(
- role: .reply,
- engagementID: firstOffer.engagementID,
- signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
- )
- await coordinator.handle(ping(
- recordName: "reply-record",
- gameID: gameID,
- authorID: "bob",
- deviceID: "deviceB",
- payload: try reply.encoded(),
- addressee: "alice:deviceA"
- ))
-
- #expect(await coordinator.channelClosed(engagementID: firstOffer.engagementID) == gameID)
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
-
- #expect(host.createdOffers.count == 2)
- let sent = await sink.sentHails()
- #expect(sent.count == 2)
- let secondOffer = try #require(EngagementHailPayload.decode(sent.last?.payload))
- #expect(secondOffer.role == .offer)
- #expect(secondOffer.engagementID != firstOffer.engagementID)
- }
-
- @Test("stale offered state demotes to idle on next presence tick and host is torn down")
- @MainActor
- func staleOfferDemotesOnPresenceTick() async throws {
- let gameID = UUID(uuidString: "C0FFEE00-0000-0000-0000-000000000001")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- },
- now: { clock.now },
- handshakeTimeout: 30
+ private func roomCredentials(
+ expiresAt: Date = .distantFuture
+ ) -> EngagementRoomCredentials {
+ EngagementRoomCredentials(
+ roomID: UUID(uuidString: "88888888-8888-8888-8888-888888888888")!,
+ secret: Data(repeating: 3, count: 32).base64URLEncodedString(),
+ createdAt: Date(timeIntervalSince1970: 1_000),
+ expiresAt: expiresAt
)
-
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let firstOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
- #expect(host.createdOffers == [firstOffer.engagementID])
-
- clock.advance(by: 31)
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
-
- #expect(host.tornDown == [firstOffer.engagementID])
- let sent = await sink.sentHails()
- #expect(sent.count == 2)
- let secondOffer = try #require(EngagementHailPayload.decode(sent.last?.payload))
- #expect(secondOffer.role == .offer)
- #expect(secondOffer.engagementID != firstOffer.engagementID)
- #expect(host.createdOffers == [firstOffer.engagementID, secondOffer.engagementID])
- }
-
- @Test("fresh offered state is not demoted by the sweep")
- @MainActor
- func freshOfferSurvivesPresenceTick() async throws {
- let gameID = UUID(uuidString: "C0FFEE00-0000-0000-0000-000000000002")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- },
- now: { clock.now },
- handshakeTimeout: 30
- )
-
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- clock.advance(by: 15)
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
-
- #expect(host.tornDown.isEmpty)
- #expect(await sink.sentHails().count == 1)
- #expect(host.createdOffers.count == 1)
- }
-
- @Test("stale replied state demotes to idle and tears down the host")
- @MainActor
- func staleReplyDemotesOnPresenceTick() async throws {
- let gameID = UUID(uuidString: "C0FFEE00-0000-0000-0000-000000000003")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
- let coordinator = EngagementCoordinator(
- host: host,
- // Local author is bob so an incoming offer from alice lands us in `.handshaking`
- // (peerPresenceMayHaveChanged would not initiate because alice < bob).
- localAuthorID: { "bob" },
- localDeviceID: "deviceB",
- presentPeers: { _ in [:] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- },
- now: { clock.now },
- handshakeTimeout: 30
- )
- let offer = EngagementHailPayload(
- role: .offer,
- engagementID: UUID(uuidString: "C0FFEE00-0000-0000-0000-0000000000A1")!,
- signal: EngagementSignal(sdp: "offer-sdp", candidates: [])
- )
- await coordinator.handle(ping(
- recordName: recordName(
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- timestampMs: Int64(clock.now.timeIntervalSince1970 * 1000)
- ),
- gameID: gameID,
- authorID: "alice",
- deviceID: "deviceA",
- payload: try offer.encoded(),
- addressee: "bob:deviceB"
- ))
- #expect(host.acceptedOffers == [offer.engagementID])
- #expect(host.tornDown.isEmpty)
-
- clock.advance(by: 31)
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
-
- #expect(host.tornDown == [offer.engagementID])
- }
-
- @Test("manual offer recovers a stuck offered state")
- @MainActor
- func manualOfferRecoversFromStuckOffered() async throws {
- let gameID = UUID(uuidString: "C0FFEE00-0000-0000-0000-000000000004")!
- let host = MockEngagementHost()
- let sink = EngagementCoordinatorTestSink()
- let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
- let coordinator = EngagementCoordinator(
- host: host,
- localAuthorID: { "alice" },
- localDeviceID: "deviceA",
- presentPeers: { _ in [gameID: ["bob"]] },
- sendHail: { gameID, payload, addressee in
- await sink.send(gameID: gameID, payload: payload, addressee: addressee)
- },
- deletePing: { recordName, gameID in
- await sink.delete(recordName: recordName, gameID: gameID)
- },
- now: { clock.now },
- handshakeTimeout: 30
- )
-
- await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
- let firstOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
-
- clock.advance(by: 31)
- await coordinator.offerEngagement(gameID: gameID)
-
- #expect(host.tornDown == [firstOffer.engagementID])
- let sent = await sink.sentHails()
- #expect(sent.count == 2)
- let secondOffer = try #require(EngagementHailPayload.decode(sent.last?.payload))
- #expect(secondOffer.engagementID != firstOffer.engagementID)
}
private func ping(
@@ -988,34 +379,37 @@ private final class TestClock: @unchecked Sendable {
}
@MainActor
-private final class MockEngagementHost: EngagementHosting, @unchecked Sendable {
- let offerSignal = EngagementSignal(sdp: "local-offer-sdp", candidates: ["local-offer-candidate"])
- let replySignal = EngagementSignal(sdp: "local-reply-sdp", candidates: ["local-reply-candidate"])
- var createdOffers: [UUID] = []
- var acceptedOffers: [UUID] = []
- var acceptedReplies: [UUID] = []
- var tornDown: [UUID] = []
- var sentMessages: [(engagementID: UUID, message: Data)] = []
-
- func createOffer(engagementID: UUID) async throws -> EngagementSignal {
- createdOffers.append(engagementID)
- return offerSignal
+private final class MockEngagementHost: EngagementTransporting, @unchecked Sendable {
+ struct Connection: Equatable {
+ var engagementID: UUID
+ var room: EngagementRoomCredentials
+ var authorID: String
+ var deviceID: String
}
- func acceptOfferAndReply(engagementID: UUID, signal: EngagementSignal) async throws -> EngagementSignal {
- acceptedOffers.append(engagementID)
- return replySignal
- }
+ var connections: [Connection] = []
+ var disconnected: [UUID] = []
+ var sentMessages: [(engagementID: UUID, message: Data)] = []
- func acceptReply(engagementID: UUID, signal: EngagementSignal) async throws {
- acceptedReplies.append(engagementID)
+ func connect(
+ engagementID: UUID,
+ room: EngagementRoomCredentials,
+ authorID: String,
+ deviceID: String
+ ) async throws {
+ connections.append(Connection(
+ engagementID: engagementID,
+ room: room,
+ authorID: authorID,
+ deviceID: deviceID
+ ))
}
- func send(engagementID: UUID, message: Data) throws {
+ func send(engagementID: UUID, message: Data) async throws {
sentMessages.append((engagementID, message))
}
- func teardown(engagementID: UUID) {
- tornDown.append(engagementID)
+ func disconnect(engagementID: UUID) {
+ disconnected.append(engagementID)
}
}
diff --git a/Worker/engagement-worker.js b/Worker/engagement-worker.js
@@ -0,0 +1,196 @@
+export class EngagementRoom {
+ constructor(state, env) {
+ this.state = state;
+ this.env = env;
+ }
+
+ async fetch(request) {
+ const upgrade = request.headers.get("Upgrade");
+ if (upgrade !== "websocket") {
+ return new Response("Expected WebSocket upgrade", { status: 426 });
+ }
+
+ const url = new URL(request.url);
+ const roomID = roomIDFromPath(url.pathname);
+ if (!roomID) {
+ return new Response("Missing room ID", { status: 400 });
+ }
+
+ const auth = await this.authenticate(roomID, url.searchParams);
+ if (!auth.ok) {
+ return new Response(auth.message, { status: auth.status });
+ }
+
+ const pair = new WebSocketPair();
+ const [client, server] = Object.values(pair);
+ server.serializeAttachment({
+ authorID: auth.authorID,
+ deviceID: auth.deviceID,
+ connectedAt: Date.now()
+ });
+ this.state.acceptWebSocket(server);
+
+ return new Response(null, {
+ status: 101,
+ webSocket: client
+ });
+ }
+
+ async authenticate(roomID, params) {
+ const authorID = params.get("authorID") || "";
+ const deviceID = params.get("deviceID") || "";
+ const timestamp = params.get("timestamp") || "";
+ const nonce = params.get("nonce") || "";
+ const secret = params.get("secret") || "";
+ const signature = params.get("signature") || "";
+
+ if (!authorID || !deviceID || !timestamp || !nonce || !secret || !signature) {
+ return { ok: false, status: 401, message: "Missing auth parameters" };
+ }
+
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const timestampSeconds = Number(timestamp);
+ const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120");
+ if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) {
+ return { ok: false, status: 401, message: "Stale auth timestamp" };
+ }
+
+ const nonceKey = `nonce:${nonce}`;
+ if (await this.state.storage.get(nonceKey)) {
+ return { ok: false, status: 401, message: "Nonce already used" };
+ }
+
+ const payload = [roomID, authorID, deviceID, timestamp, nonce].join("|");
+ const expectedSignature = await hmacSHA256(secret, payload);
+ if (!timingSafeEqual(signature, expectedSignature)) {
+ return { ok: false, status: 401, message: "Invalid signature" };
+ }
+
+ const secretHash = await sha256(secret);
+ const storedSecretHash = await this.state.storage.get("secretHash");
+ if (storedSecretHash && storedSecretHash !== secretHash) {
+ return { ok: false, status: 403, message: "Wrong room secret" };
+ }
+ if (!storedSecretHash) {
+ await this.state.storage.put("secretHash", secretHash);
+ await this.state.storage.put("createdAt", Date.now());
+ }
+
+ await this.state.storage.put(nonceKey, Date.now());
+ await this.pruneNonces();
+ await this.scheduleExpiry();
+
+ return { ok: true, authorID, deviceID };
+ }
+
+ async webSocketMessage(ws, message) {
+ const sender = ws.deserializeAttachment();
+ const data = typeof message === "string" ? message : message.slice(0);
+ for (const peer of this.state.getWebSockets()) {
+ if (peer === ws) continue;
+ peer.send(data);
+ }
+ console.log("engagement message", {
+ authorID: sender?.authorID,
+ deviceID: sender?.deviceID,
+ bytes: typeof message === "string" ? message.length : message.byteLength
+ });
+ }
+
+ async webSocketClose(ws, code, reason) {
+ ws.close(code, reason);
+ }
+
+ async alarm() {
+ await this.pruneNonces();
+ const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000;
+ const createdAt = await this.state.storage.get("createdAt");
+ if (createdAt && Date.now() - createdAt > ttlMs) {
+ for (const ws of this.state.getWebSockets()) {
+ ws.close(1001, "room expired");
+ }
+ await this.state.storage.deleteAll();
+ return;
+ }
+ await this.scheduleExpiry();
+ }
+
+ async pruneNonces() {
+ const maxAgeMs = Number(this.env.NONCE_TTL_SECONDS || "300") * 1000;
+ const cutoff = Date.now() - maxAgeMs;
+ const nonces = await this.state.storage.list({ prefix: "nonce:" });
+ for (const [key, createdAt] of nonces) {
+ if (createdAt < cutoff) {
+ await this.state.storage.delete(key);
+ }
+ }
+ }
+
+ async scheduleExpiry() {
+ const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000;
+ const createdAt = (await this.state.storage.get("createdAt")) || Date.now();
+ await this.state.storage.setAlarm(createdAt + ttlMs);
+ }
+}
+
+export default {
+ async fetch(request, env) {
+ const url = new URL(request.url);
+ if (url.pathname === "/health") {
+ return new Response("ok");
+ }
+ const roomID = roomIDFromPath(url.pathname);
+ if (!roomID) {
+ return new Response("Not found", { status: 404 });
+ }
+ const id = env.ENGAGEMENT_ROOMS.idFromName(roomID);
+ return env.ENGAGEMENT_ROOMS.get(id).fetch(request);
+ }
+};
+
+function roomIDFromPath(pathname) {
+ const match = pathname.match(/^\/rooms\/([^/]+)\/socket$/);
+ return match ? match[1] : null;
+}
+
+async function hmacSHA256(secret, payload) {
+ const key = await crypto.subtle.importKey(
+ "raw",
+ base64URLDecode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
+ return base64URLEncode(new Uint8Array(signature));
+}
+
+async function sha256(value) {
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
+ return base64URLEncode(new Uint8Array(digest));
+}
+
+function base64URLDecode(value) {
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
+ const binary = atob(base64);
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
+}
+
+function base64URLEncode(bytes) {
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+}
+
+function timingSafeEqual(a, b) {
+ const left = new TextEncoder().encode(a);
+ const right = new TextEncoder().encode(b);
+ if (left.length !== right.length) return false;
+ let diff = 0;
+ for (let index = 0; index < left.length; index += 1) {
+ diff |= left[index] ^ right[index];
+ }
+ return diff === 0;
+}
diff --git a/Worker/turn-credentials.js b/Worker/turn-credentials.js
@@ -1,107 +0,0 @@
-// Cloudflare Worker that mints short-lived Cloudflare Realtime TURN
-// credentials for Crossmate's engagement bridge.
-//
-// Required Worker secrets / vars:
-// TURN_KEY_ID Cloudflare Realtime TURN key ID
-// TURN_KEY_API_TOKEN API token allowed to generate credentials for the key
-//
-// Optional Worker vars:
-// TURN_TTL_SECONDS Defaults to 3600
-//
-// Deploy with `wrangler deploy`, then set CrossmateTURNCredentialsURL in the
-// app's Info.plist/build settings to this Worker's /turn-credentials endpoint.
-
-const DEFAULT_TTL_SECONDS = 3600;
-const CLOUDFLARE_TURN_API_BASE = "https://rtc.live.cloudflare.com";
-
-function ttlSeconds(env) {
- const parsed = Number.parseInt(env.TURN_TTL_SECONDS || "", 10);
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_SECONDS;
-}
-
-function jsonResponse(body, init = {}) {
- return new Response(JSON.stringify(body), {
- ...init,
- headers: {
- "content-type": "application/json",
- "cache-control": "no-store",
- ...(init.headers || {}),
- },
- });
-}
-
-function stripBrowserHostilePort53(iceServers) {
- return iceServers.map((server) => {
- if (!Array.isArray(server.urls)) return server;
- return {
- ...server,
- urls: server.urls.filter((url) => !url.includes(":53")),
- };
- });
-}
-
-async function generateIceServers(env) {
- if (!env.TURN_KEY_ID || !env.TURN_KEY_API_TOKEN) {
- throw new Error("TURN_KEY_ID and TURN_KEY_API_TOKEN must be configured.");
- }
-
- const response = await fetch(
- `${CLOUDFLARE_TURN_API_BASE}/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate-ice-servers`,
- {
- method: "POST",
- headers: {
- authorization: `Bearer ${env.TURN_KEY_API_TOKEN}`,
- "content-type": "application/json",
- },
- body: JSON.stringify({ ttl: ttlSeconds(env) }),
- }
- );
-
- const text = await response.text();
- let body = null;
- if (text) {
- try {
- body = JSON.parse(text);
- } catch {
- body = { message: text };
- }
- }
-
- if (!response.ok) {
- const detail = body?.message || body?.error || response.statusText;
- throw new Error(`Cloudflare TURN credential generation failed: ${detail}`);
- }
-
- if (!body || !Array.isArray(body.iceServers)) {
- throw new Error("Cloudflare TURN response did not include iceServers.");
- }
-
- return {
- ttl: ttlSeconds(env),
- iceServers: stripBrowserHostilePort53(body.iceServers),
- };
-}
-
-export default {
- async fetch(request, env) {
- const url = new URL(request.url);
- if (url.pathname !== "/turn-credentials") {
- return new Response("Not Found", { status: 404 });
- }
- if (request.method !== "POST") {
- return new Response("Method Not Allowed", {
- status: 405,
- headers: { allow: "POST" },
- });
- }
-
- try {
- return jsonResponse(await generateIceServers(env), { status: 201 });
- } catch (error) {
- return jsonResponse(
- { error: error && error.message ? error.message : String(error) },
- { status: 500 }
- );
- }
- },
-};
diff --git a/Worker/wrangler.toml b/Worker/wrangler.toml
@@ -1,7 +1,17 @@
-name = "crossmate-turn"
-main = "turn-credentials.js"
+name = "crossmate-engagement"
+main = "engagement-worker.js"
compatibility_date = "2026-05-25"
workers_dev = true
[vars]
-TURN_TTL_SECONDS = "3600"
+ROOM_TTL_SECONDS = "600"
+NONCE_TTL_SECONDS = "300"
+MAX_AUTH_SKEW_SECONDS = "120"
+
+[[durable_objects.bindings]]
+name = "ENGAGEMENT_ROOMS"
+class_name = "EngagementRoom"
+
+[[migrations]]
+tag = "v1"
+new_sqlite_classes = ["EngagementRoom"]
diff --git a/project.yml b/project.yml
@@ -67,7 +67,7 @@ targets:
- net.inqk.crossmate.xd
- com.litsoft.puz
CKSharingSupported: true
- CrossmateTURNCredentialsURL: $(CROSSMATE_TURN_CREDENTIALS_URL)
+ CrossmateEngagementSocketURL: $(CROSSMATE_ENGAGEMENT_SOCKET_URL)
LSSupportsOpeningDocumentsInPlace: false
UILaunchScreen: {}
UISupportedInterfaceOrientations:
@@ -80,7 +80,7 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate
INFOPLIST_FILE: Crossmate/Info.plist
CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements
- CROSSMATE_TURN_CREDENTIALS_URL: $(inherited)
+ CROSSMATE_ENGAGEMENT_SOCKET_URL: $(inherited)
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
configs: