commit 4370e15748a4ab61fddc546e12c83347441d9e1c
parent e448979a2ff5feb58131ef93b2d466a2eab7e3fe
Author: Michael Camilleri <[email protected]>
Date: Mon, 25 May 2026 21:54:47 +0900
Add Cloudflare TURN credentials for engagements
STUN-only WebRTC could complete CloudKit signaling and still leave both devices
stuck at 'reply accepted …, awaiting channel' until the handshake watchdog
timed out. That made live sessions unreliable on network pairs that need a
relay candidate.
This commit adds a Cloudflare Worker that mints short-lived Realtime TURN ICE
servers from server-side TURN key secrets, plus app-side plumbing to fetch
those ICE servers before creating an offer or accepting an offer. The WebRTC
host now passes the configured ICE server list into RTCPeerConnection and falls
back to the existing Cloudflare STUN server if the local endpoint is absent or
the credential fetch fails.
The build config now goes through Generated/Config.xcconfig, which includes an
ignored Generated/Local.xcconfig for local-only values such as the Worker URL
while still generating CURRENT_PROJECT_VERSION during the scheme pre-action.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
9 files changed, 233 insertions(+), 20 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -36,6 +36,7 @@
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 */; };
@@ -141,8 +142,10 @@
/* 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>"; };
0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; };
@@ -158,7 +161,6 @@
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>"; };
2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorDeltaTests.swift; sourceTree = "<group>"; };
- 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; };
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; };
@@ -399,7 +401,7 @@
6F470E54D9E6E99FCEA893D1 /* Generated */ = {
isa = PBXGroup;
children = (
- 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */,
+ 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */,
);
path = Generated;
sourceTree = "<group>";
@@ -496,6 +498,7 @@
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */,
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
+ 03C86626C072FF4C0630EC47 /* TURNCredentials.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -719,6 +722,7 @@
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 */,
);
@@ -737,7 +741,7 @@
/* Begin XCBuildConfiguration section */
209C1E6D178C7EF962FC85A5 /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */;
+ baseConfigurationReference = 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -842,6 +846,7 @@
CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
+ CROSSMATE_TURN_CREDENTIALS_URL = "$(inherited)";
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -861,6 +866,7 @@
CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
+ CROSSMATE_TURN_CREDENTIALS_URL = "$(inherited)";
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -874,7 +880,7 @@
};
E7B092DD549FA4FFED8BC20E /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */;
+ baseConfigurationReference = 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_EXTENSIONS = YES;
diff --git a/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme b/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme
@@ -11,7 +11,7 @@
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Script"
- scriptText = "cd "${SRCROOT}" mkdir -p Generated buildYear=`git log -1 --format=%cd --date=format:%Y` buildVersion=`git rev-list HEAD | wc -l | tr -d ' '` echo "CURRENT_PROJECT_VERSION = $buildYear.$buildVersion" > Generated/BuildNumber.xcconfig ">
+ scriptText = "cd "${SRCROOT}" mkdir -p Generated buildYear=`git log -1 --format=%cd --date=format:%Y` buildVersion=`git rev-list HEAD | wc -l | tr -d ' '` { echo '#include? "Local.xcconfig"' echo "CURRENT_PROJECT_VERSION = $buildYear.$buildVersion" } > Generated/Config.xcconfig ">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist
@@ -38,6 +38,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CKSharingSupported</key>
<true/>
+ <key>CrossmateTURNCredentialsURL</key>
+ <string>$(CROSSMATE_TURN_CREDENTIALS_URL)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
diff --git a/Crossmate/Services/EngagementHost.html b/Crossmate/Services/EngagementHost.html
@@ -21,16 +21,23 @@
});
}
- function iceServers() {
+ function defaultIceServers() {
return [{ urls: "stun:stun.cloudflare.com:3478" }];
}
- function createPeer(engagementID) {
+ 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()
+ iceServers: iceServers(configuredIceServers)
});
const peer = { pc, channel: null, closed: false };
peers.set(engagementID, peer);
@@ -146,9 +153,9 @@
post({ type: "onChannelClose", engagementID });
}
- async function createOffer(engagementID) {
+ async function createOffer(engagementID, _signal, iceServers) {
try {
- const peer = createPeer(engagementID);
+ const peer = createPeer(engagementID, iceServers);
const channel = peer.pc.createDataChannel("crossmate");
attachChannel(engagementID, channel);
await peer.pc.setLocalDescription(await peer.pc.createOffer());
@@ -162,9 +169,9 @@
}
}
- async function acceptOfferAndReply(engagementID, signal) {
+ async function acceptOfferAndReply(engagementID, signal, iceServers) {
try {
- const peer = createPeer(engagementID);
+ 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);
diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift
@@ -16,17 +16,19 @@ final class EngagementHost: NSObject {
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> = []
- override init() {
+ 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
@@ -34,19 +36,23 @@ final class EngagementHost: NSObject {
func createOffer(engagementID: UUID) async throws -> Signal {
beginEngagement(engagementID)
+ let iceServers = await loadIceServers(engagementID: engagementID)
return try await callSignalFunction(
"createOffer",
engagementID: engagementID,
- signal: nil
+ 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
+ signal: signal,
+ iceServers: iceServers
)
}
@@ -97,6 +103,18 @@ final class EngagementHost: NSObject {
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 {
@@ -119,11 +137,15 @@ final class EngagementHost: NSObject {
private func callSignalFunction(
_ name: String,
engagementID: UUID,
- signal: Signal?
+ signal: Signal?,
+ iceServers: [RTCIceServerConfig]?
) async throws -> Signal {
try await loadIfNeeded()
let encodedArgument = try Self.javascriptLiteral(signal)
- let script = "window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument)); undefined"
+ 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
diff --git a/Crossmate/Services/TURNCredentials.swift b/Crossmate/Services/TURNCredentials.swift
@@ -0,0 +1,58 @@
+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/Worker/turn-credentials.js b/Worker/turn-credentials.js
@@ -0,0 +1,107 @@
+// 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
@@ -0,0 +1,6 @@
+name = "crossmate"
+main = "turn-credentials.js"
+compatibility_date = "2026-05-25"
+
+[vars]
+TURN_TTL_SECONDS = "3600"
diff --git a/project.yml b/project.yml
@@ -6,8 +6,8 @@ options:
iOS: "26.0"
configFiles:
- Debug: Generated/BuildNumber.xcconfig
- Release: Generated/BuildNumber.xcconfig
+ Debug: Generated/Config.xcconfig
+ Release: Generated/Config.xcconfig
settings:
SWIFT_VERSION: 6
@@ -67,6 +67,7 @@ targets:
- net.inqk.crossmate.xd
- com.litsoft.puz
CKSharingSupported: true
+ CrossmateTURNCredentialsURL: $(CROSSMATE_TURN_CREDENTIALS_URL)
LSSupportsOpeningDocumentsInPlace: false
UILaunchScreen: {}
UISupportedInterfaceOrientations:
@@ -79,6 +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)
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
configs:
@@ -115,7 +117,10 @@ schemes:
mkdir -p Generated
buildYear=`git log -1 --format=%cd --date=format:%Y`
buildVersion=`git rev-list HEAD | wc -l | tr -d ' '`
- echo "CURRENT_PROJECT_VERSION = $buildYear.$buildVersion" > Generated/BuildNumber.xcconfig
+ {
+ echo '#include? "Local.xcconfig"'
+ echo "CURRENT_PROJECT_VERSION = $buildYear.$buildVersion"
+ } > Generated/Config.xcconfig
settingsTarget: Crossmate
run:
config: Debug