diff --git a/LiveKitExample.xcodeproj/project.pbxproj b/LiveKitExample.xcodeproj/project.pbxproj index 12545a5..db81ee1 100644 --- a/LiveKitExample.xcodeproj/project.pbxproj +++ b/LiveKitExample.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 68A50EE02C4C1ED500D2DE17 /* LiveKitExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A50ED92C4C1ED500D2DE17 /* LiveKitExample.swift */; }; 68A50EE22C4C1ED500D2DE17 /* RoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A50EDB2C4C1ED500D2DE17 /* RoomView.swift */; }; 68A50EE32C4C1ED500D2DE17 /* PublishOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A50ED22C4C1ED500D2DE17 /* PublishOptionsView.swift */; }; + RPC1234567890ABCDEF000001 /* RpcTesterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RPC1234567890ABCDEF000002 /* RpcTesterView.swift */; }; 68A50EE42C4C1ED500D2DE17 /* SecureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A50ED02C4C1ED500D2DE17 /* SecureStore.swift */; }; 68A50EE52C4C1ED500D2DE17 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A50ED62C4C1ED500D2DE17 /* ConnectView.swift */; }; 68A50EE62C4C1ED500D2DE17 /* ExampleRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A50ECE2C4C1ED500D2DE17 /* ExampleRoomMessage.swift */; }; @@ -87,6 +88,7 @@ 68A50ECF2C4C1ED500D2DE17 /* Participant+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Participant+Helpers.swift"; sourceTree = ""; }; 68A50ED02C4C1ED500D2DE17 /* SecureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStore.swift; sourceTree = ""; }; 68A50ED22C4C1ED500D2DE17 /* PublishOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishOptionsView.swift; sourceTree = ""; }; + RPC1234567890ABCDEF000002 /* RpcTesterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RpcTesterView.swift; sourceTree = ""; }; 68A50ED32C4C1ED500D2DE17 /* ScreenShareSourcePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenShareSourcePickerView.swift; sourceTree = ""; }; 68A50ED52C4C1ED500D2DE17 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 68A50ED62C4C1ED500D2DE17 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; @@ -189,6 +191,7 @@ 6830E6BD2D5BE5DF001C5E83 /* AudioControlsPanel.swift */, 6830E6C02D5BE5E0001C5E83 /* MessagesPanel.swift */, 68A50ED22C4C1ED500D2DE17 /* PublishOptionsView.swift */, + RPC1234567890ABCDEF000002 /* RpcTesterView.swift */, 68A50ED32C4C1ED500D2DE17 /* ScreenShareSourcePickerView.swift */, 6888FBE02C66B7B100AB93C1 /* ImmersiveView.swift */, 68A50ED62C4C1ED500D2DE17 /* ConnectView.swift */, @@ -390,6 +393,7 @@ 7BBEBA7A2D79103800586EC4 /* RoomContextView.swift in Sources */, 68A50EE22C4C1ED500D2DE17 /* RoomView.swift in Sources */, 68A50EE32C4C1ED500D2DE17 /* PublishOptionsView.swift in Sources */, + RPC1234567890ABCDEF000001 /* RpcTesterView.swift in Sources */, 68A50EE42C4C1ED500D2DE17 /* SecureStore.swift in Sources */, 68A50EE52C4C1ED500D2DE17 /* ConnectView.swift in Sources */, 7BBEBA8B2D7921AA00586EC4 /* LKTextField.swift in Sources */, diff --git a/Multiplatform/Views/RoomView.swift b/Multiplatform/Views/RoomView.swift index 16a01fe..a00cd01 100644 --- a/Multiplatform/Views/RoomView.swift +++ b/Multiplatform/Views/RoomView.swift @@ -79,6 +79,7 @@ struct RoomView: View { @State private var screenPickerPresented = false @State private var publishOptionsPickerPresented = false + @State private var rpcTesterPresented = false @State private var cameraPublishOptions = VideoPublishOptions() @@ -542,10 +543,19 @@ struct RoomView: View { } } + Button("RPC Tester...") { + rpcTesterPresented = true + } + } label: { Image(systemSymbol: .gear) .renderingMode(.original) } + .sheet(isPresented: $rpcTesterPresented) { + RpcTesterView(room: room) { + rpcTesterPresented = false + } + } // Disconnect Button(action: { diff --git a/Multiplatform/Views/RpcTesterView.swift b/Multiplatform/Views/RpcTesterView.swift new file mode 100644 index 0000000..4c25fc9 --- /dev/null +++ b/Multiplatform/Views/RpcTesterView.swift @@ -0,0 +1,261 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import LiveKit +import SwiftUI + +/// Sheet presented from the RoomView toolbar. Lets the user pick a destination +/// participant, type a method name and payload, send an RPC, and view the response. +struct RpcTesterView: View { + let room: Room + let onClose: () -> Void + + @State private var identity: String = "" + @State private var method: String = "test" + @State private var payload: String = "" + @State private var isSending: Bool = false + @State private var result: RpcOutcome? + + private enum RpcOutcome { + case success(payload: String, latency: TimeInterval) + case failure(error: Error) + } + + private var remoteIdentities: [String] { + room.remoteParticipants.keys + .map(\.stringValue) + .sorted() + } + + private var canSend: Bool { + !identity.isEmpty && !method.isEmpty && !isSending + } + + var body: some View { + NavigationStack { + Form { + destinationSection + methodSection + payloadSection + if let result { + resultSection(for: result) + } + } + #if os(iOS) + .listSectionSpacing(24) + #endif + .navigationTitle("RPC Tester") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: onClose) + } + #if os(iOS) + ToolbarItem(placement: .bottomBar) { + HStack { + Spacer() + sendButton + } + } + #else + ToolbarItem(placement: .confirmationAction) { + sendButton + } + #endif + } + } + .onAppear { + if identity.isEmpty, let first = remoteIdentities.first { + identity = first + } + } + } + + private var sendButton: some View { + Button(action: sendRpc) { + HStack(spacing: 6) { + if isSending { + ProgressView() + #if os(macOS) + .scaleEffect(0.6) + #endif + Text("Sending…") + } else { + Image(systemName: "paperplane.fill") + Text("Send") + } + } + } + .buttonStyle(.borderedProminent) + .disabled(!canSend) + } + + // MARK: - Sections + + @ViewBuilder + private var destinationSection: some View { + Section { + TextField("", text: $identity) + .textFieldStyle(.automatic) + .autocorrectionDisabled() + .padding(.vertical, 4) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + + if !remoteIdentities.isEmpty { + Menu { + ForEach(remoteIdentities, id: \.self) { id in + Button(id) { identity = id } + } + } label: { + Label("Pick from room (\(remoteIdentities.count))", systemImage: "person.2") + .font(.caption) + } + .padding(.vertical, 4) + } else { + Text("No remote participants in room") + .font(.caption) + .foregroundColor(.secondary) + .padding(.vertical, 4) + } + } header: { + Text("Destination participant") + } + } + + @ViewBuilder + private var methodSection: some View { + Section("Method") { + TextField("", text: $method) + .autocorrectionDisabled() + .padding(.vertical, 4) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + } + + @ViewBuilder + private var payloadSection: some View { + Section { + TextEditor(text: $payload) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 120, maxHeight: 240) + .autocorrectionDisabled() + .padding(.vertical, 4) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + + HStack { + Text("\(payload.utf8.count) bytes") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Menu("Quick fill") { + Button("1 KB of 'a'") { payload = String(repeating: "a", count: 1_000) } + Button("20 KB of 'a' (forces v2)") { payload = String(repeating: "a", count: 20_000) } + Button("Clear") { payload = "" } + } + .font(.caption) + .padding(.vertical, 4) + } + } header: { + Text("Payload") + } + } + + @ViewBuilder + private func resultSection(for outcome: RpcOutcome) -> some View { + switch outcome { + case let .success(responsePayload, latency): + Section { + HStack { + Label("\(responsePayload.utf8.count) bytes", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + Spacer() + Text(String(format: "%.0f ms", latency * 1000)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + ScrollView { + Text(responsePayload.isEmpty ? "(empty response)" : responsePayload) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(.vertical, 4) + } + .frame(minHeight: 80, maxHeight: 240) + } header: { + Text("Response") + } + case let .failure(error): + Section { + Label(failureTitle(for: error), systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + if let rpc = error as? RpcError { + Text("Code \(rpc.code) — \(rpc.message)") + .font(.caption) + .foregroundColor(.secondary) + if !rpc.data.isEmpty { + Text(rpc.data) + .font(.caption.monospaced()) + .foregroundColor(.secondary) + } + } else { + Text(String(describing: error)) + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + Text("Error") + } + } + } + + private func failureTitle(for error: Error) -> String { + if let rpc = error as? RpcError { return rpc.message } + return "RPC failed" + } + + // MARK: - Send + + private func sendRpc() { + let destination = Participant.Identity(from: identity) + let methodName = method + let body = payload + result = nil + isSending = true + let started = Date() + + Task { @MainActor in + do { + let response = try await room.localParticipant.performRpc( + destinationIdentity: destination, + method: methodName, + payload: body + ) + result = .success(payload: response, latency: Date().timeIntervalSince(started)) + } catch { + result = .failure(error: error) + } + isSending = false + } + } +}