From 23f77c3576c63429a4816a8723a5c37561d07d35 Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 12:24:22 -0400 Subject: [PATCH 01/18] Add Rust-backed iOS PR preview workflow --- .gitignore | 4 +- Cargo.lock | 2 + apps/ios/DevOpsDefender/ContentView.swift | 200 +++++++- apps/ios/DevOpsDefender/DDClientBridge.swift | 493 +++++++++++++++++++ apps/ios/DevOpsDefender/DDClientFFI.h | 16 + apps/ios/README.md | 118 +++-- apps/ios/Scripts/build-rust.sh | 81 +++ apps/ios/project.yml | 15 +- apps/ios/run-designed-for-ipad-on-mac.sh | 60 ++- crates/dd-client-core/Cargo.toml | 3 +- crates/dd-client-core/src/lib.rs | 41 ++ crates/dd-client-ffi/Cargo.toml | 3 +- crates/dd-client-ffi/src/lib.rs | 330 ++++++++++++- 13 files changed, 1312 insertions(+), 54 deletions(-) create mode 100644 apps/ios/DevOpsDefender/DDClientBridge.swift create mode 100644 apps/ios/DevOpsDefender/DDClientFFI.h create mode 100755 apps/ios/Scripts/build-rust.sh diff --git a/.gitignore b/.gitignore index 24f2189..efec3cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.key *.key.tmp .DS_Store - +apps/ios/DevOpsDefender.xcodeproj/ +apps/ios/com.apple.DeveloperTools/ +apps/ios/err diff --git a/Cargo.lock b/Cargo.lock index ae2018c..b00d45a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -377,7 +377,9 @@ dependencies = [ name = "dd-client-ffi" version = "0.1.0" dependencies = [ + "base64", "dd-client-core", + "hex", "serde_json", "tempfile", "tokio", diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index aa47ed6..897dc4a 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,24 +1,202 @@ import SwiftUI struct ContentView: View { + @StateObject private var viewModel = ClientViewModel() + var body: some View { NavigationStack { - List { - Section("Pairing") { - LabeledContent("Device key", value: "Not generated") - Button("Generate enrollment URL") {} + Form { + Section("Connection") { + TextField("Agent URL", text: $viewModel.agentURL) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + + TextField("Noise key path", text: $viewModel.keyPath) + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + + Button("Use app support key path") { + viewModel.useAppSupportKeyPath() + } + + Toggle("Dev/test: skip TDX quote verification", isOn: $viewModel.insecureSkipQuoteVerify) + + if !viewModel.insecureSkipQuoteVerify { + SecureField("Intel Trust Authority API key", text: $viewModel.itaAPIKey) + TextField("ITA base URL", text: $viewModel.itaBaseURL) + .textInputAutocapitalization(.never) + TextField("ITA JWKS URL", text: $viewModel.itaJwksURL) + .textInputAutocapitalization(.never) + TextField("ITA issuer", text: $viewModel.itaIssuer) + .textInputAutocapitalization(.never) + } + } + + Section("Key Content") { + Text("Paste a 32-byte Noise key as hex or base64 when the app cannot read your host key path.") + .font(.footnote) + .foregroundStyle(.secondary) + + TextEditor(text: $viewModel.keyContent) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 76) + + Button("Import pasted key to path") { + viewModel.importPastedKey() + } + .disabled(viewModel.isBusy) + } + + Section("Recipes") { + Button("Load recipes") { + viewModel.loadRecipes() + } + .disabled(viewModel.isBusy) + + if viewModel.recipes.isEmpty { + Text("No recipes loaded") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.recipes) { recipe in + VStack(alignment: .leading, spacing: 4) { + Text(recipe.title) + Text(recipe.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + if !recipe.detail.isEmpty { + Text(recipe.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + Section("Sessions") { + HStack { + Button("List sessions") { + viewModel.loadSessions() + } + .disabled(viewModel.isBusy) + + Button("Create shell") { + viewModel.createShellSession() + } + .disabled(viewModel.isBusy) + } + + Toggle("Notify on session changes", isOn: $viewModel.notifyOnSessionChanges) + + TextField("Selected session id", text: $viewModel.selectedSessionID) + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + + if viewModel.sessions.isEmpty { + Text("No sessions loaded") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.sessions) { session in + Button { + viewModel.selectSession(session) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(session.title) + Text(session.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + if !session.detail.isEmpty { + Text(session.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } } - Section("Agents") { - ContentUnavailableView("No agents", systemImage: "server.rack") + Section("Transcript") { + HStack { + Button("Replay") { + viewModel.replaySelectedSession() + } + .disabled(viewModel.isBusy) + + Button("Attach / refresh output") { + viewModel.attachSelectedSession() + } + .disabled(viewModel.isBusy) + } + + Stepper( + "Zoom \(Int(viewModel.transcriptFontSize)) pt", + value: $viewModel.transcriptFontSize, + in: 11...30, + step: 1 + ) + + ScrollView([.horizontal, .vertical]) { + Text(viewModel.transcript.isEmpty ? "No transcript loaded" : viewModel.transcript) + .font(.system(size: viewModel.transcriptFontSize, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + .frame(minHeight: 240) + } + + Section("Mobile Write Controls") { + TextField("Short input, e.g. 1 or y", text: $viewModel.quickInput) + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + + HStack { + Button("Send + Return") { + viewModel.sendQuickInput() + } + .disabled(viewModel.isBusy) + + Button("1") { + viewModel.sendQuickInput("1\n") + } + .disabled(viewModel.isBusy) + + Button("2") { + viewModel.sendQuickInput("2\n") + } + .disabled(viewModel.isBusy) + + Button("Enter") { + viewModel.sendQuickInput("\n") + } + .disabled(viewModel.isBusy) + } + + Text("Each send attaches, writes bytes, waits briefly for output, then detaches without closing the remote session.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Status") { + if viewModel.isBusy { + ProgressView() + } + Text(viewModel.status) + .font(.callout) + + DisclosureGroup("Last Rust response") { + ScrollView([.horizontal, .vertical]) { + Text(viewModel.rawResponse.isEmpty ? "{}" : viewModel.rawResponse) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 120) + } } } .navigationTitle("DevOps Defender") } } } - -#Preview { - ContentView() -} - diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift new file mode 100644 index 0000000..11d8983 --- /dev/null +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -0,0 +1,493 @@ +import Foundation +import UserNotifications + +struct AgentSettings: Sendable { + var agentURL: String + var keyPath: String + var insecureSkipQuoteVerify: Bool + var itaAPIKey: String + var itaBaseURL: String + var itaJwksURL: String + var itaIssuer: String +} + +struct RecipeSummary: Identifiable, Sendable { + let id: String + let title: String + let detail: String +} + +struct SessionSummary: Identifiable, Sendable { + let id: String + let title: String + let detail: String +} + +struct DDClientError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } +} + +enum DDClientBridge { + static func importKey(keyPath: String, keyContent: String) throws -> [String: Any] { + try request([ + "operation": "import_key", + "key_path": keyPath, + "key_content": keyContent + ]) + } + + static func listRecipes(settings: AgentSettings) throws -> [String: Any] { + try request(basePayload("recipes", settings: settings)) + } + + static func listSessions(settings: AgentSettings) throws -> [String: Any] { + try request(basePayload("sessions", settings: settings)) + } + + static func createShellSession(settings: AgentSettings) throws -> [String: Any] { + var payload = basePayload("create_session", settings: settings) + payload["recipe"] = "shell" + payload["name"] = "iOS shell" + return try request(payload) + } + + static func replaySession(id: String, settings: AgentSettings) throws -> [String: Any] { + var payload = basePayload("replay_session", settings: settings) + payload["id"] = id + return try request(payload) + } + + static func attachExchange( + id: String, + input: String, + maxBytes: Int, + idleTimeoutMS: Int, + settings: AgentSettings + ) throws -> [String: Any] { + var payload = basePayload("attach_exchange", settings: settings) + payload["id"] = id + payload["input"] = input + payload["max_bytes"] = maxBytes + payload["idle_timeout_ms"] = idleTimeoutMS + return try request(payload) + } + + private static func basePayload(_ operation: String, settings: AgentSettings) -> [String: Any] { + [ + "operation": operation, + "agent_url": settings.agentURL, + "key_path": settings.keyPath, + "insecure_skip_quote_verify": settings.insecureSkipQuoteVerify, + "ita_api_key": settings.itaAPIKey, + "ita_base_url": settings.itaBaseURL, + "ita_jwks_url": settings.itaJwksURL, + "ita_issuer": settings.itaIssuer + ] + } + + private static func request(_ payload: [String: Any]) throws -> [String: Any] { + let requestData = try JSONSerialization.data(withJSONObject: payload) + guard let requestJSON = String(data: requestData, encoding: .utf8) else { + throw DDClientError(message: "Failed to encode FFI request") + } + + let responsePointer = requestJSON.withCString { requestCString in + dd_client_agent_request(requestCString) + } + + guard let responsePointer else { + throw DDClientError(message: "Rust FFI returned a null response") + } + defer { + dd_client_string_free(responsePointer) + } + + let responseJSON = String(cString: responsePointer) + guard let responseData = responseJSON.data(using: .utf8), + let response = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else { + throw DDClientError(message: "Failed to decode FFI response: \(responseJSON)") + } + + if response["ok"] as? Bool == true { + return response + } + throw DDClientError(message: response["error"] as? String ?? responseJSON) + } +} + +enum AppDefaults { + static let previewAgentURL = "https://dd-pr-261-api-23bf4739-7737-483f-9256-1d184cbb7fab.devopsdefender.com" + static let itaBaseURL = "https://api.trustauthority.intel.com" + static let itaJwksURL = "https://portal.trustauthority.intel.com/certs" + static let itaIssuer = "https://portal.trustauthority.intel.com" + + static var defaultKeyPath: String { + #if targetEnvironment(simulator) + return hostNoiseKeyPath + #else + if ProcessInfo.processInfo.isiOSAppOnMac { + return hostNoiseKeyPath + } + return appSupportNoiseKeyPath + #endif + } + + static var appSupportNoiseKeyPath: String { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first ?? URL(fileURLWithPath: NSHomeDirectory()) + return base + .appendingPathComponent("devopsdefender", isDirectory: true) + .appendingPathComponent("noise.key") + .path + } + + private static var hostNoiseKeyPath: String { + let userName = NSUserName() + if userName.isEmpty { + return appSupportNoiseKeyPath + } + return "/Users/\(userName)/.config/devopsdefender/noise.key" + } +} + +@MainActor +final class ClientViewModel: ObservableObject { + @Published var agentURL = AppDefaults.previewAgentURL + @Published var keyPath = AppDefaults.defaultKeyPath + @Published var keyContent = "" + @Published var insecureSkipQuoteVerify = true + @Published var itaAPIKey = "" + @Published var itaBaseURL = AppDefaults.itaBaseURL + @Published var itaJwksURL = AppDefaults.itaJwksURL + @Published var itaIssuer = AppDefaults.itaIssuer + @Published var recipes: [RecipeSummary] = [] + @Published var sessions: [SessionSummary] = [] + @Published var selectedSessionID = "" + @Published var quickInput = "" + @Published var transcript = "" + @Published var rawResponse = "" + @Published var status = "Ready" + @Published var isBusy = false + @Published var transcriptFontSize = 15.0 + @Published var notifyOnSessionChanges = false { + didSet { + if notifyOnSessionChanges { + requestNotificationPermission() + } + } + } + + private var lastSessionIDs = Set() + + var settings: AgentSettings { + AgentSettings( + agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), + keyPath: keyPath.expandingTildePath, + insecureSkipQuoteVerify: insecureSkipQuoteVerify, + itaAPIKey: itaAPIKey.trimmingCharacters(in: .whitespacesAndNewlines), + itaBaseURL: itaBaseURL.trimmingCharacters(in: .whitespacesAndNewlines), + itaJwksURL: itaJwksURL.trimmingCharacters(in: .whitespacesAndNewlines), + itaIssuer: itaIssuer.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func useAppSupportKeyPath() { + keyPath = AppDefaults.appSupportNoiseKeyPath + } + + func importPastedKey() { + let path = keyPath.expandingTildePath + let content = keyContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { + status = "Paste a 32-byte key as hex or base64 first" + return + } + run("Importing key") { + let response = try DDClientBridge.importKey(keyPath: path, keyContent: content) + return ClientUpdate( + status: "Imported key to \(response["key_path"] as? String ?? path)", + rawResponse: prettyJSONString(response) + ) + } + } + + func loadRecipes() { + let settings = settings + run("Loading recipes") { + let response = try DDClientBridge.listRecipes(settings: settings) + return ClientUpdate( + status: "Loaded recipes", + recipes: extractRecipes(from: response["value"]), + rawResponse: prettyJSONString(response) + ) + } + } + + func loadSessions() { + let settings = settings + run("Loading sessions") { + let response = try DDClientBridge.listSessions(settings: settings) + let sessions = extractSessions(from: response["value"]) + return ClientUpdate( + status: "Loaded \(sessions.count) sessions", + sessions: sessions, + rawResponse: prettyJSONString(response) + ) + } + } + + func createShellSession() { + let settings = settings + run("Creating shell session") { + let response = try DDClientBridge.createShellSession(settings: settings) + let id = response["session_id"] as? String ?? "" + return ClientUpdate( + status: id.isEmpty ? "Created shell session" : "Created shell session \(id)", + selectedSessionID: id, + rawResponse: prettyJSONString(response) + ) + } + } + + func replaySelectedSession() { + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { + status = "Select or enter a session id first" + return + } + let settings = settings + run("Replaying session") { + let response = try DDClientBridge.replaySession(id: id, settings: settings) + return ClientUpdate( + status: "Replayed \(id)", + transcript: transcriptText(from: response["value"]), + rawResponse: prettyJSONString(response) + ) + } + } + + func attachSelectedSession() { + attach(input: "", statusText: "Attaching for output") + } + + func sendQuickInput(_ input: String? = nil) { + let text = input ?? quickInput + guard !text.isEmpty else { + status = "Enter text or use a quick key" + return + } + let normalized = text.hasSuffix("\n") ? text : text + "\n" + attach(input: normalized, statusText: "Sending short input") + if input == nil { + quickInput = "" + } + } + + func selectSession(_ session: SessionSummary) { + selectedSessionID = session.id + transcript = session.detail + } + + private func attach(input: String, statusText: String) { + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { + status = "Select or enter a session id first" + return + } + let settings = settings + run(statusText) { + let response = try DDClientBridge.attachExchange( + id: id, + input: input, + maxBytes: 128 * 1024, + idleTimeoutMS: 1200, + settings: settings + ) + let text = response["text"] as? String ?? "" + return ClientUpdate( + status: input.isEmpty ? "Attached and detached without closing \(id)" : "Sent input and detached without closing \(id)", + transcript: text.isEmpty ? "(no new output before idle timeout)" : text, + rawResponse: prettyJSONString(response) + ) + } + } + + private func run(_ pendingStatus: String, work: @escaping @Sendable () throws -> ClientUpdate) { + guard !isBusy else { + status = "Another request is already running" + return + } + isBusy = true + status = pendingStatus + Task { + do { + let update = try await Task.detached(priority: .userInitiated) { + try work() + }.value + apply(update) + } catch { + status = error.localizedDescription + } + isBusy = false + } + } + + private func apply(_ update: ClientUpdate) { + status = update.status + rawResponse = update.rawResponse ?? rawResponse + if let recipes = update.recipes { + self.recipes = recipes + } + if let sessions = update.sessions { + handleSessionUpdate(sessions) + } + if let selectedSessionID = update.selectedSessionID, !selectedSessionID.isEmpty { + self.selectedSessionID = selectedSessionID + } + if let transcript = update.transcript { + self.transcript = transcript + } + } + + private func handleSessionUpdate(_ sessions: [SessionSummary]) { + let newIDs = Set(sessions.map(\.id)) + let added = newIDs.subtracting(lastSessionIDs) + self.sessions = sessions + if notifyOnSessionChanges, !lastSessionIDs.isEmpty, !added.isEmpty { + scheduleNotification(title: "DevOps Defender sessions changed", body: "New sessions: \(added.sorted().joined(separator: ", "))") + } + lastSessionIDs = newIDs + } + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + private func scheduleNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + let request = UNNotificationRequest( + identifier: "dd-client-session-\(UUID().uuidString)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } +} + +private struct ClientUpdate: Sendable { + var status: String + var recipes: [RecipeSummary]? + var sessions: [SessionSummary]? + var selectedSessionID: String? + var transcript: String? + var rawResponse: String? +} + +private extension String { + var expandingTildePath: String { + (self as NSString).expandingTildeInPath + } +} + +private func prettyJSONString(_ value: Any) -> String { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) else { + return "\(value)" + } + return text +} + +private func transcriptText(from value: Any?) -> String { + if let text = firstString(for: ["transcript", "output", "text", "stdout", "data"], in: value) { + return text + } + return prettyJSONString(value ?? [:]) +} + +private func firstString(for keys: [String], in value: Any?) -> String? { + if let dict = value as? [String: Any] { + for key in keys { + if let text = dict[key] as? String, !text.isEmpty { + return text + } + } + for nested in dict.values { + if let text = firstString(for: keys, in: nested) { + return text + } + } + } + if let array = value as? [Any] { + for item in array { + if let text = firstString(for: keys, in: item) { + return text + } + } + } + return nil +} + +private func extractRecipes(from value: Any?) -> [RecipeSummary] { + extractDictionaries(from: value).compactMap { dict in + guard let id = stringValue(dict["id"]) ?? stringValue(dict["recipe_id"]) ?? stringValue(dict["name"]) else { + return nil + } + let title = stringValue(dict["name"]) ?? id + let detail = [stringValue(dict["description"]), stringValue(dict["command"])] + .compactMap { $0 } + .joined(separator: " ") + return RecipeSummary(id: id, title: title, detail: detail) + } +} + +private func extractSessions(from value: Any?) -> [SessionSummary] { + extractDictionaries(from: value).compactMap { dict in + guard let id = stringValue(dict["id"]) ?? stringValue(dict["session_id"]) else { + return nil + } + let title = stringValue(dict["name"]) ?? stringValue(dict["recipe_id"]) ?? "Session" + let detail = [ + stringValue(dict["status"]), + stringValue(dict["state"]), + stringValue(dict["recipe"]), + stringValue(dict["recipe_id"]), + stringValue(dict["created_at"]), + stringValue(dict["updated_at"]) + ] + .compactMap { $0 } + .joined(separator: " · ") + return SessionSummary(id: id, title: title, detail: detail) + } +} + +private func extractDictionaries(from value: Any?) -> [[String: Any]] { + if let dict = value as? [String: Any] { + var result = [dict] + for nested in dict.values { + result.append(contentsOf: extractDictionaries(from: nested)) + } + return result + } + if let array = value as? [Any] { + return array.flatMap { extractDictionaries(from: $0) } + } + return [] +} + +private func stringValue(_ value: Any?) -> String? { + switch value { + case let value as String where !value.isEmpty: + return value + case let value as NSNumber: + return value.stringValue + default: + return nil + } +} diff --git a/apps/ios/DevOpsDefender/DDClientFFI.h b/apps/ios/DevOpsDefender/DDClientFFI.h new file mode 100644 index 0000000..b55ce86 --- /dev/null +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -0,0 +1,16 @@ +#ifndef DD_CLIENT_FFI_H +#define DD_CLIENT_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +char *dd_client_keygen(const char *key_path, const char *cp_url, const char *label); +char *dd_client_agent_request(const char *request_json); +void dd_client_string_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/apps/ios/README.md b/apps/ios/README.md index 8e12b9e..868c1e6 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,37 +1,49 @@ # iOS Client -The iOS client should be a native SwiftUI app backed by the Rust client core. +Native SwiftUI client backed by `dd-client-core` through `dd-client-ffi`. +Swift owns the UI and lifecycle; Rust owns direct Noise transport, quote +verification, recipes, sessions, replay, and attach/write/detach primitives. -Initial split: +The current vertical slice targets PR preview testing against: -- SwiftUI owns screens, navigation, notifications, Keychain access, and iOS - lifecycle. -- `dd-client-core` owns protocol behavior: pairing keys, quote verification, - direct agent Noise transport, session RPCs, and PTY bytes. -- `dd-client-ffi` exposes a C-compatible bridge that can be linked into an - Xcode target as a static library. +```bash +https://dd-pr-261-api-23bf4739-7737-483f-9256-1d184cbb7fab.devopsdefender.com +``` + +The app defaults to that URL and, on simulator or "Designed for iPad on Mac", +tries `~/.config/devopsdefender/noise.key`. On sandboxed installs, use the +"Use app support key path" button and paste/import the Noise key content. -First screen to build: +## App Workflow -1. Generate or load a device key from Keychain-backed storage. -2. Display the public key and CP enrollment URL. -3. Open the enrollment URL in an authenticated browser session. -4. After enrollment, list routed agents and connect directly to the selected - agent over Noise. +- Toggle "Dev/test: skip TDX quote verification" for PR previews. This maps to + the CLI `--insecure-skip-quote-verify` path. +- Tap "Load recipes" to call Rust for the recipe list. +- Tap "List sessions" to call Rust for current sessions. +- Tap "Create shell session" to create a session with recipe `shell`. +- Select a session, then use "Replay transcript" or "Attach / refresh output". +- Use the zoom stepper for larger transcript text when reading output on mobile. +- Use quick write controls for common agent prompts: `1`, `2`, `Enter`, or a + short custom line. Attach/write/detach does not close the remote session. +- Enable session notifications to get a local notification when a newly listed + session appears while the app is active. -The iOS app should not embed a browser shell or PWA. It should be a native -client using the same core as the CLI. +This app intentionally does not embed a browser shell or fallback web terminal. + +## Prerequisites + +```bash +brew install xcodegen +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +``` -macOS testing target: +On Intel simulator hosts, also install: -- The first app target should run on Apple Silicon Macs through iOS app - compatibility mode, shown by Xcode as "Designed for iPhone/iPad". -- Do not fork the first slice into a Catalyst UI. Keep one iOS app surface and - make macOS compatibility a target setting. -- The project should keep `SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES` and - target both iPhone and iPad device families. +```bash +rustup target add x86_64-apple-ios +``` -Generate/open the starter project with XcodeGen: +## Generate Project ```bash cd apps/ios @@ -39,17 +51,63 @@ xcodegen generate open DevOpsDefender.xcodeproj ``` -Run the iOS app on Apple Silicon macOS through iOS compatibility mode: +The Xcode project is generated from `project.yml`. Keep +`SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES` and +`SUPPORTS_MACCATALYST = NO`; this target is iOS compatibility mode, not +Catalyst. + +## Build For iOS Simulator + +Use an available simulator destination from `xcodebuild -showdestinations`. +Example compile command: + +```bash +cd apps/ios +xcodebuild \ + -project DevOpsDefender.xcodeproj \ + -scheme DevOpsDefender \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath /private/tmp/dd-client-xcode-derived \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + build +``` + +## Build For "Designed For iPad On Mac" + +Compile without signing: + +```bash +cd apps/ios +xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender -showdestinations +xcodebuild \ + -project DevOpsDefender.xcodeproj \ + -scheme DevOpsDefender \ + -configuration Debug \ + -destination 'platform=macOS,id=' \ + -derivedDataPath /private/tmp/dd-client-xcode-derived \ + CODE_SIGNING_ALLOWED=NO \ + build +``` + +Build/sign for local "My Mac (Designed for iPad)": ```bash cd apps/ios -chmod +x run-designed-for-ipad-on-mac.sh -./run-designed-for-ipad-on-mac.sh +DD_DEVELOPMENT_TEAM= ./run-designed-for-ipad-on-mac.sh ``` -If destination discovery fails, pass the `My Mac (Designed for iPad)` id from -`xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender -showdestinations`: +`xcodebuild` can build the local Mac compatibility destination, but +`devicectl` does not list that destination. After the script builds the signed +app, open `DevOpsDefender.xcodeproj`, select "My Mac (Designed for iPad)", and +press Run. + +For a physical iPhone or iPad, install and launch with CoreDevice: ```bash -DD_IOS_MAC_DEVICE_ID=00008122-000121C20AF1001C ./run-designed-for-ipad-on-mac.sh +cd apps/ios +xcrun devicectl list devices +DD_DEVELOPMENT_TEAM= DD_COREDEVICE_ID= ./run-designed-for-ipad-on-mac.sh ``` diff --git a/apps/ios/Scripts/build-rust.sh b/apps/ios/Scripts/build-rust.sh new file mode 100755 index 0000000..9c661ef --- /dev/null +++ b/apps/ios/Scripts/build-rust.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +if [ -n "${SRCROOT:-}" ]; then + REPO_ROOT=$(cd "$SRCROOT/../.." && pwd) +else + SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + REPO_ROOT=$(cd "$SCRIPT_DIR/../../.." && pwd) +fi + +PLATFORM="${PLATFORM_NAME:-iphonesimulator}" +CONFIGURATION="${CONFIGURATION:-Debug}" +ARCHS_TO_BUILD="${ARCHS:-${CURRENT_ARCH:-arm64}}" +if [ -n "${TARGET_TEMP_DIR:-}" ]; then + OUT_DIR="$TARGET_TEMP_DIR/rust" +else + OUT_DIR="$REPO_ROOT/target/ios-universal/$PLATFORM" +fi + +if [ -z "${CARGO_TARGET_DIR:-}" ]; then + if [ -n "${DERIVED_FILE_DIR:-}" ]; then + CARGO_TARGET_DIR="$DERIVED_FILE_DIR/rust-cargo-target" + else + CARGO_TARGET_DIR="$REPO_ROOT/target" + fi + export CARGO_TARGET_DIR +fi + +if [ "$CONFIGURATION" = "Release" ]; then + PROFILE_ARG="--release" + PROFILE_DIR="release" +else + PROFILE_ARG="" + PROFILE_DIR="debug" +fi + +mkdir -p "$OUT_DIR" + +# Xcode exports an iOS SDKROOT, but Cargo build scripts are host binaries. +# Use the macOS SDK for those host links while Rust still targets iOS below. +SDKROOT=$(xcrun --sdk macosx --show-sdk-path) +export SDKROOT + +libs="" +for arch in $ARCHS_TO_BUILD; do + case "$PLATFORM:$arch" in + iphoneos:arm64) + rust_target="aarch64-apple-ios" + ;; + iphonesimulator:arm64) + rust_target="aarch64-apple-ios-sim" + ;; + iphonesimulator:x86_64) + rust_target="x86_64-apple-ios" + ;; + *) + echo "Unsupported Rust target for PLATFORM_NAME=$PLATFORM arch=$arch" >&2 + exit 1 + ;; + esac + + if ! rustup target list --installed | grep -qx "$rust_target"; then + echo "Missing Rust target $rust_target. Install it with: rustup target add $rust_target" >&2 + exit 1 + fi + + cargo build -p dd-client-ffi --lib --target "$rust_target" $PROFILE_ARG + lib="$CARGO_TARGET_DIR/$rust_target/$PROFILE_DIR/libdd_client_ffi.a" + if [ ! -f "$lib" ]; then + echo "Rust library was not produced at $lib" >&2 + exit 1 + fi + libs="$libs $lib" +done + +set -- $libs +if [ "$#" -eq 1 ]; then + cp "$1" "$OUT_DIR/libdd_client_ffi.a" +else + lipo -create "$@" -output "$OUT_DIR/libdd_client_ffi.a" +fi diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 3afecab..85092a2 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -12,6 +12,10 @@ targets: platform: iOS sources: - DevOpsDefender + preBuildScripts: + - name: Build Rust FFI + script: Scripts/build-rust.sh + basedOnDependencyAnalysis: false settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.devopsdefender.client @@ -20,4 +24,13 @@ targets: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES SUPPORTS_MACCATALYST: NO INFOPLIST_FILE: DevOpsDefender/Info.plist - + ENABLE_USER_SCRIPT_SANDBOXING: NO + SWIFT_OBJC_BRIDGING_HEADER: DevOpsDefender/DDClientFFI.h + LIBRARY_SEARCH_PATHS: + - "$(inherited)" + - "$(TARGET_TEMP_DIR)/rust" + OTHER_LDFLAGS: + - "$(inherited)" + - "-ldd_client_ffi" + - "-framework" + - "Security" diff --git a/apps/ios/run-designed-for-ipad-on-mac.sh b/apps/ios/run-designed-for-ipad-on-mac.sh index e071681..843c10e 100755 --- a/apps/ios/run-designed-for-ipad-on-mac.sh +++ b/apps/ios/run-designed-for-ipad-on-mac.sh @@ -6,8 +6,10 @@ cd "$(dirname "$0")" PROJECT="DevOpsDefender.xcodeproj" SCHEME="DevOpsDefender" BUNDLE_ID="com.devopsdefender.client" -DEVICE_ID="${DD_IOS_MAC_DEVICE_ID:-}" +MAC_DESTINATION_ID="${DD_IOS_MAC_DEVICE_ID:-}" +COREDEVICE_ID="${DD_COREDEVICE_ID:-}" CONFIGURATION="${CONFIGURATION:-Debug}" +DEVELOPMENT_TEAM="${DD_DEVELOPMENT_TEAM:-}" if ! command -v xcodebuild >/dev/null 2>&1; then echo "error: xcodebuild not found. Install Xcode and select it with xcode-select." >&2 @@ -28,26 +30,47 @@ if [ ! -d "$PROJECT" ]; then xcodegen generate fi -if [ -z "$DEVICE_ID" ]; then - DEVICE_ID="$( +if [ -z "$MAC_DESTINATION_ID" ]; then + MAC_DESTINATION_ID="$( xcodebuild -project "$PROJECT" -scheme "$SCHEME" -showdestinations 2>/dev/null \ | sed -n 's/.*platform:macOS.*variant:Designed for \[iPad,iPhone\].*id:\([^,}]*\).*/\1/p' \ | head -n 1 )" fi -if [ -z "$DEVICE_ID" ]; then +if [ -z "$MAC_DESTINATION_ID" ]; then echo "error: could not find a 'My Mac (Designed for iPad)' destination." >&2 echo "run: xcodebuild -project $PROJECT -scheme $SCHEME -showdestinations" >&2 echo "then retry with: DD_IOS_MAC_DEVICE_ID= $0" >&2 exit 1 fi +if [ -z "$DEVELOPMENT_TEAM" ]; then + DEVELOPMENT_TEAM="$( + xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "platform=macOS,id=$MAC_DESTINATION_ID" \ + -showBuildSettings 2>/dev/null \ + | sed -n 's/^[[:space:]]*DEVELOPMENT_TEAM = //p' \ + | head -n 1 + )" +fi + +if [ -z "$DEVELOPMENT_TEAM" ]; then + echo "error: DEVELOPMENT_TEAM is required to install/run an iOS app on Mac." >&2 + echo "retry: DD_DEVELOPMENT_TEAM= $0" >&2 + echo "find team ids in Xcode Accounts or with: security find-identity -v -p codesigning" >&2 + exit 1 +fi + xcodebuild \ -project "$PROJECT" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ - -destination "platform=macOS,id=$DEVICE_ID" \ + -destination "platform=macOS,id=$MAC_DESTINATION_ID" \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ build DERIVED_DATA_DIR="$( @@ -66,5 +89,28 @@ if [ ! -d "$APP" ]; then exit 1 fi -xcrun devicectl device install app --device "$DEVICE_ID" "$APP" -xcrun devicectl device process launch --device "$DEVICE_ID" "$BUNDLE_ID" +if [ -n "$COREDEVICE_ID" ]; then + xcrun devicectl device install app --device "$COREDEVICE_ID" "$APP" + xcrun devicectl device process launch --device "$COREDEVICE_ID" "$BUNDLE_ID" + exit 0 +fi + +cat < $0 +EOF diff --git a/crates/dd-client-core/Cargo.toml b/crates/dd-client-core/Cargo.toml index d2a7a8d..9438fa3 100644 --- a/crates/dd-client-core/Cargo.toml +++ b/crates/dd-client-core/Cargo.toml @@ -18,8 +18,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"] } serde_json = "1" snow = { version = "0.9", default-features = false, features = ["default-resolver"] } -tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } urlencoding = "2" x25519-dalek = { version = "2", features = ["static_secrets"] } - diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index b23069d..aa905dc 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -11,6 +11,7 @@ use serde_json::Value; use snow::{Builder, TransportState}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::time::{timeout, Duration}; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use x25519_dalek::{PublicKey, StaticSecret}; @@ -234,6 +235,46 @@ pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Resu Ok(()) } +pub async fn attach_session_exchange( + mut conn: NoiseConnection, + id: &str, + input: &[u8], + max_bytes: usize, + idle_timeout: Duration, +) -> anyhow::Result> { + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": id, + "tail": true, + })) + .await?; + if ack.get("error").is_some() { + anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); + } + + if !input.is_empty() { + send_encrypted(&mut conn.transport, &mut conn.sink, input).await?; + } + + let mut output = Vec::new(); + while output.len() < max_bytes { + let frame = match timeout(idle_timeout, next_binary(&mut conn.stream)).await { + Ok(frame) => frame?, + Err(_) => break, + }; + let Some(cipher) = frame else { + break; + }; + let mut plain = vec![0u8; cipher.len()]; + let n = conn.transport.read_message(&cipher, &mut plain)?; + let remaining = max_bytes - output.len(); + output.extend_from_slice(&plain[..n.min(remaining)]); + } + + Ok(output) +} + #[derive(Debug, Eq, PartialEq)] enum AttachInputAction { Forward, diff --git a/crates/dd-client-ffi/Cargo.toml b/crates/dd-client-ffi/Cargo.toml index 4fc6283..8454b56 100644 --- a/crates/dd-client-ffi/Cargo.toml +++ b/crates/dd-client-ffi/Cargo.toml @@ -9,10 +9,11 @@ repository.workspace = true crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] +base64 = "0.22" dd-client-core = { path = "../dd-client-core" } +hex = "0.4" serde_json = "1" tokio = { version = "1", features = ["fs", "rt"] } [dev-dependencies] tempfile = "3" - diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 9f2748d..1b921b5 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -1,6 +1,20 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use base64::Engine as _; +use dd_client_core::{ + attach_session_exchange, connect, create_session, list_recipes, list_sessions, replay_session, + session_id, ConnectionOptions, CreateSessionRequest, IntelTrustAuthority, QuoteVerification, +}; + +const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; +const DEFAULT_ITA_JWKS_URL: &str = "https://portal.trustauthority.intel.com/certs"; +const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; +const DEFAULT_ATTACH_MAX_BYTES: usize = 128 * 1024; +const MAX_ATTACH_BYTES: usize = 1024 * 1024; +const DEFAULT_ATTACH_IDLE_TIMEOUT_MS: u64 = 1200; #[no_mangle] pub extern "C" fn dd_client_keygen( @@ -12,6 +26,12 @@ pub extern "C" fn dd_client_keygen( into_c_string(result) } +#[no_mangle] +pub extern "C" fn dd_client_agent_request(request_json: *const c_char) -> *mut c_char { + let result = agent_request_response(request_json); + into_c_string(result) +} + #[no_mangle] /// # Safety /// @@ -68,6 +88,161 @@ fn keygen( })) } +fn agent_request_response(request_json: *const c_char) -> serde_json::Value { + match agent_request(request_json) { + Ok(value) => value, + Err(error) => serde_json::json!({ + "ok": false, + "error": error, + }), + } +} + +fn agent_request(request_json: *const c_char) -> Result { + let request_json = required_c_string(request_json, "request_json")?; + let request: serde_json::Value = + serde_json::from_str(&request_json).map_err(|e| format!("parse request_json: {e}"))?; + let operation = required_json_string(&request, "operation")?; + + match operation.as_str() { + "import_key" => import_key_request(&request), + "recipes" | "list_recipes" => { + let opts = connection_options_from_request(&request)?; + let runtime = runtime()?; + let value = runtime + .block_on(async { + let mut conn = connect(&opts).await?; + list_recipes(&mut conn).await + }) + .map_err(|e| e.to_string())?; + Ok(ok_value("recipes", value)) + } + "sessions" | "list_sessions" => { + let opts = connection_options_from_request(&request)?; + let runtime = runtime()?; + let value = runtime + .block_on(async { + let mut conn = connect(&opts).await?; + list_sessions(&mut conn).await + }) + .map_err(|e| e.to_string())?; + Ok(ok_value("sessions", value)) + } + "create_session" => { + let opts = connection_options_from_request(&request)?; + let create_request = CreateSessionRequest { + recipe: optional_json_string(&request, "recipe")?, + name: optional_json_string(&request, "name")?, + command: optional_json_string(&request, "command")?, + }; + let runtime = runtime()?; + let value = runtime + .block_on(async { + let mut conn = connect(&opts).await?; + create_session(&mut conn, &create_request).await + }) + .map_err(|e| e.to_string())?; + let mut response = ok_map("create_session"); + if let Ok(id) = session_id(&value) { + response.insert("session_id".to_string(), serde_json::Value::String(id)); + } + response.insert("value".to_string(), value); + Ok(serde_json::Value::Object(response)) + } + "replay_session" => { + let opts = connection_options_from_request(&request)?; + let id = required_json_string(&request, "id")?; + let runtime = runtime()?; + let value = runtime + .block_on(async { + let mut conn = connect(&opts).await?; + replay_session(&mut conn, &id).await + }) + .map_err(|e| e.to_string())?; + Ok(ok_value("replay_session", value)) + } + "attach_exchange" | "attach_snapshot" => { + let opts = connection_options_from_request(&request)?; + let id = required_json_string(&request, "id")?; + let input = optional_json_string(&request, "input")?.unwrap_or_default(); + let max_bytes = usize_json_field(&request, "max_bytes")? + .unwrap_or(DEFAULT_ATTACH_MAX_BYTES) + .min(MAX_ATTACH_BYTES); + let idle_timeout_ms = u64_json_field(&request, "idle_timeout_ms")? + .unwrap_or(DEFAULT_ATTACH_IDLE_TIMEOUT_MS) + .clamp(100, 10_000); + let runtime = runtime()?; + let bytes = runtime + .block_on(async { + let conn = connect(&opts).await?; + attach_session_exchange( + conn, + &id, + input.as_bytes(), + max_bytes, + Duration::from_millis(idle_timeout_ms), + ) + .await + }) + .map_err(|e| e.to_string())?; + let mut response = ok_map("attach_exchange"); + response.insert( + "text".to_string(), + serde_json::Value::String(String::from_utf8_lossy(&bytes).into_owned()), + ); + response.insert( + "bytes_base64".to_string(), + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(&bytes)), + ); + response.insert( + "truncated".to_string(), + serde_json::Value::Bool(bytes.len() >= max_bytes), + ); + Ok(serde_json::Value::Object(response)) + } + _ => Err(format!("unsupported operation: {operation}")), + } +} + +fn import_key_request(request: &serde_json::Value) -> Result { + let key_path = required_json_string(request, "key_path")?; + let key_content = required_json_string(request, "key_content")?; + let bytes = parse_key_content(&key_content)?; + persist_key(Path::new(&key_path), &bytes)?; + Ok(serde_json::json!({ + "ok": true, + "operation": "import_key", + "key_path": key_path, + })) +} + +fn connection_options_from_request( + request: &serde_json::Value, +) -> Result { + let agent_url = required_json_string(request, "agent_url")?; + let key_path = required_json_string(request, "key_path")?; + let quote_verification = + if bool_json_field(request, "insecure_skip_quote_verify")?.unwrap_or(false) { + QuoteVerification::InsecureSkip + } else { + QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { + api_key: required_json_string(request, "ita_api_key")?, + base_url: optional_json_string(request, "ita_base_url")? + .unwrap_or_else(|| DEFAULT_ITA_BASE_URL.to_string()), + jwks_url: optional_json_string(request, "ita_jwks_url")? + .unwrap_or_else(|| DEFAULT_ITA_JWKS_URL.to_string()), + issuer: optional_json_string(request, "ita_issuer")? + .unwrap_or_else(|| DEFAULT_ITA_ISSUER.to_string()), + }) + }; + + Ok(ConnectionOptions { + agent_url, + key_path: PathBuf::from(key_path), + quote_verification, + }) +} + fn required_c_string(ptr: *const c_char, name: &str) -> Result { optional_c_string(ptr)?.ok_or_else(|| format!("{name} is required")) } @@ -83,6 +258,115 @@ fn optional_c_string(ptr: *const c_char) -> Result, String> { Ok(Some(s)) } +fn required_json_string(request: &serde_json::Value, name: &str) -> Result { + optional_json_string(request, name)?.ok_or_else(|| format!("{name} is required")) +} + +fn optional_json_string(request: &serde_json::Value, name: &str) -> Result, String> { + match request.get(name) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(value)) if value.is_empty() => Ok(None), + Some(serde_json::Value::String(value)) => Ok(Some(value.to_owned())), + Some(_) => Err(format!("{name} must be a string")), + } +} + +fn bool_json_field(request: &serde_json::Value, name: &str) -> Result, String> { + match request.get(name) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(format!("{name} must be a boolean")), + } +} + +fn u64_json_field(request: &serde_json::Value, name: &str) -> Result, String> { + match request.get(name) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Number(value)) => value + .as_u64() + .ok_or_else(|| format!("{name} must be an unsigned integer")) + .map(Some), + Some(_) => Err(format!("{name} must be an unsigned integer")), + } +} + +fn usize_json_field(request: &serde_json::Value, name: &str) -> Result, String> { + Ok(u64_json_field(request, name)?.map(|value| value as usize)) +} + +fn runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string()) +} + +fn ok_value(operation: &str, value: serde_json::Value) -> serde_json::Value { + let mut response = ok_map(operation); + response.insert("value".to_string(), value); + serde_json::Value::Object(response) +} + +fn ok_map(operation: &str) -> serde_json::Map { + serde_json::Map::from_iter([ + ("ok".to_string(), serde_json::Value::Bool(true)), + ( + "operation".to_string(), + serde_json::Value::String(operation.to_string()), + ), + ]) +} + +fn parse_key_content(content: &str) -> Result<[u8; 32], String> { + let compact: String = content.split_whitespace().collect(); + let hexish = compact.strip_prefix("0x").unwrap_or(&compact); + let bytes = if hexish.len() == 64 && hexish.bytes().all(|b| b.is_ascii_hexdigit()) { + hex::decode(hexish).map_err(|e| format!("decode key hex: {e}"))? + } else { + let mut decoded = None; + for engine in [ + &base64::engine::general_purpose::STANDARD, + &base64::engine::general_purpose::STANDARD_NO_PAD, + &base64::engine::general_purpose::URL_SAFE, + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + ] { + if let Ok(bytes) = engine.decode(&compact) { + decoded = Some(bytes); + break; + } + } + decoded.ok_or_else(|| { + "key_content must be 32 raw bytes encoded as hex or base64".to_string() + })? + }; + + if bytes.len() != 32 { + return Err(format!( + "key_content decoded to {} bytes, expected 32", + bytes.len() + )); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +fn persist_key(path: &Path, bytes: &[u8; 32]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + let tmp = path.with_extension("key.tmp"); + std::fs::write(&tmp, bytes).map_err(|e| format!("write {}: {e}", tmp.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("chmod {}: {e}", tmp.display()))?; + } + std::fs::rename(&tmp, path).map_err(|e| format!("rename {}: {e}", path.display()))?; + Ok(()) +} + fn into_c_string(value: serde_json::Value) -> *mut c_char { let text = serde_json::to_string(&value) .unwrap_or_else(|e| format!(r#"{{"ok":false,"error":"serialize response: {e}"}}"#)); @@ -124,4 +408,48 @@ mod tests { assert_eq!(value["ok"], false); assert!(value["error"].as_str().unwrap().contains("key_path")); } + + #[test] + fn agent_request_rejects_unknown_operation() { + let request = CString::new(r#"{"operation":"bogus"}"#).unwrap(); + + let value = agent_request_response(request.as_ptr()); + + assert_eq!(value["ok"], false); + assert!(value["error"] + .as_str() + .unwrap() + .contains("unsupported operation")); + } + + #[test] + fn import_key_accepts_hex_content() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("noise.key"); + let request = CString::new(format!( + r#"{{"operation":"import_key","key_path":"{}","key_content":"{}"}}"#, + key_path.display(), + "07".repeat(32) + )) + .unwrap(); + + let value = agent_request_response(request.as_ptr()); + + assert_eq!(value["ok"], true); + assert_eq!(std::fs::read(key_path).unwrap(), vec![7u8; 32]); + } + + #[test] + fn connection_options_requires_ita_key_when_verification_enabled() { + let request: serde_json::Value = serde_json::json!({ + "operation": "recipes", + "agent_url": "https://agent.example.com", + "key_path": "/tmp/noise.key", + "insecure_skip_quote_verify": false + }); + + let error = connection_options_from_request(&request).unwrap_err(); + + assert!(error.contains("ita_api_key")); + } } From 4ae8a460f919008a5b04e54d64330fb6d0b24da8 Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 12:35:35 -0400 Subject: [PATCH 02/18] Use simulator host home for iOS key default --- apps/ios/DevOpsDefender/DDClientBridge.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 11d8983..156398c 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -146,6 +146,16 @@ enum AppDefaults { } private static var hostNoiseKeyPath: String { + let environment = ProcessInfo.processInfo.environment + if let simulatorHostHome = environment["SIMULATOR_HOST_HOME"], + simulatorHostHome.hasPrefix("/Users/") { + return simulatorHostHome + "/.config/devopsdefender/noise.key" + } + if let hostHome = environment["HOME"], + hostHome.hasPrefix("/Users/"), + !hostHome.contains("/CoreSimulator/Devices/") { + return hostHome + "/.config/devopsdefender/noise.key" + } let userName = NSUserName() if userName.isEmpty { return appSupportNoiseKeyPath From 622401ed87ad9441cd2be3c11befbc6e2141d740 Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 12:54:23 -0400 Subject: [PATCH 03/18] Make iOS Rust build script work from Xcode --- apps/ios/Scripts/build-rust.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/ios/Scripts/build-rust.sh b/apps/ios/Scripts/build-rust.sh index 9c661ef..7065c8d 100755 --- a/apps/ios/Scripts/build-rust.sh +++ b/apps/ios/Scripts/build-rust.sh @@ -1,6 +1,27 @@ #!/bin/sh set -eu +# Xcode launched from the GUI does not inherit the user's shell PATH. Make the +# common Rust/Homebrew locations visible before invoking cargo/rustup. +if [ -n "${HOME:-}" ]; then + PATH="$HOME/.cargo/bin:$PATH" +fi +PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" +export PATH + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: $1 not found while building Rust FFI." >&2 + echo "hint: install Rust with rustup and ensure $HOME/.cargo/bin exists." >&2 + echo "hint: current PATH is: $PATH" >&2 + exit 1 + fi +} + +require_command cargo +require_command rustup +require_command xcrun + if [ -n "${SRCROOT:-}" ]; then REPO_ROOT=$(cd "$SRCROOT/../.." && pwd) else From 1fe3680bee5cf9809d8f487e92e1bb1318b0bc9e Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 13:05:53 -0400 Subject: [PATCH 04/18] Fix iOS local signing defaults --- .gitignore | 1 + apps/ios/Config/Signing.xcconfig | 3 +++ apps/ios/README.md | 12 +++++++++ apps/ios/project.yml | 9 ++++--- apps/ios/run-designed-for-ipad-on-mac.sh | 34 ++++++++++++++++++------ 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 apps/ios/Config/Signing.xcconfig diff --git a/.gitignore b/.gitignore index efec3cc..622d2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ *.key.tmp .DS_Store apps/ios/DevOpsDefender.xcodeproj/ +apps/ios/Config/Signing.local.xcconfig apps/ios/com.apple.DeveloperTools/ apps/ios/err diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig new file mode 100644 index 0000000..61fe954 --- /dev/null +++ b/apps/ios/Config/Signing.xcconfig @@ -0,0 +1,3 @@ +DD_PRODUCT_BUNDLE_IDENTIFIER = dev.devopsdefender.client.team$(DEVELOPMENT_TEAM) + +#include? "Signing.local.xcconfig" diff --git a/apps/ios/README.md b/apps/ios/README.md index 868c1e6..f076707 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -99,6 +99,18 @@ cd apps/ios DD_DEVELOPMENT_TEAM= ./run-designed-for-ipad-on-mac.sh ``` +The script defaults the bundle identifier to +`dev.devopsdefender.client.team` and passes +`-allowProvisioningUpdates` so Xcode can create a local development profile. +If Apple reports that a bundle identifier is unavailable, choose another unique +one: + +```bash +DD_DEVELOPMENT_TEAM= \ +DD_BUNDLE_ID=com..devopsdefender.client \ +./run-designed-for-ipad-on-mac.sh +``` + `xcodebuild` can build the local Mac compatibility destination, but `devicectl` does not list that destination. After the script builds the signed app, open `DevOpsDefender.xcodeproj`, select "My Mac (Designed for iPad)", and diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 85092a2..9280429 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -3,22 +3,23 @@ options: bundleIdPrefix: com.devopsdefender deploymentTarget: iOS: "17.0" -settings: - base: - DEVELOPMENT_TEAM: "" targets: DevOpsDefender: type: application platform: iOS sources: - DevOpsDefender + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig preBuildScripts: - name: Build Rust FFI script: Scripts/build-rust.sh basedOnDependencyAnalysis: false settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.devopsdefender.client + DD_PRODUCT_BUNDLE_IDENTIFIER: dev.devopsdefender.client.team$(DEVELOPMENT_TEAM) + PRODUCT_BUNDLE_IDENTIFIER: "$(DD_PRODUCT_BUNDLE_IDENTIFIER)" PRODUCT_NAME: DevOpsDefender TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES diff --git a/apps/ios/run-designed-for-ipad-on-mac.sh b/apps/ios/run-designed-for-ipad-on-mac.sh index 843c10e..292f86c 100755 --- a/apps/ios/run-designed-for-ipad-on-mac.sh +++ b/apps/ios/run-designed-for-ipad-on-mac.sh @@ -5,7 +5,7 @@ cd "$(dirname "$0")" PROJECT="DevOpsDefender.xcodeproj" SCHEME="DevOpsDefender" -BUNDLE_ID="com.devopsdefender.client" +BUNDLE_ID="${DD_BUNDLE_ID:-}" MAC_DESTINATION_ID="${DD_IOS_MAC_DEVICE_ID:-}" COREDEVICE_ID="${DD_COREDEVICE_ID:-}" CONFIGURATION="${CONFIGURATION:-Debug}" @@ -21,13 +21,12 @@ if ! command -v xcrun >/dev/null 2>&1; then exit 1 fi -if [ ! -d "$PROJECT" ]; then - if ! command -v xcodegen >/dev/null 2>&1; then - echo "error: $PROJECT is missing and xcodegen is not installed." >&2 - echo "install: brew install xcodegen" >&2 - exit 1 - fi +if command -v xcodegen >/dev/null 2>&1; then xcodegen generate +elif [ ! -d "$PROJECT" ]; then + echo "error: $PROJECT is missing and xcodegen is not installed." >&2 + echo "install: brew install xcodegen" >&2 + exit 1 fi if [ -z "$MAC_DESTINATION_ID" ]; then @@ -65,12 +64,24 @@ if [ -z "$DEVELOPMENT_TEAM" ]; then exit 1 fi +if [ -z "$BUNDLE_ID" ]; then + BUNDLE_ID="dev.devopsdefender.client.team$DEVELOPMENT_TEAM" +fi + +mkdir -p Config +cat > Config/Signing.local.xcconfig < $0 + DD_DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM DD_BUNDLE_ID=$BUNDLE_ID DD_COREDEVICE_ID= $0 EOF From 52297e85c117e9fdd19b4fa2f659a6a67fe602fb Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 13:09:46 -0400 Subject: [PATCH 05/18] Restyle iOS app as transcript workspace --- apps/ios/DevOpsDefender/ContentView.swift | 711 +++++++++++++++++----- 1 file changed, 574 insertions(+), 137 deletions(-) diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index 897dc4a..4a74ff6 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -2,201 +2,638 @@ import SwiftUI struct ContentView: View { @StateObject private var viewModel = ClientViewModel() + @State private var isConnectionExpanded = false + @State private var isDebugExpanded = false + @State private var showTranscriptReader = false + + private let actionColumns = [ + GridItem(.adaptive(minimum: 150), spacing: 10) + ] var body: some View { NavigationStack { - Form { - Section("Connection") { + ZStack { + Palette.page.ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + statusStrip + actionPanel + transcriptPanel + writePanel + sessionsPanel + recipesPanel + connectionPanel + debugPanel + } + .padding(.horizontal, 16) + .padding(.vertical, 18) + .frame(maxWidth: 980, alignment: .center) + .frame(maxWidth: .infinity) + } + } + .navigationTitle("DevOps Defender") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showTranscriptReader) { + TranscriptReaderView( + transcript: viewModel.transcript, + fontSize: $viewModel.transcriptFontSize + ) + } + } + } + + private var header: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Agent workspace") + .font(.title2.weight(.semibold)) + .foregroundStyle(Palette.text) + Text(agentHost) + .font(.callout) + .foregroundStyle(Palette.muted) + .lineLimit(1) + } + + Spacer() + + Text(viewModel.insecureSkipQuoteVerify ? "PR preview" : "Quote verified") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Palette.chip) + .clipShape(Capsule()) + .foregroundStyle(Palette.text) + } + + HStack(spacing: 10) { + Label(selectedSessionLabel, systemImage: "terminal") + .font(.caption) + .foregroundStyle(Palette.muted) + .lineLimit(1) + + Spacer() + + if viewModel.isBusy { + ProgressView() + .controlSize(.small) + } + } + } + } + } + + private var statusStrip: some View { + HStack(spacing: 10) { + Circle() + .fill(viewModel.isBusy ? Palette.busy : Palette.ready) + .frame(width: 8, height: 8) + + Text(viewModel.status) + .font(.callout) + .foregroundStyle(Palette.text) + .lineLimit(2) + + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Palette.status) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var actionPanel: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Runbook", subtitle: "Load, create, attach, detach") + + LazyVGrid(columns: actionColumns, spacing: 10) { + ActionButton("Load recipes", systemImage: "list.bullet.rectangle") { + viewModel.loadRecipes() + } + .disabled(viewModel.isBusy) + + ActionButton("List sessions", systemImage: "rectangle.stack") { + viewModel.loadSessions() + } + .disabled(viewModel.isBusy) + + ActionButton("Create shell", systemImage: "plus.app") { + viewModel.createShellSession() + } + .disabled(viewModel.isBusy) + + ActionButton("Attach refresh", systemImage: "arrow.clockwise") { + viewModel.attachSelectedSession() + } + .disabled(viewModel.isBusy) + } + } + } + } + + private var transcriptPanel: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + SectionHeader(title: "Transcript", subtitle: selectedSessionLabel) + + Spacer() + + Button { + showTranscriptReader = true + } label: { + Label("Reader", systemImage: "text.magnifyingglass") + .labelStyle(.titleAndIcon) + } + .buttonStyle(QuietButtonStyle()) + } + + HStack(spacing: 10) { + Button("Replay") { + viewModel.replaySelectedSession() + } + .buttonStyle(PlainPillButtonStyle()) + .disabled(viewModel.isBusy) + + Stepper( + "Text \(Int(viewModel.transcriptFontSize))", + value: $viewModel.transcriptFontSize, + in: 11...30, + step: 1 + ) + .font(.caption) + .foregroundStyle(Palette.muted) + } + + ScrollView([.horizontal, .vertical]) { + Text(transcriptText) + .font(.system(size: viewModel.transcriptFontSize, design: .monospaced)) + .foregroundStyle(Palette.terminalText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + .frame(minHeight: 340) + .background(Palette.terminal) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Palette.border, lineWidth: 1) + ) + } + } + } + + private var writePanel: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + SectionHeader( + title: "Mobile replies", + subtitle: "Optimized for 1, 2, enter, and short prompts" + ) + + TextField("Type a short reply", text: $viewModel.quickInput, axis: .vertical) + .textInputAutocapitalization(.never) + .font(.body.monospaced()) + .lineLimit(1...3) + .padding(12) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + HStack(spacing: 10) { + Button("Send") { + viewModel.sendQuickInput() + } + .buttonStyle(PrimaryPillButtonStyle()) + .disabled(viewModel.isBusy) + + Button("1") { + viewModel.sendQuickInput("1\n") + } + .buttonStyle(PlainPillButtonStyle()) + .disabled(viewModel.isBusy) + + Button("2") { + viewModel.sendQuickInput("2\n") + } + .buttonStyle(PlainPillButtonStyle()) + .disabled(viewModel.isBusy) + + Button("Enter") { + viewModel.sendQuickInput("\n") + } + .buttonStyle(PlainPillButtonStyle()) + .disabled(viewModel.isBusy) + } + + Text("Each send attaches, writes bytes, waits briefly, then detaches without closing the session.") + .font(.footnote) + .foregroundStyle(Palette.muted) + } + } + } + + private var sessionsPanel: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Sessions", subtitle: "\(viewModel.sessions.count) loaded") + + Toggle("Notify on session changes", isOn: $viewModel.notifyOnSessionChanges) + .font(.callout) + + TextField("Paste or edit selected session id", text: $viewModel.selectedSessionID) + .textInputAutocapitalization(.never) + .font(.caption.monospaced()) + .padding(12) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + if viewModel.sessions.isEmpty { + EmptyState(text: "No sessions loaded") + } else { + VStack(spacing: 8) { + ForEach(viewModel.sessions) { session in + SessionRow( + session: session, + isSelected: session.id == viewModel.selectedSessionID + ) { + viewModel.selectSession(session) + } + } + } + } + } + } + } + + private var recipesPanel: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Recipes", subtitle: "\(viewModel.recipes.count) loaded") + + if viewModel.recipes.isEmpty { + EmptyState(text: "Load recipes to confirm the PR preview is reachable") + } else { + VStack(spacing: 8) { + ForEach(viewModel.recipes) { recipe in + SummaryRow(title: recipe.title, id: recipe.id, detail: recipe.detail) + } + } + } + } + } + } + + private var connectionPanel: some View { + Card { + DisclosureGroup(isExpanded: $isConnectionExpanded) { + VStack(alignment: .leading, spacing: 12) { TextField("Agent URL", text: $viewModel.agentURL) .textInputAutocapitalization(.never) .keyboardType(.URL) + .textFieldStyle(WorkspaceTextFieldStyle()) TextField("Noise key path", text: $viewModel.keyPath) .textInputAutocapitalization(.never) - .font(.system(.body, design: .monospaced)) + .font(.body.monospaced()) + .textFieldStyle(WorkspaceTextFieldStyle()) Button("Use app support key path") { viewModel.useAppSupportKeyPath() } + .buttonStyle(PlainPillButtonStyle()) Toggle("Dev/test: skip TDX quote verification", isOn: $viewModel.insecureSkipQuoteVerify) if !viewModel.insecureSkipQuoteVerify { SecureField("Intel Trust Authority API key", text: $viewModel.itaAPIKey) + .textFieldStyle(WorkspaceTextFieldStyle()) TextField("ITA base URL", text: $viewModel.itaBaseURL) .textInputAutocapitalization(.never) + .textFieldStyle(WorkspaceTextFieldStyle()) TextField("ITA JWKS URL", text: $viewModel.itaJwksURL) .textInputAutocapitalization(.never) + .textFieldStyle(WorkspaceTextFieldStyle()) TextField("ITA issuer", text: $viewModel.itaIssuer) .textInputAutocapitalization(.never) + .textFieldStyle(WorkspaceTextFieldStyle()) } - } - Section("Key Content") { - Text("Paste a 32-byte Noise key as hex or base64 when the app cannot read your host key path.") + Text("Paste a 32-byte Noise key as hex or base64 if the app cannot read your host key path.") .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(Palette.muted) TextEditor(text: $viewModel.keyContent) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 76) + .font(.body.monospaced()) + .scrollContentBackground(.hidden) + .frame(minHeight: 88) + .padding(8) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - Button("Import pasted key to path") { + Button("Import pasted key") { viewModel.importPastedKey() } + .buttonStyle(PrimaryPillButtonStyle()) .disabled(viewModel.isBusy) } + .padding(.top, 12) + } label: { + SectionHeader(title: "Connection", subtitle: viewModel.keyPath) + } + } + } - Section("Recipes") { - Button("Load recipes") { - viewModel.loadRecipes() - } - .disabled(viewModel.isBusy) - - if viewModel.recipes.isEmpty { - Text("No recipes loaded") - .foregroundStyle(.secondary) - } else { - ForEach(viewModel.recipes) { recipe in - VStack(alignment: .leading, spacing: 4) { - Text(recipe.title) - Text(recipe.id) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - if !recipe.detail.isEmpty { - Text(recipe.detail) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } + private var debugPanel: some View { + Card { + DisclosureGroup(isExpanded: $isDebugExpanded) { + ScrollView([.horizontal, .vertical]) { + Text(viewModel.rawResponse.isEmpty ? "{}" : viewModel.rawResponse) + .font(.caption.monospaced()) + .foregroundStyle(Palette.terminalText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) } + .frame(minHeight: 140) + .background(Palette.terminal) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.top, 12) + } label: { + SectionHeader(title: "Debug", subtitle: "Last Rust response") + } + } + } - Section("Sessions") { - HStack { - Button("List sessions") { - viewModel.loadSessions() - } - .disabled(viewModel.isBusy) + private var agentHost: String { + URL(string: viewModel.agentURL)?.host ?? viewModel.agentURL + } - Button("Create shell") { - viewModel.createShellSession() - } - .disabled(viewModel.isBusy) - } + private var selectedSessionLabel: String { + let id = viewModel.selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty { + return "No session selected" + } + return "Session \(String(id.prefix(8)))" + } + + private var transcriptText: String { + viewModel.transcript.isEmpty ? "No transcript loaded" : viewModel.transcript + } +} - Toggle("Notify on session changes", isOn: $viewModel.notifyOnSessionChanges) +private enum Palette { + static let page = Color(red: 0.96, green: 0.94, blue: 0.90) + static let card = Color(red: 0.99, green: 0.98, blue: 0.94) + static let chip = Color(red: 0.91, green: 0.86, blue: 0.77) + static let input = Color(red: 0.94, green: 0.92, blue: 0.87) + static let status = Color(red: 0.90, green: 0.87, blue: 0.80) + static let border = Color.black.opacity(0.10) + static let text = Color(red: 0.14, green: 0.12, blue: 0.09) + static let muted = Color(red: 0.42, green: 0.37, blue: 0.30) + static let accent = Color(red: 0.54, green: 0.30, blue: 0.18) + static let accentText = Color(red: 0.99, green: 0.97, blue: 0.91) + static let ready = Color(red: 0.20, green: 0.48, blue: 0.31) + static let busy = Color(red: 0.70, green: 0.43, blue: 0.12) + static let terminal = Color(red: 0.13, green: 0.12, blue: 0.10) + static let terminalText = Color(red: 0.94, green: 0.91, blue: 0.82) +} - TextField("Selected session id", text: $viewModel.selectedSessionID) - .textInputAutocapitalization(.never) - .font(.system(.body, design: .monospaced)) +private struct Card: View { + @ViewBuilder var content: Content - if viewModel.sessions.isEmpty { - Text("No sessions loaded") - .foregroundStyle(.secondary) - } else { - ForEach(viewModel.sessions) { session in - Button { - viewModel.selectSession(session) - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(session.title) - Text(session.id) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - if !session.detail.isEmpty { - Text(session.detail) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } + var body: some View { + content + .padding(14) + .background(Palette.card) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Palette.border, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.04), radius: 12, x: 0, y: 6) + } +} - Section("Transcript") { - HStack { - Button("Replay") { - viewModel.replaySelectedSession() - } - .disabled(viewModel.isBusy) +private struct SectionHeader: View { + let title: String + let subtitle: String - Button("Attach / refresh output") { - viewModel.attachSelectedSession() - } - .disabled(viewModel.isBusy) - } + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline.weight(.semibold)) + .foregroundStyle(Palette.text) + Text(subtitle) + .font(.caption) + .foregroundStyle(Palette.muted) + .lineLimit(1) + } + } +} - Stepper( - "Zoom \(Int(viewModel.transcriptFontSize)) pt", - value: $viewModel.transcriptFontSize, - in: 11...30, - step: 1 - ) +private struct ActionButton: View { + let title: String + let systemImage: String + let action: () -> Void + + init(_ title: String, systemImage: String, action: @escaping () -> Void) { + self.title = title + self.systemImage = systemImage + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: systemImage) + Text(title) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.callout.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .foregroundStyle(Palette.text) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + } +} - ScrollView([.horizontal, .vertical]) { - Text(viewModel.transcript.isEmpty ? "No transcript loaded" : viewModel.transcript) - .font(.system(size: viewModel.transcriptFontSize, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) +private struct SessionRow: View { + let session: SessionSummary + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(isSelected ? Palette.accent : Palette.border) + .frame(width: 9, height: 9) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 4) { + Text(session.title) + .font(.callout.weight(.semibold)) + .foregroundStyle(Palette.text) + Text(session.id) + .font(.caption.monospaced()) + .foregroundStyle(Palette.muted) + .lineLimit(1) + if !session.detail.isEmpty { + Text(session.detail) + .font(.caption) + .foregroundStyle(Palette.muted) + .lineLimit(2) } - .frame(minHeight: 240) } - Section("Mobile Write Controls") { - TextField("Short input, e.g. 1 or y", text: $viewModel.quickInput) - .textInputAutocapitalization(.never) - .font(.system(.body, design: .monospaced)) + Spacer() + } + .padding(12) + .background(isSelected ? Palette.chip : Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + } +} - HStack { - Button("Send + Return") { - viewModel.sendQuickInput() - } - .disabled(viewModel.isBusy) +private struct SummaryRow: View { + let title: String + let id: String + let detail: String - Button("1") { - viewModel.sendQuickInput("1\n") - } - .disabled(viewModel.isBusy) + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.callout.weight(.semibold)) + .foregroundStyle(Palette.text) + Text(id) + .font(.caption.monospaced()) + .foregroundStyle(Palette.muted) + if !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(Palette.muted) + .lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} - Button("2") { - viewModel.sendQuickInput("2\n") - } - .disabled(viewModel.isBusy) +private struct EmptyState: View { + let text: String - Button("Enter") { - viewModel.sendQuickInput("\n") - } - .disabled(viewModel.isBusy) - } + var body: some View { + Text(text) + .font(.callout) + .foregroundStyle(Palette.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} - Text("Each send attaches, writes bytes, waits briefly for output, then detaches without closing the remote session.") - .font(.footnote) - .foregroundStyle(.secondary) - } +private struct TranscriptReaderView: View { + @Environment(\.dismiss) private var dismiss + let transcript: String + @Binding var fontSize: Double - Section("Status") { - if viewModel.isBusy { - ProgressView() - } - Text(viewModel.status) - .font(.callout) + var body: some View { + NavigationStack { + ZStack { + Palette.terminal.ignoresSafeArea() - DisclosureGroup("Last Rust response") { - ScrollView([.horizontal, .vertical]) { - Text(viewModel.rawResponse.isEmpty ? "{}" : viewModel.rawResponse) - .font(.caption.monospaced()) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(minHeight: 120) + ScrollView([.horizontal, .vertical]) { + Text(transcript.isEmpty ? "No transcript loaded" : transcript) + .font(.system(size: fontSize, design: .monospaced)) + .foregroundStyle(Palette.terminalText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + } + } + .navigationTitle("Reader") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() } } } - .navigationTitle("DevOps Defender") + .safeAreaInset(edge: .bottom) { + Stepper( + "Text \(Int(fontSize)) pt", + value: $fontSize, + in: 11...34, + step: 1 + ) + .font(.callout) + .foregroundStyle(Palette.terminalText) + .padding(14) + .background(Palette.terminal.opacity(0.92)) + } } } } + +private struct WorkspaceTextFieldStyle: TextFieldStyle { + func _body(configuration: TextField) -> some View { + configuration + .padding(12) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +private struct QuietButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .foregroundStyle(Palette.text) + .background(Palette.input) + .clipShape(Capsule()) + .opacity(configuration.isPressed ? 0.65 : 1) + } +} + +private struct PrimaryPillButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(Palette.accentText) + .background(Palette.accent) + .clipShape(Capsule()) + .opacity(configuration.isPressed ? 0.75 : 1) + } +} + +private struct PlainPillButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .foregroundStyle(Palette.text) + .background(Palette.input) + .clipShape(Capsule()) + .opacity(configuration.isPressed ? 0.70 : 1) + } +} From 8e36b6fdb8a761c931f0e35b901de75d664c47ff Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 13:13:40 -0400 Subject: [PATCH 06/18] Simplify iOS app setup flow --- apps/ios/DevOpsDefender/ContentView.swift | 111 ++++++++++++++++++- apps/ios/DevOpsDefender/DDClientBridge.swift | 31 ++++++ apps/ios/README.md | 19 +++- 3 files changed, 155 insertions(+), 6 deletions(-) diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index 4a74ff6..a95ad08 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct ContentView: View { @StateObject private var viewModel = ClientViewModel() @@ -18,6 +19,7 @@ struct ContentView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { header + setupPanel statusStrip actionPanel transcriptPanel @@ -44,6 +46,58 @@ struct ContentView: View { } } + private var setupPanel: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Quick setup", subtitle: "Use defaults, confirm the key, start reading") + + VStack(spacing: 8) { + SetupChecklistRow( + title: "Agent", + detail: agentHost, + state: .ready + ) + SetupChecklistRow( + title: "Noise key", + detail: keyFileExists ? "Found at \(expandedKeyPath)" : "Not found. Paste the key or switch to app storage.", + state: keyFileExists ? .ready : .needsAction + ) + SetupChecklistRow( + title: "Verification", + detail: viewModel.insecureSkipQuoteVerify ? "TDX quote checks skipped for PR preview testing" : "Intel Trust Authority required", + state: viewModel.insecureSkipQuoteVerify ? .preview : .ready + ) + } + + LazyVGrid(columns: actionColumns, spacing: 10) { + ActionButton("Load defaults", systemImage: "play.circle") { + viewModel.usePreviewDefaultsAndLoad() + } + .disabled(viewModel.isBusy) + + ActionButton("Paste key", systemImage: "doc.on.clipboard") { + guard let key = UIPasteboard.general.string else { + viewModel.status = "Clipboard did not contain text" + return + } + viewModel.keyContent = key + viewModel.importPastedKey() + } + .disabled(viewModel.isBusy) + + ActionButton("App key", systemImage: "folder") { + viewModel.useAppSupportKeyPath() + isConnectionExpanded = true + } + + ActionButton("Advanced", systemImage: "slider.horizontal.3") { + isConnectionExpanded.toggle() + } + } + } + } + } + private var header: some View { Card { VStack(alignment: .leading, spacing: 12) { @@ -343,7 +397,7 @@ struct ContentView: View { } .padding(.top, 12) } label: { - SectionHeader(title: "Connection", subtitle: viewModel.keyPath) + SectionHeader(title: "Advanced connection", subtitle: viewModel.keyPath) } } } @@ -384,6 +438,14 @@ struct ContentView: View { private var transcriptText: String { viewModel.transcript.isEmpty ? "No transcript loaded" : viewModel.transcript } + + private var expandedKeyPath: String { + (viewModel.keyPath as NSString).expandingTildeInPath + } + + private var keyFileExists: Bool { + FileManager.default.fileExists(atPath: expandedKeyPath) + } } private enum Palette { @@ -399,6 +461,7 @@ private enum Palette { static let accentText = Color(red: 0.99, green: 0.97, blue: 0.91) static let ready = Color(red: 0.20, green: 0.48, blue: 0.31) static let busy = Color(red: 0.70, green: 0.43, blue: 0.12) + static let warn = Color(red: 0.72, green: 0.49, blue: 0.16) static let terminal = Color(red: 0.13, green: 0.12, blue: 0.10) static let terminalText = Color(red: 0.94, green: 0.91, blue: 0.82) } @@ -466,6 +529,52 @@ private struct ActionButton: View { } } +private enum SetupState { + case ready + case preview + case needsAction + + var color: Color { + switch self { + case .ready: + Palette.ready + case .preview: + Palette.warn + case .needsAction: + Palette.busy + } + } +} + +private struct SetupChecklistRow: View { + let title: String + let detail: String + let state: SetupState + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Circle() + .fill(state.color) + .frame(width: 8, height: 8) + + Text(title) + .font(.callout.weight(.semibold)) + .foregroundStyle(Palette.text) + .frame(width: 92, alignment: .leading) + + Text(detail) + .font(.callout) + .foregroundStyle(Palette.muted) + .lineLimit(2) + + Spacer(minLength: 0) + } + .padding(12) + .background(Palette.input) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + private struct SessionRow: View { let session: SessionSummary let isSelected: Bool diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 156398c..24d25f1 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -205,6 +205,37 @@ final class ClientViewModel: ObservableObject { ) } + func usePreviewDefaults() { + agentURL = AppDefaults.previewAgentURL + keyPath = AppDefaults.defaultKeyPath + insecureSkipQuoteVerify = true + itaAPIKey = "" + itaBaseURL = AppDefaults.itaBaseURL + itaJwksURL = AppDefaults.itaJwksURL + itaIssuer = AppDefaults.itaIssuer + status = "Using PR preview defaults" + } + + func usePreviewDefaultsAndLoad() { + usePreviewDefaults() + let settings = settings + run("Checking setup") { + let recipesResponse = try DDClientBridge.listRecipes(settings: settings) + let sessionsResponse = try DDClientBridge.listSessions(settings: settings) + let recipes = extractRecipes(from: recipesResponse["value"]) + let sessions = extractSessions(from: sessionsResponse["value"]) + return ClientUpdate( + status: "Ready: \(recipes.count) recipes, \(sessions.count) sessions", + recipes: recipes, + sessions: sessions, + rawResponse: prettyJSONString([ + "recipes": recipesResponse, + "sessions": sessionsResponse + ]) + ) + } + } + func useAppSupportKeyPath() { keyPath = AppDefaults.appSupportNoiseKeyPath } diff --git a/apps/ios/README.md b/apps/ios/README.md index f076707..e889938 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -14,15 +14,24 @@ The app defaults to that URL and, on simulator or "Designed for iPad on Mac", tries `~/.config/devopsdefender/noise.key`. On sandboxed installs, use the "Use app support key path" button and paste/import the Noise key content. +To copy the local key as hex for paste/import: + +```bash +xxd -p -c 256 "$HOME/.config/devopsdefender/noise.key" | pbcopy +``` + ## App Workflow -- Toggle "Dev/test: skip TDX quote verification" for PR previews. This maps to - the CLI `--insecure-skip-quote-verify` path. -- Tap "Load recipes" to call Rust for the recipe list. -- Tap "List sessions" to call Rust for current sessions. +- The first card is "Quick setup". Tap "Use defaults & load" for PR preview + testing; it restores the PR #261 URL, uses the default Noise key path, skips + quote verification, and loads recipes plus sessions. +- If the key is not found, copy the 32-byte Noise key as hex/base64, then use + the app's paste/import control. "Use app key path" switches to app storage for + sandboxed installs. - Tap "Create shell session" to create a session with recipe `shell`. - Select a session, then use "Replay transcript" or "Attach / refresh output". -- Use the zoom stepper for larger transcript text when reading output on mobile. +- Use "Reader" and the zoom stepper for larger transcript text when reading + output on mobile. - Use quick write controls for common agent prompts: `1`, `2`, `Enter`, or a short custom line. Attach/write/detach does not close the remote session. - Enable session notifications to get a local notification when a newly listed From f67c1641618eb9c78b997bc9b26a295b7c7571c9 Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 15:07:16 -0400 Subject: [PATCH 07/18] Simplify iOS session viewer --- README.md | 16 + apps/ios/DevOpsDefender/ContentView.swift | 756 +----------------- apps/ios/DevOpsDefender/DDClientBridge.swift | 798 +++++++++++-------- apps/ios/DevOpsDefender/Info.plist | 12 +- apps/ios/README.md | 143 ++-- apps/ios/project.yml | 1 - crates/dd-client-cli/src/main.rs | 104 ++- crates/dd-client-core/src/lib.rs | 15 +- crates/dd-client-ffi/src/lib.rs | 134 ++-- 9 files changed, 754 insertions(+), 1225 deletions(-) diff --git a/README.md b/README.md index de1d927..309fa56 100644 --- a/README.md +++ b/README.md @@ -52,5 +52,21 @@ During an attached shell, `Ctrl-]` detaches and leaves the remote session alive. `Ctrl-D` sends EOF to the remote shell and disconnects the local client. Use `dd-client close --id SESSION_ID ...` to terminate a session explicitly. +Send a running session to the mobile companion app: + +```bash +dd-client mobile-link \ + --url https://agent.example.com \ + --key ~/.config/devopsdefender/noise.key \ + --id SESSION_ID \ + --include-key +``` + +Open the printed `devopsdefender://session?...` link on iOS, or render it as a +QR code with the printed `qrencode` command. `--include-key` puts the Noise +private key in the handoff URL so the mobile app can import it before replaying; +treat that link or QR code as secret. Omit `--include-key` to send only the +agent URL and session id after the app already has the key. + Quote verification is on by default. Local preview/dev runs without Intel Trust Authority credentials must pass `--insecure-skip-quote-verify` explicitly. diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index a95ad08..0e896aa 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,748 +1,66 @@ import SwiftUI -import UIKit struct ContentView: View { @StateObject private var viewModel = ClientViewModel() - @State private var isConnectionExpanded = false - @State private var isDebugExpanded = false - @State private var showTranscriptReader = false - - private let actionColumns = [ - GridItem(.adaptive(minimum: 150), spacing: 10) - ] var body: some View { - NavigationStack { - ZStack { - Palette.page.ignoresSafeArea() - - ScrollView { - VStack(alignment: .leading, spacing: 14) { - header - setupPanel - statusStrip - actionPanel - transcriptPanel - writePanel - sessionsPanel - recipesPanel - connectionPanel - debugPanel - } + ZStack(alignment: .topLeading) { + Palette.background.ignoresSafeArea() + + ScrollView(.vertical) { + Text(transcriptText) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Palette.text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.horizontal, 16) - .padding(.vertical, 18) - .frame(maxWidth: 980, alignment: .center) - .frame(maxWidth: .infinity) - } - } - .navigationTitle("DevOps Defender") - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showTranscriptReader) { - TranscriptReaderView( - transcript: viewModel.transcript, - fontSize: $viewModel.transcriptFontSize - ) - } - } - } - - private var setupPanel: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "Quick setup", subtitle: "Use defaults, confirm the key, start reading") - - VStack(spacing: 8) { - SetupChecklistRow( - title: "Agent", - detail: agentHost, - state: .ready - ) - SetupChecklistRow( - title: "Noise key", - detail: keyFileExists ? "Found at \(expandedKeyPath)" : "Not found. Paste the key or switch to app storage.", - state: keyFileExists ? .ready : .needsAction - ) - SetupChecklistRow( - title: "Verification", - detail: viewModel.insecureSkipQuoteVerify ? "TDX quote checks skipped for PR preview testing" : "Intel Trust Authority required", - state: viewModel.insecureSkipQuoteVerify ? .preview : .ready - ) - } - - LazyVGrid(columns: actionColumns, spacing: 10) { - ActionButton("Load defaults", systemImage: "play.circle") { - viewModel.usePreviewDefaultsAndLoad() - } - .disabled(viewModel.isBusy) - - ActionButton("Paste key", systemImage: "doc.on.clipboard") { - guard let key = UIPasteboard.general.string else { - viewModel.status = "Clipboard did not contain text" - return - } - viewModel.keyContent = key - viewModel.importPastedKey() - } - .disabled(viewModel.isBusy) - - ActionButton("App key", systemImage: "folder") { - viewModel.useAppSupportKeyPath() - isConnectionExpanded = true - } - - ActionButton("Advanced", systemImage: "slider.horizontal.3") { - isConnectionExpanded.toggle() - } - } - } - } - } - - private var header: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 4) { - Text("Agent workspace") - .font(.title2.weight(.semibold)) - .foregroundStyle(Palette.text) - Text(agentHost) - .font(.callout) - .foregroundStyle(Palette.muted) - .lineLimit(1) - } - - Spacer() - - Text(viewModel.insecureSkipQuoteVerify ? "PR preview" : "Quote verified") - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Palette.chip) - .clipShape(Capsule()) - .foregroundStyle(Palette.text) - } - - HStack(spacing: 10) { - Label(selectedSessionLabel, systemImage: "terminal") - .font(.caption) - .foregroundStyle(Palette.muted) - .lineLimit(1) - - Spacer() - - if viewModel.isBusy { - ProgressView() - .controlSize(.small) - } - } - } - } - } - - private var statusStrip: some View { - HStack(spacing: 10) { - Circle() - .fill(viewModel.isBusy ? Palette.busy : Palette.ready) - .frame(width: 8, height: 8) - - Text(viewModel.status) - .font(.callout) - .foregroundStyle(Palette.text) - .lineLimit(2) - - Spacer() - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Palette.status) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - - private var actionPanel: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "Runbook", subtitle: "Load, create, attach, detach") - - LazyVGrid(columns: actionColumns, spacing: 10) { - ActionButton("Load recipes", systemImage: "list.bullet.rectangle") { - viewModel.loadRecipes() - } - .disabled(viewModel.isBusy) - - ActionButton("List sessions", systemImage: "rectangle.stack") { - viewModel.loadSessions() - } - .disabled(viewModel.isBusy) - - ActionButton("Create shell", systemImage: "plus.app") { - viewModel.createShellSession() - } - .disabled(viewModel.isBusy) - - ActionButton("Attach refresh", systemImage: "arrow.clockwise") { - viewModel.attachSelectedSession() - } - .disabled(viewModel.isBusy) - } - } - } - } - - private var transcriptPanel: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline) { - SectionHeader(title: "Transcript", subtitle: selectedSessionLabel) - - Spacer() - - Button { - showTranscriptReader = true - } label: { - Label("Reader", systemImage: "text.magnifyingglass") - .labelStyle(.titleAndIcon) - } - .buttonStyle(QuietButtonStyle()) - } - - HStack(spacing: 10) { - Button("Replay") { - viewModel.replaySelectedSession() - } - .buttonStyle(PlainPillButtonStyle()) - .disabled(viewModel.isBusy) - - Stepper( - "Text \(Int(viewModel.transcriptFontSize))", - value: $viewModel.transcriptFontSize, - in: 11...30, - step: 1 - ) - .font(.caption) - .foregroundStyle(Palette.muted) - } - - ScrollView([.horizontal, .vertical]) { - Text(transcriptText) - .font(.system(size: viewModel.transcriptFontSize, design: .monospaced)) - .foregroundStyle(Palette.terminalText) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - } - .frame(minHeight: 340) - .background(Palette.terminal) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Palette.border, lineWidth: 1) - ) + .padding(.top, 46) + .padding(.bottom, 24) } - } - } - - private var writePanel: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - SectionHeader( - title: "Mobile replies", - subtitle: "Optimized for 1, 2, enter, and short prompts" - ) - - TextField("Type a short reply", text: $viewModel.quickInput, axis: .vertical) - .textInputAutocapitalization(.never) - .font(.body.monospaced()) - .lineLimit(1...3) - .padding(12) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - - HStack(spacing: 10) { - Button("Send") { - viewModel.sendQuickInput() - } - .buttonStyle(PrimaryPillButtonStyle()) - .disabled(viewModel.isBusy) - Button("1") { - viewModel.sendQuickInput("1\n") - } - .buttonStyle(PlainPillButtonStyle()) - .disabled(viewModel.isBusy) - - Button("2") { - viewModel.sendQuickInput("2\n") - } - .buttonStyle(PlainPillButtonStyle()) - .disabled(viewModel.isBusy) - - Button("Enter") { - viewModel.sendQuickInput("\n") - } - .buttonStyle(PlainPillButtonStyle()) - .disabled(viewModel.isBusy) - } + HStack(spacing: 8) { + Circle() + .fill(viewModel.isBusy ? Palette.busy : Palette.ready) + .frame(width: 7, height: 7) - Text("Each send attaches, writes bytes, waits briefly, then detaches without closing the session.") - .font(.footnote) + Text(statusText) + .font(.caption.monospaced()) .foregroundStyle(Palette.muted) + .lineLimit(1) + .truncationMode(.middle) } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Palette.background.opacity(0.94)) } - } - - private var sessionsPanel: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "Sessions", subtitle: "\(viewModel.sessions.count) loaded") - - Toggle("Notify on session changes", isOn: $viewModel.notifyOnSessionChanges) - .font(.callout) - - TextField("Paste or edit selected session id", text: $viewModel.selectedSessionID) - .textInputAutocapitalization(.never) - .font(.caption.monospaced()) - .padding(12) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - - if viewModel.sessions.isEmpty { - EmptyState(text: "No sessions loaded") - } else { - VStack(spacing: 8) { - ForEach(viewModel.sessions) { session in - SessionRow( - session: session, - isSelected: session.id == viewModel.selectedSessionID - ) { - viewModel.selectSession(session) - } - } - } - } - } + .onOpenURL { url in + viewModel.openMobileLink(url) } } - private var recipesPanel: some View { - Card { - VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "Recipes", subtitle: "\(viewModel.recipes.count) loaded") - - if viewModel.recipes.isEmpty { - EmptyState(text: "Load recipes to confirm the PR preview is reachable") - } else { - VStack(spacing: 8) { - ForEach(viewModel.recipes) { recipe in - SummaryRow(title: recipe.title, id: recipe.id, detail: recipe.detail) - } - } - } - } - } - } - - private var connectionPanel: some View { - Card { - DisclosureGroup(isExpanded: $isConnectionExpanded) { - VStack(alignment: .leading, spacing: 12) { - TextField("Agent URL", text: $viewModel.agentURL) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - .textFieldStyle(WorkspaceTextFieldStyle()) - - TextField("Noise key path", text: $viewModel.keyPath) - .textInputAutocapitalization(.never) - .font(.body.monospaced()) - .textFieldStyle(WorkspaceTextFieldStyle()) - - Button("Use app support key path") { - viewModel.useAppSupportKeyPath() - } - .buttonStyle(PlainPillButtonStyle()) - - Toggle("Dev/test: skip TDX quote verification", isOn: $viewModel.insecureSkipQuoteVerify) - - if !viewModel.insecureSkipQuoteVerify { - SecureField("Intel Trust Authority API key", text: $viewModel.itaAPIKey) - .textFieldStyle(WorkspaceTextFieldStyle()) - TextField("ITA base URL", text: $viewModel.itaBaseURL) - .textInputAutocapitalization(.never) - .textFieldStyle(WorkspaceTextFieldStyle()) - TextField("ITA JWKS URL", text: $viewModel.itaJwksURL) - .textInputAutocapitalization(.never) - .textFieldStyle(WorkspaceTextFieldStyle()) - TextField("ITA issuer", text: $viewModel.itaIssuer) - .textInputAutocapitalization(.never) - .textFieldStyle(WorkspaceTextFieldStyle()) - } - - Text("Paste a 32-byte Noise key as hex or base64 if the app cannot read your host key path.") - .font(.footnote) - .foregroundStyle(Palette.muted) - - TextEditor(text: $viewModel.keyContent) - .font(.body.monospaced()) - .scrollContentBackground(.hidden) - .frame(minHeight: 88) - .padding(8) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - - Button("Import pasted key") { - viewModel.importPastedKey() - } - .buttonStyle(PrimaryPillButtonStyle()) - .disabled(viewModel.isBusy) - } - .padding(.top, 12) - } label: { - SectionHeader(title: "Advanced connection", subtitle: viewModel.keyPath) - } + private var transcriptText: String { + if !viewModel.transcript.isEmpty { + return viewModel.transcript } - } - - private var debugPanel: some View { - Card { - DisclosureGroup(isExpanded: $isDebugExpanded) { - ScrollView([.horizontal, .vertical]) { - Text(viewModel.rawResponse.isEmpty ? "{}" : viewModel.rawResponse) - .font(.caption.monospaced()) - .foregroundStyle(Palette.terminalText) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - } - .frame(minHeight: 140) - .background(Palette.terminal) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .padding(.top, 12) - } label: { - SectionHeader(title: "Debug", subtitle: "Last Rust response") - } + if viewModel.hasLinkedSession { + return viewModel.status } + return "Open a DevOps Defender session link." } - private var agentHost: String { - URL(string: viewModel.agentURL)?.host ?? viewModel.agentURL - } - - private var selectedSessionLabel: String { - let id = viewModel.selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) - if id.isEmpty { - return "No session selected" + private var statusText: String { + if viewModel.hasLinkedSession { + return "\(viewModel.linkedSessionTitle) \(viewModel.status)" } - return "Session \(String(id.prefix(8)))" - } - - private var transcriptText: String { - viewModel.transcript.isEmpty ? "No transcript loaded" : viewModel.transcript - } - - private var expandedKeyPath: String { - (viewModel.keyPath as NSString).expandingTildeInPath - } - - private var keyFileExists: Bool { - FileManager.default.fileExists(atPath: expandedKeyPath) + return viewModel.status } } private enum Palette { - static let page = Color(red: 0.96, green: 0.94, blue: 0.90) - static let card = Color(red: 0.99, green: 0.98, blue: 0.94) - static let chip = Color(red: 0.91, green: 0.86, blue: 0.77) - static let input = Color(red: 0.94, green: 0.92, blue: 0.87) - static let status = Color(red: 0.90, green: 0.87, blue: 0.80) - static let border = Color.black.opacity(0.10) - static let text = Color(red: 0.14, green: 0.12, blue: 0.09) + static let background = Color(red: 0.96, green: 0.94, blue: 0.90) + static let text = Color(red: 0.16, green: 0.14, blue: 0.11) static let muted = Color(red: 0.42, green: 0.37, blue: 0.30) - static let accent = Color(red: 0.54, green: 0.30, blue: 0.18) - static let accentText = Color(red: 0.99, green: 0.97, blue: 0.91) static let ready = Color(red: 0.20, green: 0.48, blue: 0.31) static let busy = Color(red: 0.70, green: 0.43, blue: 0.12) - static let warn = Color(red: 0.72, green: 0.49, blue: 0.16) - static let terminal = Color(red: 0.13, green: 0.12, blue: 0.10) - static let terminalText = Color(red: 0.94, green: 0.91, blue: 0.82) -} - -private struct Card: View { - @ViewBuilder var content: Content - - var body: some View { - content - .padding(14) - .background(Palette.card) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Palette.border, lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.04), radius: 12, x: 0, y: 6) - } -} - -private struct SectionHeader: View { - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.headline.weight(.semibold)) - .foregroundStyle(Palette.text) - Text(subtitle) - .font(.caption) - .foregroundStyle(Palette.muted) - .lineLimit(1) - } - } -} - -private struct ActionButton: View { - let title: String - let systemImage: String - let action: () -> Void - - init(_ title: String, systemImage: String, action: @escaping () -> Void) { - self.title = title - self.systemImage = systemImage - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: systemImage) - Text(title) - .lineLimit(1) - Spacer(minLength: 0) - } - .font(.callout.weight(.semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 12) - .foregroundStyle(Palette.text) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .buttonStyle(.plain) - } -} - -private enum SetupState { - case ready - case preview - case needsAction - - var color: Color { - switch self { - case .ready: - Palette.ready - case .preview: - Palette.warn - case .needsAction: - Palette.busy - } - } -} - -private struct SetupChecklistRow: View { - let title: String - let detail: String - let state: SetupState - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Circle() - .fill(state.color) - .frame(width: 8, height: 8) - - Text(title) - .font(.callout.weight(.semibold)) - .foregroundStyle(Palette.text) - .frame(width: 92, alignment: .leading) - - Text(detail) - .font(.callout) - .foregroundStyle(Palette.muted) - .lineLimit(2) - - Spacer(minLength: 0) - } - .padding(12) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct SessionRow: View { - let session: SessionSummary - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(isSelected ? Palette.accent : Palette.border) - .frame(width: 9, height: 9) - .padding(.top, 5) - - VStack(alignment: .leading, spacing: 4) { - Text(session.title) - .font(.callout.weight(.semibold)) - .foregroundStyle(Palette.text) - Text(session.id) - .font(.caption.monospaced()) - .foregroundStyle(Palette.muted) - .lineLimit(1) - if !session.detail.isEmpty { - Text(session.detail) - .font(.caption) - .foregroundStyle(Palette.muted) - .lineLimit(2) - } - } - - Spacer() - } - .padding(12) - .background(isSelected ? Palette.chip : Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .buttonStyle(.plain) - } -} - -private struct SummaryRow: View { - let title: String - let id: String - let detail: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.callout.weight(.semibold)) - .foregroundStyle(Palette.text) - Text(id) - .font(.caption.monospaced()) - .foregroundStyle(Palette.muted) - if !detail.isEmpty { - Text(detail) - .font(.caption) - .foregroundStyle(Palette.muted) - .lineLimit(3) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct EmptyState: View { - let text: String - - var body: some View { - Text(text) - .font(.callout) - .foregroundStyle(Palette.muted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct TranscriptReaderView: View { - @Environment(\.dismiss) private var dismiss - let transcript: String - @Binding var fontSize: Double - - var body: some View { - NavigationStack { - ZStack { - Palette.terminal.ignoresSafeArea() - - ScrollView([.horizontal, .vertical]) { - Text(transcript.isEmpty ? "No transcript loaded" : transcript) - .font(.system(size: fontSize, design: .monospaced)) - .foregroundStyle(Palette.terminalText) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - } - } - .navigationTitle("Reader") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - dismiss() - } - } - } - .safeAreaInset(edge: .bottom) { - Stepper( - "Text \(Int(fontSize)) pt", - value: $fontSize, - in: 11...34, - step: 1 - ) - .font(.callout) - .foregroundStyle(Palette.terminalText) - .padding(14) - .background(Palette.terminal.opacity(0.92)) - } - } - } -} - -private struct WorkspaceTextFieldStyle: TextFieldStyle { - func _body(configuration: TextField) -> some View { - configuration - .padding(12) - .background(Palette.input) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } -} - -private struct QuietButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.caption.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .foregroundStyle(Palette.text) - .background(Palette.input) - .clipShape(Capsule()) - .opacity(configuration.isPressed ? 0.65 : 1) - } -} - -private struct PrimaryPillButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.callout.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(Palette.accentText) - .background(Palette.accent) - .clipShape(Capsule()) - .opacity(configuration.isPressed ? 0.75 : 1) - } -} - -private struct PlainPillButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.callout.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(Palette.text) - .background(Palette.input) - .clipShape(Capsule()) - .opacity(configuration.isPressed ? 0.70 : 1) - } } diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 24d25f1..570fce8 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -1,26 +1,8 @@ import Foundation -import UserNotifications struct AgentSettings: Sendable { var agentURL: String var keyPath: String - var insecureSkipQuoteVerify: Bool - var itaAPIKey: String - var itaBaseURL: String - var itaJwksURL: String - var itaIssuer: String -} - -struct RecipeSummary: Identifiable, Sendable { - let id: String - let title: String - let detail: String -} - -struct SessionSummary: Identifiable, Sendable { - let id: String - let title: String - let detail: String } struct DDClientError: LocalizedError { @@ -32,61 +14,44 @@ struct DDClientError: LocalizedError { } enum DDClientBridge { - static func importKey(keyPath: String, keyContent: String) throws -> [String: Any] { - try request([ + static func importKey(keyPath: String, keyContent: String) throws { + _ = try request([ "operation": "import_key", "key_path": keyPath, "key_content": keyContent ]) } - static func listRecipes(settings: AgentSettings) throws -> [String: Any] { - try request(basePayload("recipes", settings: settings)) - } - - static func listSessions(settings: AgentSettings) throws -> [String: Any] { - try request(basePayload("sessions", settings: settings)) - } - - static func createShellSession(settings: AgentSettings) throws -> [String: Any] { - var payload = basePayload("create_session", settings: settings) - payload["recipe"] = "shell" - payload["name"] = "iOS shell" - return try request(payload) - } - - static func replaySession(id: String, settings: AgentSettings) throws -> [String: Any] { - var payload = basePayload("replay_session", settings: settings) - payload["id"] = id - return try request(payload) - } - - static func attachExchange( - id: String, - input: String, - maxBytes: Int, - idleTimeoutMS: Int, - settings: AgentSettings - ) throws -> [String: Any] { - var payload = basePayload("attach_exchange", settings: settings) - payload["id"] = id - payload["input"] = input - payload["max_bytes"] = maxBytes - payload["idle_timeout_ms"] = idleTimeoutMS - return try request(payload) + static func transcriptSnapshot(id: String, settings: AgentSettings) throws -> [String: Any] { + try request([ + "operation": "attach_exchange", + "agent_url": settings.agentURL, + "key_path": settings.keyPath, + "insecure_skip_quote_verify": true, + "ita_api_key": "", + "ita_base_url": "", + "ita_jwks_url": "", + "ita_issuer": "", + "id": id, + "input": "", + "max_bytes": 131072, + "idle_timeout_ms": 250 + ]) } - private static func basePayload(_ operation: String, settings: AgentSettings) -> [String: Any] { - [ - "operation": operation, + static func transcriptHistory(id: String, settings: AgentSettings) throws -> [String: Any] { + try request([ + "operation": "replay_session", "agent_url": settings.agentURL, "key_path": settings.keyPath, - "insecure_skip_quote_verify": settings.insecureSkipQuoteVerify, - "ita_api_key": settings.itaAPIKey, - "ita_base_url": settings.itaBaseURL, - "ita_jwks_url": settings.itaJwksURL, - "ita_issuer": settings.itaIssuer - ] + "insecure_skip_quote_verify": true, + "ita_api_key": "", + "ita_base_url": "", + "ita_jwks_url": "", + "ita_issuer": "", + "id": id, + "max_bytes": 49152 + ]) } private static func request(_ payload: [String: Any]) throws -> [String: Any] { @@ -98,7 +63,6 @@ enum DDClientBridge { let responsePointer = requestJSON.withCString { requestCString in dd_client_agent_request(requestCString) } - guard let responsePointer else { throw DDClientError(message: "Rust FFI returned a null response") } @@ -120,22 +84,6 @@ enum DDClientBridge { } enum AppDefaults { - static let previewAgentURL = "https://dd-pr-261-api-23bf4739-7737-483f-9256-1d184cbb7fab.devopsdefender.com" - static let itaBaseURL = "https://api.trustauthority.intel.com" - static let itaJwksURL = "https://portal.trustauthority.intel.com/certs" - static let itaIssuer = "https://portal.trustauthority.intel.com" - - static var defaultKeyPath: String { - #if targetEnvironment(simulator) - return hostNoiseKeyPath - #else - if ProcessInfo.processInfo.isiOSAppOnMac { - return hostNoiseKeyPath - } - return appSupportNoiseKeyPath - #endif - } - static var appSupportNoiseKeyPath: String { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) .first ?? URL(fileURLWithPath: NSHomeDirectory()) @@ -144,222 +92,105 @@ enum AppDefaults { .appendingPathComponent("noise.key") .path } - - private static var hostNoiseKeyPath: String { - let environment = ProcessInfo.processInfo.environment - if let simulatorHostHome = environment["SIMULATOR_HOST_HOME"], - simulatorHostHome.hasPrefix("/Users/") { - return simulatorHostHome + "/.config/devopsdefender/noise.key" - } - if let hostHome = environment["HOME"], - hostHome.hasPrefix("/Users/"), - !hostHome.contains("/CoreSimulator/Devices/") { - return hostHome + "/.config/devopsdefender/noise.key" - } - let userName = NSUserName() - if userName.isEmpty { - return appSupportNoiseKeyPath - } - return "/Users/\(userName)/.config/devopsdefender/noise.key" - } } @MainActor final class ClientViewModel: ObservableObject { - @Published var agentURL = AppDefaults.previewAgentURL - @Published var keyPath = AppDefaults.defaultKeyPath - @Published var keyContent = "" - @Published var insecureSkipQuoteVerify = true - @Published var itaAPIKey = "" - @Published var itaBaseURL = AppDefaults.itaBaseURL - @Published var itaJwksURL = AppDefaults.itaJwksURL - @Published var itaIssuer = AppDefaults.itaIssuer - @Published var recipes: [RecipeSummary] = [] - @Published var sessions: [SessionSummary] = [] @Published var selectedSessionID = "" - @Published var quickInput = "" @Published var transcript = "" - @Published var rawResponse = "" - @Published var status = "Ready" + @Published var status = "Open a mobile link from desktop" @Published var isBusy = false - @Published var transcriptFontSize = 15.0 - @Published var notifyOnSessionChanges = false { - didSet { - if notifyOnSessionChanges { - requestNotificationPermission() - } - } - } - - private var lastSessionIDs = Set() - - var settings: AgentSettings { - AgentSettings( - agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), - keyPath: keyPath.expandingTildePath, - insecureSkipQuoteVerify: insecureSkipQuoteVerify, - itaAPIKey: itaAPIKey.trimmingCharacters(in: .whitespacesAndNewlines), - itaBaseURL: itaBaseURL.trimmingCharacters(in: .whitespacesAndNewlines), - itaJwksURL: itaJwksURL.trimmingCharacters(in: .whitespacesAndNewlines), - itaIssuer: itaIssuer.trimmingCharacters(in: .whitespacesAndNewlines) - ) - } - func usePreviewDefaults() { - agentURL = AppDefaults.previewAgentURL - keyPath = AppDefaults.defaultKeyPath - insecureSkipQuoteVerify = true - itaAPIKey = "" - itaBaseURL = AppDefaults.itaBaseURL - itaJwksURL = AppDefaults.itaJwksURL - itaIssuer = AppDefaults.itaIssuer - status = "Using PR preview defaults" - } - - func usePreviewDefaultsAndLoad() { - usePreviewDefaults() - let settings = settings - run("Checking setup") { - let recipesResponse = try DDClientBridge.listRecipes(settings: settings) - let sessionsResponse = try DDClientBridge.listSessions(settings: settings) - let recipes = extractRecipes(from: recipesResponse["value"]) - let sessions = extractSessions(from: sessionsResponse["value"]) - return ClientUpdate( - status: "Ready: \(recipes.count) recipes, \(sessions.count) sessions", - recipes: recipes, - sessions: sessions, - rawResponse: prettyJSONString([ - "recipes": recipesResponse, - "sessions": sessionsResponse - ]) - ) - } - } - - func useAppSupportKeyPath() { - keyPath = AppDefaults.appSupportNoiseKeyPath - } + private var agentURL = "" + private var keyPath = AppDefaults.appSupportNoiseKeyPath + private var refreshTask: Task? + private var terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) - func importPastedKey() { - let path = keyPath.expandingTildePath - let content = keyContent.trimmingCharacters(in: .whitespacesAndNewlines) - guard !content.isEmpty else { - status = "Paste a 32-byte key as hex or base64 first" - return - } - run("Importing key") { - let response = try DDClientBridge.importKey(keyPath: path, keyContent: content) - return ClientUpdate( - status: "Imported key to \(response["key_path"] as? String ?? path)", - rawResponse: prettyJSONString(response) - ) - } + var hasLinkedSession: Bool { + !selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - func loadRecipes() { - let settings = settings - run("Loading recipes") { - let response = try DDClientBridge.listRecipes(settings: settings) - return ClientUpdate( - status: "Loaded recipes", - recipes: extractRecipes(from: response["value"]), - rawResponse: prettyJSONString(response) - ) + var linkedSessionTitle: String { + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty { + return "No linked session" } + return "Session \(String(id.prefix(8)))" } - func loadSessions() { - let settings = settings - run("Loading sessions") { - let response = try DDClientBridge.listSessions(settings: settings) - let sessions = extractSessions(from: response["value"]) - return ClientUpdate( - status: "Loaded \(sessions.count) sessions", - sessions: sessions, - rawResponse: prettyJSONString(response) - ) + func openMobileLink(_ url: URL) { + guard url.scheme == "devopsdefender", + url.host == "session", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + status = "Unsupported mobile link" + return } - } - func createShellSession() { - let settings = settings - run("Creating shell session") { - let response = try DDClientBridge.createShellSession(settings: settings) - let id = response["session_id"] as? String ?? "" - return ClientUpdate( - status: id.isEmpty ? "Created shell session" : "Created shell session \(id)", - selectedSessionID: id, - rawResponse: prettyJSONString(response) - ) + var query: [String: String] = [:] + for item in components.queryItems ?? [] { + query[item.name] = item.value } - } - func replaySelectedSession() { - let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty else { - status = "Select or enter a session id first" + guard let agent = query["agent"], !agent.isEmpty, + let id = query["id"], !id.isEmpty else { + status = "Mobile link missing agent or session" return } - let settings = settings - run("Replaying session") { - let response = try DDClientBridge.replaySession(id: id, settings: settings) - return ClientUpdate( - status: "Replayed \(id)", - transcript: transcriptText(from: response["value"]), - rawResponse: prettyJSONString(response) - ) - } - } - func attachSelectedSession() { - attach(input: "", statusText: "Attaching for output") - } + refreshTask?.cancel() + agentURL = agent + selectedSessionID = id + keyPath = AppDefaults.appSupportNoiseKeyPath + transcript = "" + terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) - func sendQuickInput(_ input: String? = nil) { - let text = input ?? quickInput - guard !text.isEmpty else { - status = "Enter text or use a quick key" + if let key = query["key"], !key.isEmpty { + importKeyAndLoadTranscript(key) return } - let normalized = text.hasSuffix("\n") ? text : text + "\n" - attach(input: normalized, statusText: "Sending short input") - if input == nil { - quickInput = "" + + if FileManager.default.fileExists(atPath: keyPath.expandingTildePath) { + loadTranscript() + } else { + status = "Linked \(linkedSessionTitle); link did not include key" } } - func selectSession(_ session: SessionSummary) { - selectedSessionID = session.id - transcript = session.detail + private func importKeyAndLoadTranscript(_ key: String) { + let path = keyPath.expandingTildePath + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + let settings = AgentSettings( + agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), + keyPath: path + ) + run("Importing key", keepRefreshing: true) { + try DDClientBridge.importKey(keyPath: path, keyContent: key) + return initialTranscriptUpdate(id: id, settings: settings) + } } - private func attach(input: String, statusText: String) { + private func loadTranscript() { let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) guard !id.isEmpty else { - status = "Select or enter a session id first" + status = "No linked session" return } - let settings = settings - run(statusText) { - let response = try DDClientBridge.attachExchange( - id: id, - input: input, - maxBytes: 128 * 1024, - idleTimeoutMS: 1200, - settings: settings - ) - let text = response["text"] as? String ?? "" - return ClientUpdate( - status: input.isEmpty ? "Attached and detached without closing \(id)" : "Sent input and detached without closing \(id)", - transcript: text.isEmpty ? "(no new output before idle timeout)" : text, - rawResponse: prettyJSONString(response) - ) - } - } - - private func run(_ pendingStatus: String, work: @escaping @Sendable () throws -> ClientUpdate) { + let settings = AgentSettings( + agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), + keyPath: keyPath.expandingTildePath + ) + run("Loading transcript", keepRefreshing: true) { + initialTranscriptUpdate(id: id, settings: settings) + } + } + + private func run( + _ pendingStatus: String, + keepRefreshing: Bool = false, + work: @escaping @Sendable () throws -> ClientUpdate + ) { guard !isBusy else { - status = "Another request is already running" + status = "Already loading" return } isBusy = true @@ -369,7 +200,11 @@ final class ClientViewModel: ObservableObject { let update = try await Task.detached(priority: .userInitiated) { try work() }.value + status = update.status apply(update) + if keepRefreshing { + startRefreshing() + } } catch { status = error.localizedDescription } @@ -377,57 +212,53 @@ final class ClientViewModel: ObservableObject { } } - private func apply(_ update: ClientUpdate) { - status = update.status - rawResponse = update.rawResponse ?? rawResponse - if let recipes = update.recipes { - self.recipes = recipes - } - if let sessions = update.sessions { - handleSessionUpdate(sessions) - } - if let selectedSessionID = update.selectedSessionID, !selectedSessionID.isEmpty { - self.selectedSessionID = selectedSessionID - } - if let transcript = update.transcript { - self.transcript = transcript + private func startRefreshing() { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 650_000_000) + self?.refreshTranscript() + } } } - private func handleSessionUpdate(_ sessions: [SessionSummary]) { - let newIDs = Set(sessions.map(\.id)) - let added = newIDs.subtracting(lastSessionIDs) - self.sessions = sessions - if notifyOnSessionChanges, !lastSessionIDs.isEmpty, !added.isEmpty { - scheduleNotification(title: "DevOps Defender sessions changed", body: "New sessions: \(added.sorted().joined(separator: ", "))") + private func refreshTranscript() { + guard hasLinkedSession, !isBusy else { + return + } + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + let settings = AgentSettings( + agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), + keyPath: keyPath.expandingTildePath + ) + isBusy = true + Task { + do { + let update = try await Task.detached(priority: .utility) { + try transcriptUpdate(id: id, settings: settings) + }.value + status = update.status + apply(update) + } catch { + status = error.localizedDescription + } + isBusy = false } - lastSessionIDs = newIDs - } - - private func requestNotificationPermission() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } } - private func scheduleNotification(title: String, body: String) { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - let request = UNNotificationRequest( - identifier: "dd-client-session-\(UUID().uuidString)", - content: content, - trigger: nil - ) - UNUserNotificationCenter.current().add(request) + private func apply(_ update: ClientUpdate) { + guard !update.terminalText.isEmpty else { + return + } + terminalRenderer.feed(update.terminalText.unicodeScalars) + let rendered = terminalRenderer.renderedText() + transcript = rendered.isEmpty ? "(no transcript output before idle timeout)" : rendered } } private struct ClientUpdate: Sendable { var status: String - var recipes: [RecipeSummary]? - var sessions: [SessionSummary]? - var selectedSessionID: String? - var transcript: String? - var rawResponse: String? + var terminalText: String } private extension String { @@ -436,13 +267,23 @@ private extension String { } } -private func prettyJSONString(_ value: Any) -> String { - guard JSONSerialization.isValidJSONObject(value), - let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), - let text = String(data: data, encoding: .utf8) else { - return "\(value)" +private func transcriptUpdate(id: String, settings: AgentSettings) throws -> ClientUpdate { + let response = try DDClientBridge.transcriptSnapshot(id: id, settings: settings) + return ClientUpdate( + status: "Loaded \(id)", + terminalText: transcriptText(from: response) + ) +} + +private func initialTranscriptUpdate(id: String, settings: AgentSettings) -> ClientUpdate { + if let response = try? DDClientBridge.transcriptHistory(id: id, settings: settings) { + let history = historyText(from: response) + if !history.isEmpty { + return ClientUpdate(status: "Loaded \(id)", terminalText: history) + } } - return text + return (try? transcriptUpdate(id: id, settings: settings)) + ?? ClientUpdate(status: "Loaded \(id)", terminalText: "") } private func transcriptText(from value: Any?) -> String { @@ -452,6 +293,24 @@ private func transcriptText(from value: Any?) -> String { return prettyJSONString(value ?? [:]) } +private func historyText(from value: Any?) -> String { + guard let encoded = firstString(for: ["bytes_b64"], in: value), + let data = Data(base64Encoded: encoded), + let text = String(data: data, encoding: .utf8) else { + return transcriptText(from: value) + } + return text +} + +private func prettyJSONString(_ value: Any) -> String { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) else { + return "\(value)" + } + return text +} + private func firstString(for keys: [String], in value: Any?) -> String? { if let dict = value as? [String: Any] { for key in keys { @@ -475,60 +334,297 @@ private func firstString(for keys: [String], in value: Any?) -> String? { return nil } -private func extractRecipes(from value: Any?) -> [RecipeSummary] { - extractDictionaries(from: value).compactMap { dict in - guard let id = stringValue(dict["id"]) ?? stringValue(dict["recipe_id"]) ?? stringValue(dict["name"]) else { - return nil +private final class TerminalScreenRenderer { + private let width: Int + private let maxRows: Int + private var rows: [[UnicodeScalar]] + private var row = 0 + private var column = 0 + + init(width: Int, maxRows: Int) { + self.width = width + self.maxRows = maxRows + self.rows = [Self.blankRow(width: width)] + } + + func feed(_ scalars: String.UnicodeScalarView) { + feed(Array(scalars)) + } + + func renderedText() -> String { + rows + .map { trimTrailingSpaces(String(String.UnicodeScalarView($0))) } + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func feed(_ scalars: [UnicodeScalar]) { + var index = 0 + + while index < scalars.count { + let scalar = scalars[index] + let value = scalar.value + + if value == 0x1B { + index = handleEscapeSequence(in: scalars, from: index) + continue + } + + if value == 0x9B { + index = handleCSISequence(in: scalars, from: index + 1) + continue + } + + if value == 0x9D { + index = skipOSCSequence(in: scalars, from: index + 1) + continue + } + + if let next = skipOrphanCSIFragmentIfPresent(in: scalars, from: index) { + index = next + continue + } + + if value == 0x08 { + column = max(0, column - 1) + index += 1 + continue + } + + if value == 0x0D { + column = 0 + index += 1 + continue } - let title = stringValue(dict["name"]) ?? id - let detail = [stringValue(dict["description"]), stringValue(dict["command"])] - .compactMap { $0 } - .joined(separator: " ") - return RecipeSummary(id: id, title: title, detail: detail) + + if value == 0x0A { + row += 1 + column = 0 + clampCursor() + index += 1 + continue + } + + if value == 0x09 { + let spaces = max(1, 4 - (column % 4)) + for _ in 0..= 0x20 { + put(scalar) + } + index += 1 } -} + } + + private func put(_ scalar: UnicodeScalar) { + clampCursor() + rows[row][column] = scalar + column += 1 + if column >= width { + column = 0 + row += 1 + clampCursor() + } + } -private func extractSessions(from value: Any?) -> [SessionSummary] { - extractDictionaries(from: value).compactMap { dict in - guard let id = stringValue(dict["id"]) ?? stringValue(dict["session_id"]) else { + private func clampCursor() { + row = max(0, row) + column = max(0, min(column, width - 1)) + while rows.count <= row { + rows.append(Self.blankRow(width: width)) + } + while rows.count > maxRows { + rows.removeFirst() + row = max(0, row - 1) + } + } + + private func handleEscapeSequence(in scalars: [UnicodeScalar], from start: Int) -> Int { + let index = start + 1 + guard index < scalars.count else { + return index + } + + let introducer = scalars[index].value + if introducer == 0x5B { + return handleCSISequence(in: scalars, from: index + 1) + } + if introducer == 0x5D { + return skipOSCSequence(in: scalars, from: index + 1) + } + + return min(index + 1, scalars.count) + } + + private func skipOSCSequence(in scalars: [UnicodeScalar], from start: Int) -> Int { + var index = start + while index < scalars.count { + let value = scalars[index].value + if value == 0x07 { + return index + 1 + } + if value == 0x1B, index + 1 < scalars.count, scalars[index + 1].value == 0x5C { + return index + 2 + } + index += 1 + } + return index + } + + private func skipOrphanCSIFragmentIfPresent(in scalars: [UnicodeScalar], from start: Int) -> Int? { + guard start < scalars.count else { return nil } - let title = stringValue(dict["name"]) ?? stringValue(dict["recipe_id"]) ?? "Session" - let detail = [ - stringValue(dict["status"]), - stringValue(dict["state"]), - stringValue(dict["recipe"]), - stringValue(dict["recipe_id"]), - stringValue(dict["created_at"]), - stringValue(dict["updated_at"]) - ] - .compactMap { $0 } - .joined(separator: " · ") - return SessionSummary(id: id, title: title, detail: detail) + let first = scalars[start].value + guard first == 0x3B || first == 0x3F || first == 0x5B else { + return nil + } + + var index = start + if first == 0x5B { + index += 1 + } + + let scanLimit = min(index + 20, scalars.count) + while index < scanLimit { + let value = scalars[index].value + if value >= 0x30, value <= 0x39 + || value == 0x3B + || value == 0x3F + || value == 0x3D + || value == 0x3E + || value == 0x3C { + index += 1 + continue + } + + if isCSIControlFinal(value) { + return index + 1 + } + return nil + } + return nil } -} -private func extractDictionaries(from value: Any?) -> [[String: Any]] { - if let dict = value as? [String: Any] { - var result = [dict] - for nested in dict.values { - result.append(contentsOf: extractDictionaries(from: nested)) + private func isCSIControlFinal(_ value: UInt32) -> Bool { + switch value { + case 0x41, 0x42, 0x43, 0x44, 0x47, 0x48, 0x4A, 0x4B, 0x66, 0x68, 0x6C, 0x6D: + return true + default: + return false } - return result } - if let array = value as? [Any] { - return array.flatMap { extractDictionaries(from: $0) } + + private func handleCSISequence(in scalars: [UnicodeScalar], from start: Int) -> Int { + var index = start + var raw = "" + while index < scalars.count { + let value = scalars[index].value + if value >= 0x40, value <= 0x7E { + applyCSI(raw, final: Character(UnicodeScalar(value)!)) + index += 1 + return index + } + raw.unicodeScalars.append(scalars[index]) + index += 1 + } + return index + } + + private func applyCSI(_ raw: String, final: Character) { + let params = parseCSIParams(raw) + let amount = max(1, params.first ?? 1) + + switch final { + case "A": + row -= amount + case "B": + row += amount + case "C": + column += amount + case "D": + column -= amount + case "G": + column = max(0, amount - 1) + case "H", "f": + row = max(0, (params.first ?? 1) - 1) + column = max(0, (params.dropFirst().first ?? 1) - 1) + case "J": + eraseDisplay(mode: params.first ?? 0) + case "K": + eraseLine(mode: params.first ?? 0) + default: + break + } + clampCursor() + } + + private func eraseDisplay(mode: Int) { + clampCursor() + switch mode { + case 2, 3: + rows = [Self.blankRow(width: width)] + row = 0 + column = 0 + case 1: + for y in 0...row { + let end = y == row ? column : width - 1 + guard end >= 0 else { continue } + for x in 0...end { + rows[y][x] = " " + } + } + default: + for y in row.. [Int] { + let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "?=><")) + if cleaned.isEmpty { + return [] + } + return cleaned.split(separator: ";", omittingEmptySubsequences: false).map { + Int($0) ?? 0 + } + } + + private static func blankRow(width: Int) -> [UnicodeScalar] { + Array(repeating: " ", count: width) } - return [] } -private func stringValue(_ value: Any?) -> String? { - switch value { - case let value as String where !value.isEmpty: - return value - case let value as NSNumber: - return value.stringValue - default: - return nil +private func trimTrailingSpaces(_ line: String) -> String { + var line = line + while line.last == " " || line.last == "\t" { + line.removeLast() } + return line } diff --git a/apps/ios/DevOpsDefender/Info.plist b/apps/ios/DevOpsDefender/Info.plist index 397d9c9..7312964 100644 --- a/apps/ios/DevOpsDefender/Info.plist +++ b/apps/ios/DevOpsDefender/Info.plist @@ -17,6 +17,17 @@ $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 0.1 + CFBundleURLTypes + + + CFBundleURLName + DevOps Defender Session + CFBundleURLSchemes + + devopsdefender + + + CFBundleVersion 1 LSRequiresIPhoneOS @@ -28,4 +39,3 @@ - diff --git a/apps/ios/README.md b/apps/ios/README.md index e889938..4aa6760 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,20 +1,55 @@ # iOS Client -Native SwiftUI client backed by `dd-client-core` through `dd-client-ffi`. -Swift owns the UI and lifecycle; Rust owns direct Noise transport, quote -verification, recipes, sessions, replay, and attach/write/detach primitives. +Native SwiftUI companion for `dd-client` CLI sessions. -The current vertical slice targets PR preview testing against: +The desktop CLI owns agent selection, session creation, enrollment, and full +terminal attach. The iOS app only opens a desktop-generated session link and +shows the replayed transcript. + +## Desktop Flow + +Start or reattach a CLI session first: + +```bash +cd ~/src/dd-client + +cargo run -p dd-client -- shell \ + --url "$AGENT_URL" \ + --key "$HOME/.config/devopsdefender/noise.key" \ + --insecure-skip-quote-verify \ + --recipe codex-podman \ + --name "dogfood codex" +``` + +Detach without closing the session with `Ctrl-]`, then list sessions if needed: + +```bash +cargo run -p dd-client -- sessions \ + --url "$AGENT_URL" \ + --key "$HOME/.config/devopsdefender/noise.key" \ + --insecure-skip-quote-verify +``` + +Generate the iOS link: ```bash -https://dd-pr-261-api-23bf4739-7737-483f-9256-1d184cbb7fab.devopsdefender.com +cargo run -p dd-client -- mobile-link \ + --url "$AGENT_URL" \ + --key "$HOME/.config/devopsdefender/noise.key" \ + --id "$SESSION_ID" \ + --include-key ``` -The app defaults to that URL and, on simulator or "Designed for iPad on Mac", -tries `~/.config/devopsdefender/noise.key`. On sandboxed installs, use the -"Use app support key path" button and paste/import the Noise key content. +Open the printed `devopsdefender://session?...` link on iOS, or render the QR +with the printed `qrencode` command. With `--include-key`, the link contains the +Noise private key and the app imports it before replaying; treat the link or QR +as secret. -To copy the local key as hex for paste/import: +## Key Import Fallback + +Prefer `--include-key` so the link is self-contained. If you omit it, the app +can only replay after a key has already been imported into its Application +Support directory. ```bash xxd -p -c 256 "$HOME/.config/devopsdefender/noise.key" | pbcopy @@ -22,22 +57,12 @@ xxd -p -c 256 "$HOME/.config/devopsdefender/noise.key" | pbcopy ## App Workflow -- The first card is "Quick setup". Tap "Use defaults & load" for PR preview - testing; it restores the PR #261 URL, uses the default Noise key path, skips - quote verification, and loads recipes plus sessions. -- If the key is not found, copy the 32-byte Noise key as hex/base64, then use - the app's paste/import control. "Use app key path" switches to app storage for - sandboxed installs. -- Tap "Create shell session" to create a session with recipe `shell`. -- Select a session, then use "Replay transcript" or "Attach / refresh output". -- Use "Reader" and the zoom stepper for larger transcript text when reading - output on mobile. -- Use quick write controls for common agent prompts: `1`, `2`, `Enter`, or a - short custom line. Attach/write/detach does not close the remote session. -- Enable session notifications to get a local notification when a newly listed - session appears while the app is active. - -This app intentionally does not embed a browser shell or fallback web terminal. +- Open a `devopsdefender://session?...` link or scan its QR code. +- The app imports the embedded key when present. +- The app replays the linked session transcript. + +The app intentionally does not create sessions, list recipes, browse agents, +attach for live I/O, or send input. ## Prerequisites @@ -64,71 +89,3 @@ The Xcode project is generated from `project.yml`. Keep `SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES` and `SUPPORTS_MACCATALYST = NO`; this target is iOS compatibility mode, not Catalyst. - -## Build For iOS Simulator - -Use an available simulator destination from `xcodebuild -showdestinations`. -Example compile command: - -```bash -cd apps/ios -xcodebuild \ - -project DevOpsDefender.xcodeproj \ - -scheme DevOpsDefender \ - -configuration Debug \ - -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath /private/tmp/dd-client-xcode-derived \ - ARCHS=arm64 \ - ONLY_ACTIVE_ARCH=YES \ - CODE_SIGNING_ALLOWED=NO \ - build -``` - -## Build For "Designed For iPad On Mac" - -Compile without signing: - -```bash -cd apps/ios -xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender -showdestinations -xcodebuild \ - -project DevOpsDefender.xcodeproj \ - -scheme DevOpsDefender \ - -configuration Debug \ - -destination 'platform=macOS,id=' \ - -derivedDataPath /private/tmp/dd-client-xcode-derived \ - CODE_SIGNING_ALLOWED=NO \ - build -``` - -Build/sign for local "My Mac (Designed for iPad)": - -```bash -cd apps/ios -DD_DEVELOPMENT_TEAM= ./run-designed-for-ipad-on-mac.sh -``` - -The script defaults the bundle identifier to -`dev.devopsdefender.client.team` and passes -`-allowProvisioningUpdates` so Xcode can create a local development profile. -If Apple reports that a bundle identifier is unavailable, choose another unique -one: - -```bash -DD_DEVELOPMENT_TEAM= \ -DD_BUNDLE_ID=com..devopsdefender.client \ -./run-designed-for-ipad-on-mac.sh -``` - -`xcodebuild` can build the local Mac compatibility destination, but -`devicectl` does not list that destination. After the script builds the signed -app, open `DevOpsDefender.xcodeproj`, select "My Mac (Designed for iPad)", and -press Run. - -For a physical iPhone or iPad, install and launch with CoreDevice: - -```bash -cd apps/ios -xcrun devicectl list devices -DD_DEVELOPMENT_TEAM= DD_COREDEVICE_ID= ./run-designed-for-ipad-on-mac.sh -``` diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 9280429..2d9cf9c 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -18,7 +18,6 @@ targets: basedOnDependencyAnalysis: false settings: base: - DD_PRODUCT_BUNDLE_IDENTIFIER: dev.devopsdefender.client.team$(DEVELOPMENT_TEAM) PRODUCT_BUNDLE_IDENTIFIER: "$(DD_PRODUCT_BUNDLE_IDENTIFIER)" PRODUCT_NAME: DevOpsDefender TARGETED_DEVICE_FAMILY: "1,2" diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 6778815..774088d 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; use clap::{Args, Parser, Subcommand}; @@ -24,6 +24,7 @@ struct Cli { enum Command { Keygen(KeygenArgs), Pubkey(KeyOnlyArgs), + MobileLink(MobileLinkArgs), Recipes(ConnectArgs), Sessions(ConnectArgs), Create(CreateArgs), @@ -51,6 +52,18 @@ struct KeygenArgs { label: Option, } +#[derive(Args)] +struct MobileLinkArgs { + #[arg(long)] + url: String, + #[arg(long)] + id: String, + #[arg(long)] + key: Option, + #[arg(long)] + include_key: bool, +} + #[derive(Args, Clone)] struct ConnectArgs { #[arg(long)] @@ -87,6 +100,8 @@ struct SessionArgs { connect: ConnectArgs, #[arg(long)] id: String, + #[arg(long)] + max_bytes: Option, } #[derive(Args)] @@ -129,6 +144,9 @@ async fn main() -> anyhow::Result<()> { Command::Pubkey(args) => { println!("{}", public_key_hex(&args.key).await?); } + Command::MobileLink(args) => { + print_mobile_link(args).await?; + } Command::Recipes(args) => { let mut conn = connect(&connection_options(args)?).await?; print_json(list_recipes(&mut conn).await?)?; @@ -143,7 +161,7 @@ async fn main() -> anyhow::Result<()> { } Command::Replay(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; - print_json(replay_session(&mut conn, &args.id).await?)?; + print_json(replay_session(&mut conn, &args.id, args.max_bytes).await?)?; } Command::Resize(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; @@ -180,6 +198,88 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +async fn print_mobile_link(args: MobileLinkArgs) -> anyhow::Result<()> { + let key_hex = if args.include_key { + let key = args + .key + .as_deref() + .ok_or_else(|| anyhow!("--key is required with --include-key"))?; + Some(load_key_hex(key).await?) + } else { + None + }; + let link = mobile_session_url(&args.url, &args.id, key_hex.as_deref()); + println!("{link}"); + + if let Some(key) = args.key { + println!(); + println!("pubkey: {}", public_key_hex(&key).await?); + println!( + "key import: xxd -p -c 256 {} | pbcopy", + shell_quote_path(&key) + ); + } + + println!(); + if args.include_key { + println!(); + println!("warning: this link contains the Noise private key; treat the QR as secret"); + } + + println!(); + println!("Open this link on iPhone, or make a QR with:"); + println!("qrencode -t ansiutf8 '{}'", shell_escape_single(&link)); + Ok(()) +} + +fn mobile_session_url(agent_url: &str, session_id: &str, key_hex: Option<&str>) -> String { + let mut url = format!( + "devopsdefender://session?agent={}&id={}&skip_quote_verify=1", + percent_encode(agent_url), + percent_encode(session_id) + ); + if let Some(key_hex) = key_hex { + url.push_str("&key="); + url.push_str(&percent_encode(key_hex)); + } + url +} + +async fn load_key_hex(path: &Path) -> anyhow::Result { + let bytes = tokio::fs::read(path) + .await + .with_context(|| format!("read {}", path.display()))?; + if bytes.len() != 32 { + anyhow::bail!("{} is {} bytes, expected 32", path.display(), bytes.len()); + } + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn percent_encode(value: &str) -> String { + let mut out = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn shell_quote_path(path: &std::path::Path) -> String { + shell_quote(&path.to_string_lossy()) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", shell_escape_single(value)) +} + +fn shell_escape_single(value: &str) -> String { + value.replace('\'', "'\\''") +} + fn create_request(args: &CreateArgs) -> CreateSessionRequest { CreateSessionRequest { recipe: args.recipe.clone(), diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index aa905dc..8334dbe 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -144,12 +144,19 @@ pub async fn create_session( conn.call(Value::Object(body)).await } -pub async fn replay_session(conn: &mut NoiseConnection, id: &str) -> anyhow::Result { - conn.call(serde_json::json!({ +pub async fn replay_session( + conn: &mut NoiseConnection, + id: &str, + max_bytes: Option, +) -> anyhow::Result { + let mut request = serde_json::json!({ "method": "shell.replay_session", "id": id, - })) - .await + }); + if let Some(max_bytes) = max_bytes { + request["max_bytes"] = serde_json::json!(max_bytes); + } + conn.call(request).await } pub async fn resize_session( diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 1b921b5..717e64e 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -1,6 +1,9 @@ use std::ffi::{CStr, CString}; +use std::future::Future; use std::os::raw::c_char; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::thread; use std::time::Duration; use base64::Engine as _; @@ -15,6 +18,8 @@ const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; const DEFAULT_ATTACH_MAX_BYTES: usize = 128 * 1024; const MAX_ATTACH_BYTES: usize = 1024 * 1024; const DEFAULT_ATTACH_IDLE_TIMEOUT_MS: u64 = 1200; +const DEFAULT_REPLAY_MAX_BYTES: usize = 48 * 1024; +const MAX_REPLAY_BYTES: usize = 48 * 1024; #[no_mangle] pub extern "C" fn dd_client_keygen( @@ -67,13 +72,11 @@ fn keygen( let cp_url = optional_c_string(cp_url)?; let label = optional_c_string(label)?; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| e.to_string())?; - let pubkey_hex = runtime - .block_on(dd_client_core::public_key_hex(Path::new(&key_path))) - .map_err(|e| e.to_string())?; + let key_path = PathBuf::from(key_path); + let pubkey_hex = + block_on_ffi_result( + move || async move { dd_client_core::public_key_hex(&key_path).await }, + )?; let enrollment_url = match (cp_url.as_deref(), label.as_deref()) { (Some(cp_url), Some(label)) => { Some(dd_client_core::enrollment_url(cp_url, &pubkey_hex, label)) @@ -108,24 +111,18 @@ fn agent_request(request_json: *const c_char) -> Result import_key_request(&request), "recipes" | "list_recipes" => { let opts = connection_options_from_request(&request)?; - let runtime = runtime()?; - let value = runtime - .block_on(async { - let mut conn = connect(&opts).await?; - list_recipes(&mut conn).await - }) - .map_err(|e| e.to_string())?; + let value = block_on_ffi_result(move || async move { + let mut conn = connect(&opts).await?; + list_recipes(&mut conn).await + })?; Ok(ok_value("recipes", value)) } "sessions" | "list_sessions" => { let opts = connection_options_from_request(&request)?; - let runtime = runtime()?; - let value = runtime - .block_on(async { - let mut conn = connect(&opts).await?; - list_sessions(&mut conn).await - }) - .map_err(|e| e.to_string())?; + let value = block_on_ffi_result(move || async move { + let mut conn = connect(&opts).await?; + list_sessions(&mut conn).await + })?; Ok(ok_value("sessions", value)) } "create_session" => { @@ -135,13 +132,10 @@ fn agent_request(request_json: *const c_char) -> Result Result { let opts = connection_options_from_request(&request)?; let id = required_json_string(&request, "id")?; - let runtime = runtime()?; - let value = runtime - .block_on(async { - let mut conn = connect(&opts).await?; - replay_session(&mut conn, &id).await - }) - .map_err(|e| e.to_string())?; + let max_bytes = usize_json_field(&request, "max_bytes")? + .unwrap_or(DEFAULT_REPLAY_MAX_BYTES) + .min(MAX_REPLAY_BYTES); + let value = block_on_ffi_result(move || async move { + let mut conn = connect(&opts).await?; + replay_session(&mut conn, &id, Some(max_bytes)).await + })?; Ok(ok_value("replay_session", value)) } "attach_exchange" | "attach_snapshot" => { @@ -171,20 +165,17 @@ fn agent_request(request_json: *const c_char) -> Result Result Result { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| e.to_string()) +fn block_on_ffi(make_future: M) -> Result +where + M: FnOnce() -> F + Send + 'static, + F: Future + Send + 'static, + T: Send + 'static, +{ + let runtime = runtime()?; + let worker = thread::Builder::new() + .name("dd-client-ffi".to_string()) + .stack_size(16 * 1024 * 1024) + .spawn(move || runtime.block_on(make_future())) + .map_err(|e| format!("spawn async worker: {e}"))?; + + worker + .join() + .map_err(|_| "dd-client async worker panicked".to_string()) +} + +fn block_on_ffi_result(make_future: M) -> Result +where + M: FnOnce() -> F + Send + 'static, + F: Future> + Send + 'static, + T: Send + 'static, + E: ToString + Send + 'static, +{ + block_on_ffi(make_future)?.map_err(|e| e.to_string()) +} + +fn runtime() -> Result<&'static tokio::runtime::Runtime, String> { + static RUNTIME: OnceLock> = OnceLock::new(); + + RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string()) + }) + .as_ref() + .map_err(|e| e.to_owned()) } fn ok_value(operation: &str, value: serde_json::Value) -> serde_json::Value { From fdd95fd854d41d67afcc93f686d599feb121612d Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 15:21:39 -0400 Subject: [PATCH 08/18] Stream iOS session transcripts --- README.md | 7 +- apps/ios/DevOpsDefender/DDClientBridge.swift | 164 +++++++++++++++---- apps/ios/DevOpsDefender/DDClientFFI.h | 6 + apps/ios/README.md | 14 +- crates/dd-client-core/src/lib.rs | 45 +++++ crates/dd-client-ffi/src/lib.rs | 154 ++++++++++++++++- 6 files changed, 342 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 309fa56..36945ff 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,10 @@ dd-client mobile-link \ Open the printed `devopsdefender://session?...` link on iOS, or render it as a QR code with the printed `qrencode` command. `--include-key` puts the Noise -private key in the handoff URL so the mobile app can import it before replaying; -treat that link or QR code as secret. Omit `--include-key` to send only the -agent URL and session id after the app already has the key. +private key in the handoff URL so the mobile app can import it before loading +history and following the live transcript; treat that link or QR code as secret. +Omit `--include-key` to send only the agent URL and session id after the app +already has the key. Quote verification is on by default. Local preview/dev runs without Intel Trust Authority credentials must pass `--insecure-skip-quote-verify` explicitly. diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 570fce8..39bec68 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -50,10 +50,36 @@ enum DDClientBridge { "ita_jwks_url": "", "ita_issuer": "", "id": id, - "max_bytes": 49152 + "max_bytes": 32768 ]) } + static func startAttachStream( + id: String, + settings: AgentSettings, + onEvent: @escaping @Sendable (AttachStreamEvent) -> Void + ) throws -> AttachStream { + let payload: [String: Any] = [ + "agent_url": settings.agentURL, + "key_path": settings.keyPath, + "insecure_skip_quote_verify": true, + "ita_api_key": "", + "ita_base_url": "", + "ita_jwks_url": "", + "ita_issuer": "", + "id": id, + "tail": true + ] + let data = try JSONSerialization.data(withJSONObject: payload) + guard let requestJSON = String(data: data, encoding: .utf8) else { + throw DDClientError(message: "Failed to encode stream request") + } + guard let stream = AttachStream(requestJSON: requestJSON, onEvent: onEvent) else { + throw DDClientError(message: "Failed to start attach stream") + } + return stream + } + private static func request(_ payload: [String: Any]) throws -> [String: Any] { let requestData = try JSONSerialization.data(withJSONObject: payload) guard let requestJSON = String(data: requestData, encoding: .utf8) else { @@ -83,6 +109,61 @@ enum DDClientBridge { } } +struct AttachStreamEvent: Sendable { + var type: String + var data: Data? + var message: String? +} + +final class AttachStream { + private let handle: UInt64 + + init?(requestJSON: String, onEvent: @escaping @Sendable (AttachStreamEvent) -> Void) { + let context = Unmanaged.passRetained(AttachStreamContext(onEvent: onEvent)).toOpaque() + let handle = requestJSON.withCString { requestCString in + dd_client_attach_stream_start(requestCString, attachStreamCallback, context) + } + if handle == 0 { + Unmanaged.fromOpaque(context).release() + return nil + } + self.handle = handle + } + + func stop() { + dd_client_attach_stream_stop(handle) + } + + deinit { + stop() + } +} + +private final class AttachStreamContext { + let onEvent: @Sendable (AttachStreamEvent) -> Void + + init(onEvent: @escaping @Sendable (AttachStreamEvent) -> Void) { + self.onEvent = onEvent + } +} + +private let attachStreamCallback: @convention(c) ( + UInt64, + UnsafePointer?, + UnsafeMutableRawPointer? +) -> Void = { _, eventPointer, contextPointer in + guard let eventPointer, let contextPointer else { + return + } + let eventJSON = String(cString: eventPointer) + let event = parseAttachStreamEvent(eventJSON) + let context = Unmanaged.fromOpaque(contextPointer).takeUnretainedValue() + context.onEvent(event) + if event.type == "close" { + Unmanaged.fromOpaque(contextPointer).release() + } +} + enum AppDefaults { static var appSupportNoiseKeyPath: String { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) @@ -103,7 +184,7 @@ final class ClientViewModel: ObservableObject { private var agentURL = "" private var keyPath = AppDefaults.appSupportNoiseKeyPath - private var refreshTask: Task? + private var attachStream: AttachStream? private var terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) var hasLinkedSession: Bool { @@ -137,7 +218,8 @@ final class ClientViewModel: ObservableObject { return } - refreshTask?.cancel() + attachStream?.stop() + attachStream = nil agentURL = agent selectedSessionID = id keyPath = AppDefaults.appSupportNoiseKeyPath @@ -163,7 +245,7 @@ final class ClientViewModel: ObservableObject { agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), keyPath: path ) - run("Importing key", keepRefreshing: true) { + run("Importing key", startStreamAfterInitialLoad: true) { try DDClientBridge.importKey(keyPath: path, keyContent: key) return initialTranscriptUpdate(id: id, settings: settings) } @@ -179,14 +261,14 @@ final class ClientViewModel: ObservableObject { agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), keyPath: keyPath.expandingTildePath ) - run("Loading transcript", keepRefreshing: true) { + run("Loading transcript", startStreamAfterInitialLoad: true) { initialTranscriptUpdate(id: id, settings: settings) } } private func run( _ pendingStatus: String, - keepRefreshing: Bool = false, + startStreamAfterInitialLoad: Bool = false, work: @escaping @Sendable () throws -> ClientUpdate ) { guard !isBusy else { @@ -202,8 +284,8 @@ final class ClientViewModel: ObservableObject { }.value status = update.status apply(update) - if keepRefreshing { - startRefreshing() + if startStreamAfterInitialLoad { + startAttachStream() } } catch { status = error.localizedDescription @@ -212,37 +294,43 @@ final class ClientViewModel: ObservableObject { } } - private func startRefreshing() { - refreshTask?.cancel() - refreshTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 650_000_000) - self?.refreshTranscript() - } - } - } - - private func refreshTranscript() { - guard hasLinkedSession, !isBusy else { + private func startAttachStream() { + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { return } - let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + attachStream?.stop() let settings = AgentSettings( agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), keyPath: keyPath.expandingTildePath ) - isBusy = true - Task { - do { - let update = try await Task.detached(priority: .utility) { - try transcriptUpdate(id: id, settings: settings) - }.value - status = update.status - apply(update) - } catch { - status = error.localizedDescription + do { + attachStream = try DDClientBridge.startAttachStream(id: id, settings: settings) { [weak self] event in + Task { @MainActor in + self?.handleStreamEvent(event, id: id) + } } - isBusy = false + } catch { + status = error.localizedDescription + } + } + + private func handleStreamEvent(_ event: AttachStreamEvent, id: String) { + switch event.type { + case "open": + status = "Connected \(id)" + case "bytes": + guard let data = event.data, + let text = String(data: data, encoding: .utf8) else { + return + } + apply(ClientUpdate(status: "Connected \(id)", terminalText: text)) + case "error": + status = event.message ?? "Stream error" + case "close": + status = "Disconnected \(id)" + default: + break } } @@ -267,6 +355,18 @@ private extension String { } } +private func parseAttachStreamEvent(_ eventJSON: String) -> AttachStreamEvent { + guard let data = eventJSON.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return AttachStreamEvent(type: "error", data: nil, message: "Invalid stream event") + } + + let type = object["type"] as? String ?? "unknown" + let payload = (object["data_b64"] as? String).flatMap { Data(base64Encoded: $0) } + let message = object["message"] as? String + return AttachStreamEvent(type: type, data: payload, message: message) +} + private func transcriptUpdate(id: String, settings: AgentSettings) throws -> ClientUpdate { let response = try DDClientBridge.transcriptSnapshot(id: id, settings: settings) return ClientUpdate( diff --git a/apps/ios/DevOpsDefender/DDClientFFI.h b/apps/ios/DevOpsDefender/DDClientFFI.h index b55ce86..ab3c00e 100644 --- a/apps/ios/DevOpsDefender/DDClientFFI.h +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -5,8 +5,14 @@ extern "C" { #endif +#include + +typedef void (*dd_client_stream_callback)(uint64_t handle, const char *event_json, void *context); + char *dd_client_keygen(const char *key_path, const char *cp_url, const char *label); char *dd_client_agent_request(const char *request_json); +uint64_t dd_client_attach_stream_start(const char *request_json, dd_client_stream_callback callback, void *context); +void dd_client_attach_stream_stop(uint64_t handle); void dd_client_string_free(char *value); #ifdef __cplusplus diff --git a/apps/ios/README.md b/apps/ios/README.md index 4aa6760..3ca3788 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -3,8 +3,8 @@ Native SwiftUI companion for `dd-client` CLI sessions. The desktop CLI owns agent selection, session creation, enrollment, and full -terminal attach. The iOS app only opens a desktop-generated session link and -shows the replayed transcript. +terminal attach. The iOS app only opens a desktop-generated session link, loads +bounded transcript history, and follows the live transcript. ## Desktop Flow @@ -42,13 +42,13 @@ cargo run -p dd-client -- mobile-link \ Open the printed `devopsdefender://session?...` link on iOS, or render the QR with the printed `qrencode` command. With `--include-key`, the link contains the -Noise private key and the app imports it before replaying; treat the link or QR -as secret. +Noise private key and the app imports it before loading history and following +the transcript; treat the link or QR as secret. ## Key Import Fallback Prefer `--include-key` so the link is self-contained. If you omit it, the app -can only replay after a key has already been imported into its Application +can only connect after a key has already been imported into its Application Support directory. ```bash @@ -59,10 +59,10 @@ xxd -p -c 256 "$HOME/.config/devopsdefender/noise.key" | pbcopy - Open a `devopsdefender://session?...` link or scan its QR code. - The app imports the embedded key when present. -- The app replays the linked session transcript. +- The app loads recent transcript history, then keeps following live output. The app intentionally does not create sessions, list recipes, browse agents, -attach for live I/O, or send input. +send input, or take over terminal control. ## Prerequisites diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index 8334dbe..57f6306 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -11,6 +11,7 @@ use serde_json::Value; use snow::{Builder, TransportState}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::sync::watch; use tokio::time::{timeout, Duration}; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; @@ -282,6 +283,50 @@ pub async fn attach_session_exchange( Ok(output) } +pub async fn attach_session_stream( + mut conn: NoiseConnection, + id: &str, + tail: bool, + mut shutdown: watch::Receiver, + mut on_open: impl FnMut() -> anyhow::Result<()> + Send, + mut on_bytes: F, +) -> anyhow::Result<()> +where + F: FnMut(&[u8]) -> anyhow::Result<()> + Send, +{ + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": id, + "tail": tail, + })) + .await?; + if ack.get("error").is_some() { + anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); + } + on_open()?; + + loop { + tokio::select! { + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + break; + } + } + frame = next_binary(&mut conn.stream) => { + let Some(cipher) = frame? else { + break; + }; + let mut plain = vec![0u8; cipher.len()]; + let n = conn.transport.read_message(&cipher, &mut plain)?; + on_bytes(&plain[..n])?; + } + } + } + + Ok(()) +} + #[derive(Debug, Eq, PartialEq)] enum AttachInputAction { Forward, diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 717e64e..0b1be8f 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -1,16 +1,20 @@ use std::ffi::{CStr, CString}; use std::future::Future; -use std::os::raw::c_char; +use std::os::raw::{c_char, c_void}; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::Duration; use base64::Engine as _; use dd_client_core::{ - attach_session_exchange, connect, create_session, list_recipes, list_sessions, replay_session, - session_id, ConnectionOptions, CreateSessionRequest, IntelTrustAuthority, QuoteVerification, + attach_session_exchange, attach_session_stream, connect, create_session, list_recipes, + list_sessions, replay_session, session_id, ConnectionOptions, CreateSessionRequest, + IntelTrustAuthority, QuoteVerification, }; +use std::collections::HashMap; +use tokio::sync::watch; const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; const DEFAULT_ITA_JWKS_URL: &str = "https://portal.trustauthority.intel.com/certs"; @@ -18,8 +22,17 @@ const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; const DEFAULT_ATTACH_MAX_BYTES: usize = 128 * 1024; const MAX_ATTACH_BYTES: usize = 1024 * 1024; const DEFAULT_ATTACH_IDLE_TIMEOUT_MS: u64 = 1200; -const DEFAULT_REPLAY_MAX_BYTES: usize = 48 * 1024; -const MAX_REPLAY_BYTES: usize = 48 * 1024; +const DEFAULT_REPLAY_MAX_BYTES: usize = 32 * 1024; +const MAX_REPLAY_BYTES: usize = 32 * 1024; + +type StreamCallback = extern "C" fn(u64, *const c_char, *mut c_void); + +struct StreamControl { + shutdown: watch::Sender, +} + +static NEXT_STREAM_ID: AtomicU64 = AtomicU64::new(1); +static STREAMS: OnceLock>> = OnceLock::new(); #[no_mangle] pub extern "C" fn dd_client_keygen( @@ -37,6 +50,29 @@ pub extern "C" fn dd_client_agent_request(request_json: *const c_char) -> *mut c into_c_string(result) } +#[no_mangle] +pub extern "C" fn dd_client_attach_stream_start( + request_json: *const c_char, + callback: Option, + context: *mut c_void, +) -> u64 { + attach_stream_start(request_json, callback, context).unwrap_or(0) +} + +#[no_mangle] +pub extern "C" fn dd_client_attach_stream_stop(handle: u64) { + if handle == 0 { + return; + } + if let Some(control) = streams() + .lock() + .ok() + .and_then(|mut map| map.remove(&handle)) + { + let _ = control.shutdown.send(true); + } +} + #[no_mangle] /// # Safety /// @@ -49,6 +85,112 @@ pub unsafe extern "C" fn dd_client_string_free(value: *mut c_char) { let _ = unsafe { CString::from_raw(value) }; } +fn attach_stream_start( + request_json: *const c_char, + callback: Option, + context: *mut c_void, +) -> Result { + let callback = callback.ok_or_else(|| "callback is required".to_string())?; + let request_json = required_c_string(request_json, "request_json")?; + let request: serde_json::Value = + serde_json::from_str(&request_json).map_err(|e| format!("parse request_json: {e}"))?; + let opts = connection_options_from_request(&request)?; + let id = required_json_string(&request, "id")?; + let tail = bool_json_field(&request, "tail")?.unwrap_or(true); + let (shutdown, shutdown_rx) = watch::channel(false); + let handle = NEXT_STREAM_ID.fetch_add(1, Ordering::Relaxed); + + streams() + .lock() + .map_err(|_| "stream registry lock poisoned".to_string())? + .insert(handle, StreamControl { shutdown }); + + let context_addr = context as usize; + let worker = thread::Builder::new() + .name(format!("dd-client-attach-{handle}")) + .stack_size(16 * 1024 * 1024) + .spawn(move || { + let result = runtime().and_then(|runtime| { + runtime + .block_on(async move { + let conn = connect(&opts).await?; + attach_session_stream( + conn, + &id, + tail, + shutdown_rx, + || { + emit_stream_event( + callback, + context_addr, + handle, + serde_json::json!({"type": "open", "id": id}), + ); + Ok(()) + }, + |bytes| { + emit_stream_event( + callback, + context_addr, + handle, + serde_json::json!({ + "type": "bytes", + "data_b64": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ); + Ok(()) + }, + ) + .await + }) + .map_err(|e| e.to_string()) + }); + if let Err(error) = result { + emit_stream_event( + callback, + context_addr, + handle, + serde_json::json!({"type": "error", "message": error}), + ); + } + if let Ok(mut map) = streams().lock() { + map.remove(&handle); + } + emit_stream_event( + callback, + context_addr, + handle, + serde_json::json!({"type": "close"}), + ); + }); + + if let Err(error) = worker { + if let Ok(mut map) = streams().lock() { + map.remove(&handle); + } + return Err(format!("spawn attach stream: {error}")); + } + + Ok(handle) +} + +fn emit_stream_event( + callback: StreamCallback, + context_addr: usize, + handle: u64, + event: serde_json::Value, +) { + if let Ok(event_json) = serde_json::to_string(&event) { + if let Ok(c_event) = CString::new(event_json) { + callback(handle, c_event.as_ptr(), context_addr as *mut c_void); + } + } +} + +fn streams() -> &'static Mutex> { + STREAMS.get_or_init(|| Mutex::new(HashMap::new())) +} + fn keygen_response( key_path: *const c_char, cp_url: *const c_char, From a33d480448233cb8490a820fef0eaa84a0cc7b44 Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 15:38:46 -0400 Subject: [PATCH 09/18] Prevent iOS transcript wrapping --- apps/ios/DevOpsDefender/ContentView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index 0e896aa..83371a9 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -7,17 +7,18 @@ struct ContentView: View { ZStack(alignment: .topLeading) { Palette.background.ignoresSafeArea() - ScrollView(.vertical) { + ScrollView([.horizontal, .vertical]) { Text(transcriptText) .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Palette.text) .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: true, vertical: true) + .frame(alignment: .topLeading) .padding(.horizontal, 16) .padding(.top, 46) .padding(.bottom, 24) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) HStack(spacing: 8) { Circle() From 869d153cc8ad3845759bb8a48206ad2794eecc5a Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 15:51:12 -0400 Subject: [PATCH 10/18] Resize CLI sessions with terminal window --- Cargo.lock | 11 ++++ crates/dd-client-cli/src/main.rs | 10 +-- crates/dd-client-core/Cargo.toml | 2 +- crates/dd-client-core/src/lib.rs | 102 ++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b00d45a..5fe8073 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1563,6 +1573,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 774088d..f42181c 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -172,14 +172,16 @@ async fn main() -> anyhow::Result<()> { print_json(close_session(&mut conn, &args.id).await?)?; } Command::Attach(args) => { - let conn = connect(&connection_options(args.connect)?).await?; - attach_session(conn, &args.id).await?; + let opts = connection_options(args.connect)?; + let conn = connect(&opts).await?; + attach_session(conn, &args.id, Some(opts)).await?; } Command::Shell(args) => { - let mut conn = connect(&connection_options(args.connect.clone())?).await?; + let opts = connection_options(args.connect.clone())?; + let mut conn = connect(&opts).await?; let session = create_session(&mut conn, &create_request(&args)).await?; let id = session_id(&session)?; - attach_session(conn, &id).await?; + attach_session(conn, &id, Some(opts)).await?; } Command::Exec(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; diff --git a/crates/dd-client-core/Cargo.toml b/crates/dd-client-core/Cargo.toml index 9438fa3..662657e 100644 --- a/crates/dd-client-core/Cargo.toml +++ b/crates/dd-client-core/Cargo.toml @@ -18,7 +18,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"] } serde_json = "1" snow = { version = "0.9", default-features = false, features = ["default-resolver"] } -tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } urlencoding = "2" x25519-dalek = { version = "2", features = ["static_secrets"] } diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index 57f6306..906b9ee 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -192,7 +192,18 @@ pub async fn exec(conn: &mut NoiseConnection, request: &ExecRequest) -> anyhow:: .await } -pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Result<()> { +pub async fn attach_session( + mut conn: NoiseConnection, + id: &str, + resize_opts: Option, +) -> anyhow::Result<()> { + let initial_size = current_terminal_size(); + if let Some(size) = initial_size { + if let Err(error) = resize_session(&mut conn, id, size.cols, size.rows).await { + eprintln!("warning: initial terminal resize failed: {error}"); + } + } + let ack = conn .call(serde_json::json!({ "method": "shell.attach_session", @@ -207,6 +218,13 @@ pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Resu eprintln!("attached; Ctrl-] detaches, Ctrl-D sends EOF and disconnects"); let _raw = RawMode::enter()?; + let resize_task = resize_opts.map(|opts| { + tokio::spawn(resize_on_terminal_change( + opts, + id.to_string(), + initial_size, + )) + }); let mut stdin = tokio::io::stdin(); let mut stdout = tokio::io::stdout(); let mut in_buf = [0u8; ATTACH_CHUNK]; @@ -240,6 +258,9 @@ pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Resu } } } + if let Some(task) = resize_task { + task.abort(); + } Ok(()) } @@ -334,6 +355,85 @@ enum AttachInputAction { Disconnect, } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +struct TerminalSize { + cols: u64, + rows: u64, +} + +#[cfg(unix)] +async fn resize_on_terminal_change( + opts: ConnectionOptions, + id: String, + mut last_size: Option, +) { + let mut signals = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()) { + Ok(signals) => signals, + Err(error) => { + eprintln!("warning: could not watch terminal resize events: {error}"); + return; + } + }; + + while signals.recv().await.is_some() { + let Some(size) = current_terminal_size() else { + continue; + }; + if Some(size) == last_size { + continue; + } + match connect(&opts).await { + Ok(mut conn) => match resize_session(&mut conn, &id, size.cols, size.rows).await { + Ok(_) => last_size = Some(size), + Err(error) => eprintln!("warning: terminal resize failed: {error}"), + }, + Err(error) => eprintln!("warning: terminal resize connect failed: {error}"), + } + } +} + +#[cfg(not(unix))] +async fn resize_on_terminal_change( + _opts: ConnectionOptions, + _id: String, + _last_size: Option, +) { +} + +fn current_terminal_size() -> Option { + #[cfg(unix)] + { + terminal_size_for_fd(libc::STDOUT_FILENO) + .or_else(|| terminal_size_for_fd(libc::STDIN_FILENO)) + .or_else(|| terminal_size_for_fd(libc::STDERR_FILENO)) + } + #[cfg(not(unix))] + { + None + } +} + +#[cfg(unix)] +fn terminal_size_for_fd(fd: libc::c_int) -> Option { + if unsafe { libc::isatty(fd) } != 1 { + return None; + } + + let mut winsize = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, winsize.as_mut_ptr()) } != 0 { + return None; + } + let winsize = unsafe { winsize.assume_init() }; + if winsize.ws_col == 0 || winsize.ws_row == 0 { + return None; + } + Some(TerminalSize { + cols: winsize.ws_col.into(), + rows: winsize.ws_row.into(), + }) +} + fn attach_input_action(bytes: &[u8]) -> AttachInputAction { match bytes { [CTRL_D] => AttachInputAction::ForwardThenDisconnect, From b6bf155387e1b841e913a71f480a6f3f0c64b37b Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 16:16:41 -0400 Subject: [PATCH 11/18] Remove legacy mobile preview controls --- README.md | 12 +- apps/ios/DevOpsDefender/DDClientBridge.swift | 28 +--- apps/ios/DevOpsDefender/DDClientFFI.h | 1 - crates/dd-client-cli/src/main.rs | 74 +-------- crates/dd-client-core/src/lib.rs | 61 ------- crates/dd-client-ffi/src/lib.rs | 161 +------------------ 6 files changed, 9 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 36945ff..5f20d41 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ This repo owns client-side code that should not live in - `dd-client-core`: reusable Rust client core for pairing, quote verification, direct agent Noise transport, session RPCs, and PTY streaming. - `dd-client`: CLI binary using `dd-client-core`. -- `dd-client-ffi`: C-compatible bridge for native mobile shells. -- `apps/ios`: iOS client workspace notes; it will use the same core. +- `dd-client-ffi`: C-compatible bridge for mobile transcript viewing. +- `apps/ios`: iOS companion that opens desktop-generated session links. The control plane is only for enrollment and route discovery. Shell, log, and session bytes go directly between the paired client and the selected agent over @@ -31,14 +31,6 @@ dd-client keygen --key ~/.config/devopsdefender/noise.key \ --label laptop ``` -List recipes on an enrolled agent: - -```bash -DD_ITA_API_KEY=... dd-client recipes \ - --url https://agent.example.com \ - --key ~/.config/devopsdefender/noise.key -``` - Open a shell: ```bash diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 39bec68..d4efe45 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -22,23 +22,6 @@ enum DDClientBridge { ]) } - static func transcriptSnapshot(id: String, settings: AgentSettings) throws -> [String: Any] { - try request([ - "operation": "attach_exchange", - "agent_url": settings.agentURL, - "key_path": settings.keyPath, - "insecure_skip_quote_verify": true, - "ita_api_key": "", - "ita_base_url": "", - "ita_jwks_url": "", - "ita_issuer": "", - "id": id, - "input": "", - "max_bytes": 131072, - "idle_timeout_ms": 250 - ]) - } - static func transcriptHistory(id: String, settings: AgentSettings) throws -> [String: Any] { try request([ "operation": "replay_session", @@ -367,14 +350,6 @@ private func parseAttachStreamEvent(_ eventJSON: String) -> AttachStreamEvent { return AttachStreamEvent(type: type, data: payload, message: message) } -private func transcriptUpdate(id: String, settings: AgentSettings) throws -> ClientUpdate { - let response = try DDClientBridge.transcriptSnapshot(id: id, settings: settings) - return ClientUpdate( - status: "Loaded \(id)", - terminalText: transcriptText(from: response) - ) -} - private func initialTranscriptUpdate(id: String, settings: AgentSettings) -> ClientUpdate { if let response = try? DDClientBridge.transcriptHistory(id: id, settings: settings) { let history = historyText(from: response) @@ -382,8 +357,7 @@ private func initialTranscriptUpdate(id: String, settings: AgentSettings) -> Cli return ClientUpdate(status: "Loaded \(id)", terminalText: history) } } - return (try? transcriptUpdate(id: id, settings: settings)) - ?? ClientUpdate(status: "Loaded \(id)", terminalText: "") + return ClientUpdate(status: "Loaded \(id)", terminalText: "") } private func transcriptText(from value: Any?) -> String { diff --git a/apps/ios/DevOpsDefender/DDClientFFI.h b/apps/ios/DevOpsDefender/DDClientFFI.h index ab3c00e..6cd7afa 100644 --- a/apps/ios/DevOpsDefender/DDClientFFI.h +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -9,7 +9,6 @@ extern "C" { typedef void (*dd_client_stream_callback)(uint64_t handle, const char *event_json, void *context); -char *dd_client_keygen(const char *key_path, const char *cp_url, const char *label); char *dd_client_agent_request(const char *request_json); uint64_t dd_client_attach_stream_start(const char *request_json, dd_client_stream_callback callback, void *context); void dd_client_attach_stream_stop(uint64_t handle); diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index f42181c..24e4bef 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -3,9 +3,9 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; use clap::{Args, Parser, Subcommand}; use dd_client_core::{ - attach_session, close_session, connect, create_session, enrollment_url, exec, list_recipes, - list_sessions, public_key_hex, replay_session, resize_session, session_id, ConnectionOptions, - CreateSessionRequest, ExecRequest, IntelTrustAuthority, QuoteVerification, + attach_session, close_session, connect, create_session, enrollment_url, list_sessions, + public_key_hex, session_id, ConnectionOptions, CreateSessionRequest, IntelTrustAuthority, + QuoteVerification, }; const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; @@ -23,23 +23,11 @@ struct Cli { #[derive(Subcommand)] enum Command { Keygen(KeygenArgs), - Pubkey(KeyOnlyArgs), MobileLink(MobileLinkArgs), - Recipes(ConnectArgs), Sessions(ConnectArgs), - Create(CreateArgs), - Replay(SessionArgs), - Resize(ResizeArgs), Close(SessionArgs), Attach(SessionArgs), Shell(CreateArgs), - Exec(ExecArgs), -} - -#[derive(Args)] -struct KeyOnlyArgs { - #[arg(long)] - key: PathBuf, } #[derive(Args)] @@ -100,30 +88,6 @@ struct SessionArgs { connect: ConnectArgs, #[arg(long)] id: String, - #[arg(long)] - max_bytes: Option, -} - -#[derive(Args)] -struct ResizeArgs { - #[command(flatten)] - connect: ConnectArgs, - #[arg(long)] - id: String, - #[arg(long)] - cols: u64, - #[arg(long)] - rows: u64, -} - -#[derive(Args)] -struct ExecArgs { - #[command(flatten)] - connect: ConnectArgs, - #[arg(long, default_value_t = 60)] - timeout: u64, - #[arg(last = true, required = true)] - cmd: Vec, } #[tokio::main] @@ -141,32 +105,13 @@ async fn main() -> anyhow::Result<()> { println!("{}", enrollment_url(cp_url, &pubkey, label)); } } - Command::Pubkey(args) => { - println!("{}", public_key_hex(&args.key).await?); - } Command::MobileLink(args) => { print_mobile_link(args).await?; } - Command::Recipes(args) => { - let mut conn = connect(&connection_options(args)?).await?; - print_json(list_recipes(&mut conn).await?)?; - } Command::Sessions(args) => { let mut conn = connect(&connection_options(args)?).await?; print_json(list_sessions(&mut conn).await?)?; } - Command::Create(args) => { - let mut conn = connect(&connection_options(args.connect.clone())?).await?; - print_json(create_session(&mut conn, &create_request(&args)).await?)?; - } - Command::Replay(args) => { - let mut conn = connect(&connection_options(args.connect)?).await?; - print_json(replay_session(&mut conn, &args.id, args.max_bytes).await?)?; - } - Command::Resize(args) => { - let mut conn = connect(&connection_options(args.connect)?).await?; - print_json(resize_session(&mut conn, &args.id, args.cols, args.rows).await?)?; - } Command::Close(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; print_json(close_session(&mut conn, &args.id).await?)?; @@ -183,19 +128,6 @@ async fn main() -> anyhow::Result<()> { let id = session_id(&session)?; attach_session(conn, &id, Some(opts)).await?; } - Command::Exec(args) => { - let mut conn = connect(&connection_options(args.connect)?).await?; - print_json( - exec( - &mut conn, - &ExecRequest { - cmd: args.cmd, - timeout_secs: args.timeout, - }, - ) - .await?, - )?; - } } Ok(()) } diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index 906b9ee..2c82d17 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -12,7 +12,6 @@ use snow::{Builder, TransportState}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::sync::watch; -use tokio::time::{timeout, Duration}; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use x25519_dalek::{PublicKey, StaticSecret}; @@ -55,12 +54,6 @@ pub struct CreateSessionRequest { pub command: Option, } -#[derive(Debug, Clone)] -pub struct ExecRequest { - pub cmd: Vec, - pub timeout_secs: u64, -} - pub struct NoiseConnection { transport: TransportState, sink: WsSink, @@ -115,11 +108,6 @@ pub async fn connect(opts: &ConnectionOptions) -> anyhow::Result anyhow::Result { - conn.call(serde_json::json!({"method": "shell.list_recipes"})) - .await -} - pub async fn list_sessions(conn: &mut NoiseConnection) -> anyhow::Result { conn.call(serde_json::json!({"method": "shell.list_sessions"})) .await @@ -183,15 +171,6 @@ pub async fn close_session(conn: &mut NoiseConnection, id: &str) -> anyhow::Resu .await } -pub async fn exec(conn: &mut NoiseConnection, request: &ExecRequest) -> anyhow::Result { - conn.call(serde_json::json!({ - "method": "exec", - "cmd": request.cmd, - "timeout_secs": request.timeout_secs, - })) - .await -} - pub async fn attach_session( mut conn: NoiseConnection, id: &str, @@ -264,46 +243,6 @@ pub async fn attach_session( Ok(()) } -pub async fn attach_session_exchange( - mut conn: NoiseConnection, - id: &str, - input: &[u8], - max_bytes: usize, - idle_timeout: Duration, -) -> anyhow::Result> { - let ack = conn - .call(serde_json::json!({ - "method": "shell.attach_session", - "id": id, - "tail": true, - })) - .await?; - if ack.get("error").is_some() { - anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); - } - - if !input.is_empty() { - send_encrypted(&mut conn.transport, &mut conn.sink, input).await?; - } - - let mut output = Vec::new(); - while output.len() < max_bytes { - let frame = match timeout(idle_timeout, next_binary(&mut conn.stream)).await { - Ok(frame) => frame?, - Err(_) => break, - }; - let Some(cipher) = frame else { - break; - }; - let mut plain = vec![0u8; cipher.len()]; - let n = conn.transport.read_message(&cipher, &mut plain)?; - let remaining = max_bytes - output.len(); - output.extend_from_slice(&plain[..n.min(remaining)]); - } - - Ok(output) -} - pub async fn attach_session_stream( mut conn: NoiseConnection, id: &str, diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 0b1be8f..0f2dae7 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -5,13 +5,11 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::thread; -use std::time::Duration; use base64::Engine as _; use dd_client_core::{ - attach_session_exchange, attach_session_stream, connect, create_session, list_recipes, - list_sessions, replay_session, session_id, ConnectionOptions, CreateSessionRequest, - IntelTrustAuthority, QuoteVerification, + attach_session_stream, connect, replay_session, ConnectionOptions, IntelTrustAuthority, + QuoteVerification, }; use std::collections::HashMap; use tokio::sync::watch; @@ -19,9 +17,6 @@ use tokio::sync::watch; const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; const DEFAULT_ITA_JWKS_URL: &str = "https://portal.trustauthority.intel.com/certs"; const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; -const DEFAULT_ATTACH_MAX_BYTES: usize = 128 * 1024; -const MAX_ATTACH_BYTES: usize = 1024 * 1024; -const DEFAULT_ATTACH_IDLE_TIMEOUT_MS: u64 = 1200; const DEFAULT_REPLAY_MAX_BYTES: usize = 32 * 1024; const MAX_REPLAY_BYTES: usize = 32 * 1024; @@ -34,16 +29,6 @@ struct StreamControl { static NEXT_STREAM_ID: AtomicU64 = AtomicU64::new(1); static STREAMS: OnceLock>> = OnceLock::new(); -#[no_mangle] -pub extern "C" fn dd_client_keygen( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> *mut c_char { - let result = keygen_response(key_path, cp_url, label); - into_c_string(result) -} - #[no_mangle] pub extern "C" fn dd_client_agent_request(request_json: *const c_char) -> *mut c_char { let result = agent_request_response(request_json); @@ -191,48 +176,6 @@ fn streams() -> &'static Mutex> { STREAMS.get_or_init(|| Mutex::new(HashMap::new())) } -fn keygen_response( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> serde_json::Value { - match keygen(key_path, cp_url, label) { - Ok(value) => value, - Err(error) => serde_json::json!({ - "ok": false, - "error": error, - }), - } -} - -fn keygen( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> Result { - let key_path = required_c_string(key_path, "key_path")?; - let cp_url = optional_c_string(cp_url)?; - let label = optional_c_string(label)?; - - let key_path = PathBuf::from(key_path); - let pubkey_hex = - block_on_ffi_result( - move || async move { dd_client_core::public_key_hex(&key_path).await }, - )?; - let enrollment_url = match (cp_url.as_deref(), label.as_deref()) { - (Some(cp_url), Some(label)) => { - Some(dd_client_core::enrollment_url(cp_url, &pubkey_hex, label)) - } - _ => None, - }; - - Ok(serde_json::json!({ - "ok": true, - "pubkey_hex": pubkey_hex, - "enrollment_url": enrollment_url, - })) -} - fn agent_request_response(request_json: *const c_char) -> serde_json::Value { match agent_request(request_json) { Ok(value) => value, @@ -251,40 +194,6 @@ fn agent_request(request_json: *const c_char) -> Result import_key_request(&request), - "recipes" | "list_recipes" => { - let opts = connection_options_from_request(&request)?; - let value = block_on_ffi_result(move || async move { - let mut conn = connect(&opts).await?; - list_recipes(&mut conn).await - })?; - Ok(ok_value("recipes", value)) - } - "sessions" | "list_sessions" => { - let opts = connection_options_from_request(&request)?; - let value = block_on_ffi_result(move || async move { - let mut conn = connect(&opts).await?; - list_sessions(&mut conn).await - })?; - Ok(ok_value("sessions", value)) - } - "create_session" => { - let opts = connection_options_from_request(&request)?; - let create_request = CreateSessionRequest { - recipe: optional_json_string(&request, "recipe")?, - name: optional_json_string(&request, "name")?, - command: optional_json_string(&request, "command")?, - }; - let value = block_on_ffi_result(move || async move { - let mut conn = connect(&opts).await?; - create_session(&mut conn, &create_request).await - })?; - let mut response = ok_map("create_session"); - if let Ok(id) = session_id(&value) { - response.insert("session_id".to_string(), serde_json::Value::String(id)); - } - response.insert("value".to_string(), value); - Ok(serde_json::Value::Object(response)) - } "replay_session" => { let opts = connection_options_from_request(&request)?; let id = required_json_string(&request, "id")?; @@ -297,42 +206,6 @@ fn agent_request(request_json: *const c_char) -> Result { - let opts = connection_options_from_request(&request)?; - let id = required_json_string(&request, "id")?; - let input = optional_json_string(&request, "input")?.unwrap_or_default(); - let max_bytes = usize_json_field(&request, "max_bytes")? - .unwrap_or(DEFAULT_ATTACH_MAX_BYTES) - .min(MAX_ATTACH_BYTES); - let idle_timeout_ms = u64_json_field(&request, "idle_timeout_ms")? - .unwrap_or(DEFAULT_ATTACH_IDLE_TIMEOUT_MS) - .clamp(100, 10_000); - let bytes = block_on_ffi_result(move || async move { - let conn = connect(&opts).await?; - attach_session_exchange( - conn, - &id, - input.as_bytes(), - max_bytes, - Duration::from_millis(idle_timeout_ms), - ) - .await - })?; - let mut response = ok_map("attach_exchange"); - response.insert( - "text".to_string(), - serde_json::Value::String(String::from_utf8_lossy(&bytes).into_owned()), - ); - response.insert( - "bytes_base64".to_string(), - serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(&bytes)), - ); - response.insert( - "truncated".to_string(), - serde_json::Value::Bool(bytes.len() >= max_bytes), - ); - Ok(serde_json::Value::Object(response)) - } _ => Err(format!("unsupported operation: {operation}")), } } @@ -549,34 +422,6 @@ fn into_c_string(value: serde_json::Value) -> *mut c_char { mod tests { use super::*; - #[test] - fn keygen_returns_enrollment_url() { - let dir = tempfile::tempdir().unwrap(); - let key_path = - CString::new(dir.path().join("noise.key").to_string_lossy().as_ref()).unwrap(); - let cp_url = CString::new("https://cp.example.com").unwrap(); - let label = CString::new("ios phone").unwrap(); - - let value = keygen_response(key_path.as_ptr(), cp_url.as_ptr(), label.as_ptr()); - - assert_eq!(value["ok"], true); - assert!(value["pubkey_hex"].as_str().unwrap().len() == 64); - assert_eq!( - value["enrollment_url"], - "https://cp.example.com/admin/enroll?pubkey=".to_string() - + value["pubkey_hex"].as_str().unwrap() - + "&label=ios%20phone" - ); - } - - #[test] - fn keygen_rejects_missing_key_path() { - let value = keygen_response(std::ptr::null(), std::ptr::null(), std::ptr::null()); - - assert_eq!(value["ok"], false); - assert!(value["error"].as_str().unwrap().contains("key_path")); - } - #[test] fn agent_request_rejects_unknown_operation() { let request = CString::new(r#"{"operation":"bogus"}"#).unwrap(); @@ -610,7 +455,7 @@ mod tests { #[test] fn connection_options_requires_ita_key_when_verification_enabled() { let request: serde_json::Value = serde_json::json!({ - "operation": "recipes", + "operation": "replay_session", "agent_url": "https://agent.example.com", "key_path": "/tmp/noise.key", "insecure_skip_quote_verify": false From 793d45515e9ae837b3fab5b802f12fb0494d9db4 Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 16:23:57 -0400 Subject: [PATCH 12/18] Simplify mobile handoff surface --- Cargo.lock | 107 ------------------- README.md | 11 +- apps/ios/DevOpsDefender/DDClientBridge.swift | 53 ++++----- apps/ios/DevOpsDefender/DDClientFFI.h | 3 +- apps/ios/README.md | 21 +--- crates/dd-client-cli/src/main.rs | 55 +++------- crates/dd-client-core/Cargo.toml | 1 - crates/dd-client-core/src/lib.rs | 15 ++- crates/dd-client-ffi/src/lib.rs | 102 ++++++++---------- 9 files changed, 97 insertions(+), 271 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fe8073..0c069d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,15 +37,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -202,20 +193,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "cipher" version = "0.4.4" @@ -273,12 +250,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -357,7 +328,6 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", - "chrono", "futures-util", "hex", "jsonwebtoken", @@ -662,30 +632,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1892,65 +1838,12 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/README.md b/README.md index 5f20d41..e54e101 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,13 @@ Send a running session to the mobile companion app: dd-client mobile-link \ --url https://agent.example.com \ --key ~/.config/devopsdefender/noise.key \ - --id SESSION_ID \ - --include-key + --id SESSION_ID ``` Open the printed `devopsdefender://session?...` link on iOS, or render it as a -QR code with the printed `qrencode` command. `--include-key` puts the Noise -private key in the handoff URL so the mobile app can import it before loading -history and following the live transcript; treat that link or QR code as secret. -Omit `--include-key` to send only the agent URL and session id after the app -already has the key. +QR code with the printed `qrencode` command. The link includes the Noise private +key so the mobile app can import it before loading history and following the +live transcript; treat that link or QR code as secret. Quote verification is on by default. Local preview/dev runs without Intel Trust Authority credentials must pass `--insecure-skip-quote-verify` explicitly. diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index d4efe45..2cfd6e5 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -15,16 +15,16 @@ struct DDClientError: LocalizedError { enum DDClientBridge { static func importKey(keyPath: String, keyContent: String) throws { - _ = try request([ - "operation": "import_key", - "key_path": keyPath, - "key_content": keyContent - ]) + let responsePointer = keyPath.withCString { keyPathCString in + keyContent.withCString { keyContentCString in + dd_client_import_key(keyPathCString, keyContentCString) + } + } + _ = try decodeResponse(responsePointer) } static func transcriptHistory(id: String, settings: AgentSettings) throws -> [String: Any] { try request([ - "operation": "replay_session", "agent_url": settings.agentURL, "key_path": settings.keyPath, "insecure_skip_quote_verify": true, @@ -34,7 +34,7 @@ enum DDClientBridge { "ita_issuer": "", "id": id, "max_bytes": 32768 - ]) + ], using: dd_client_replay_session) } static func startAttachStream( @@ -50,8 +50,7 @@ enum DDClientBridge { "ita_base_url": "", "ita_jwks_url": "", "ita_issuer": "", - "id": id, - "tail": true + "id": id ] let data = try JSONSerialization.data(withJSONObject: payload) guard let requestJSON = String(data: data, encoding: .utf8) else { @@ -63,15 +62,22 @@ enum DDClientBridge { return stream } - private static func request(_ payload: [String: Any]) throws -> [String: Any] { + private static func request( + _ payload: [String: Any], + using call: (UnsafePointer?) -> UnsafeMutablePointer? + ) throws -> [String: Any] { let requestData = try JSONSerialization.data(withJSONObject: payload) guard let requestJSON = String(data: requestData, encoding: .utf8) else { throw DDClientError(message: "Failed to encode FFI request") } let responsePointer = requestJSON.withCString { requestCString in - dd_client_agent_request(requestCString) + call(requestCString) } + return try decodeResponse(responsePointer) + } + + private static func decodeResponse(_ responsePointer: UnsafeMutablePointer?) throws -> [String: Any] { guard let responsePointer else { throw DDClientError(message: "Rust FFI returned a null response") } @@ -209,16 +215,12 @@ final class ClientViewModel: ObservableObject { transcript = "" terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) - if let key = query["key"], !key.isEmpty { - importKeyAndLoadTranscript(key) + guard let key = query["key"], !key.isEmpty else { + status = "Mobile link missing key" return } - if FileManager.default.fileExists(atPath: keyPath.expandingTildePath) { - loadTranscript() - } else { - status = "Linked \(linkedSessionTitle); link did not include key" - } + importKeyAndLoadTranscript(key) } private func importKeyAndLoadTranscript(_ key: String) { @@ -234,21 +236,6 @@ final class ClientViewModel: ObservableObject { } } - private func loadTranscript() { - let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty else { - status = "No linked session" - return - } - let settings = AgentSettings( - agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), - keyPath: keyPath.expandingTildePath - ) - run("Loading transcript", startStreamAfterInitialLoad: true) { - initialTranscriptUpdate(id: id, settings: settings) - } - } - private func run( _ pendingStatus: String, startStreamAfterInitialLoad: Bool = false, diff --git a/apps/ios/DevOpsDefender/DDClientFFI.h b/apps/ios/DevOpsDefender/DDClientFFI.h index 6cd7afa..4db8997 100644 --- a/apps/ios/DevOpsDefender/DDClientFFI.h +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -9,7 +9,8 @@ extern "C" { typedef void (*dd_client_stream_callback)(uint64_t handle, const char *event_json, void *context); -char *dd_client_agent_request(const char *request_json); +char *dd_client_import_key(const char *key_path, const char *key_content); +char *dd_client_replay_session(const char *request_json); uint64_t dd_client_attach_stream_start(const char *request_json, dd_client_stream_callback callback, void *context); void dd_client_attach_stream_stop(uint64_t handle); void dd_client_string_free(char *value); diff --git a/apps/ios/README.md b/apps/ios/README.md index 3ca3788..7950b91 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -36,29 +36,18 @@ Generate the iOS link: cargo run -p dd-client -- mobile-link \ --url "$AGENT_URL" \ --key "$HOME/.config/devopsdefender/noise.key" \ - --id "$SESSION_ID" \ - --include-key + --id "$SESSION_ID" ``` Open the printed `devopsdefender://session?...` link on iOS, or render the QR -with the printed `qrencode` command. With `--include-key`, the link contains the -Noise private key and the app imports it before loading history and following -the transcript; treat the link or QR as secret. - -## Key Import Fallback - -Prefer `--include-key` so the link is self-contained. If you omit it, the app -can only connect after a key has already been imported into its Application -Support directory. - -```bash -xxd -p -c 256 "$HOME/.config/devopsdefender/noise.key" | pbcopy -``` +with the printed `qrencode` command. The link contains the Noise private key and +the app imports it before loading history and following the transcript; treat +the link or QR as secret. ## App Workflow - Open a `devopsdefender://session?...` link or scan its QR code. -- The app imports the embedded key when present. +- The app imports the embedded key. - The app loads recent transcript history, then keeps following live output. The app intentionally does not create sessions, list recipes, browse agents, diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 24e4bef..991a7b3 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -47,9 +47,7 @@ struct MobileLinkArgs { #[arg(long)] id: String, #[arg(long)] - key: Option, - #[arg(long)] - include_key: bool, + key: PathBuf, } #[derive(Args, Clone)] @@ -133,32 +131,15 @@ async fn main() -> anyhow::Result<()> { } async fn print_mobile_link(args: MobileLinkArgs) -> anyhow::Result<()> { - let key_hex = if args.include_key { - let key = args - .key - .as_deref() - .ok_or_else(|| anyhow!("--key is required with --include-key"))?; - Some(load_key_hex(key).await?) - } else { - None - }; - let link = mobile_session_url(&args.url, &args.id, key_hex.as_deref()); + let key_hex = load_key_hex(&args.key).await?; + let link = mobile_session_url(&args.url, &args.id, &key_hex); println!("{link}"); - if let Some(key) = args.key { - println!(); - println!("pubkey: {}", public_key_hex(&key).await?); - println!( - "key import: xxd -p -c 256 {} | pbcopy", - shell_quote_path(&key) - ); - } + println!(); + println!("pubkey: {}", public_key_hex(&args.key).await?); println!(); - if args.include_key { - println!(); - println!("warning: this link contains the Noise private key; treat the QR as secret"); - } + println!("warning: this link contains the Noise private key; treat the QR as secret"); println!(); println!("Open this link on iPhone, or make a QR with:"); @@ -166,17 +147,13 @@ async fn print_mobile_link(args: MobileLinkArgs) -> anyhow::Result<()> { Ok(()) } -fn mobile_session_url(agent_url: &str, session_id: &str, key_hex: Option<&str>) -> String { - let mut url = format!( - "devopsdefender://session?agent={}&id={}&skip_quote_verify=1", +fn mobile_session_url(agent_url: &str, session_id: &str, key_hex: &str) -> String { + format!( + "devopsdefender://session?agent={}&id={}&skip_quote_verify=1&key={}", percent_encode(agent_url), - percent_encode(session_id) - ); - if let Some(key_hex) = key_hex { - url.push_str("&key="); - url.push_str(&percent_encode(key_hex)); - } - url + percent_encode(session_id), + percent_encode(key_hex) + ) } async fn load_key_hex(path: &Path) -> anyhow::Result { @@ -202,14 +179,6 @@ fn percent_encode(value: &str) -> String { out } -fn shell_quote_path(path: &std::path::Path) -> String { - shell_quote(&path.to_string_lossy()) -} - -fn shell_quote(value: &str) -> String { - format!("'{}'", shell_escape_single(value)) -} - fn shell_escape_single(value: &str) -> String { value.replace('\'', "'\\''") } diff --git a/crates/dd-client-core/Cargo.toml b/crates/dd-client-core/Cargo.toml index 662657e..5933c41 100644 --- a/crates/dd-client-core/Cargo.toml +++ b/crates/dd-client-core/Cargo.toml @@ -8,7 +8,6 @@ repository.workspace = true [dependencies] anyhow = "1" base64 = "0.22" -chrono = { version = "0.4", features = ["serde"] } futures-util = "0.3" hex = "0.4" jsonwebtoken = "9" diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index 2c82d17..c1efa13 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -61,7 +61,7 @@ pub struct NoiseConnection { } impl NoiseConnection { - pub async fn call(&mut self, request: Value) -> anyhow::Result { + async fn call(&mut self, request: Value) -> anyhow::Result { let plain = serde_json::to_vec(&request)?; send_encrypted(&mut self.transport, &mut self.sink, &plain).await?; let cipher = next_binary(&mut self.stream) @@ -148,7 +148,7 @@ pub async fn replay_session( conn.call(request).await } -pub async fn resize_session( +async fn resize_session( conn: &mut NoiseConnection, id: &str, cols: u64, @@ -246,7 +246,6 @@ pub async fn attach_session( pub async fn attach_session_stream( mut conn: NoiseConnection, id: &str, - tail: bool, mut shutdown: watch::Receiver, mut on_open: impl FnMut() -> anyhow::Result<()> + Send, mut on_bytes: F, @@ -258,7 +257,7 @@ where .call(serde_json::json!({ "method": "shell.attach_session", "id": id, - "tail": tail, + "tail": true, })) .await?; if ack.get("error").is_some() { @@ -393,7 +392,7 @@ pub fn session_id(value: &Value) -> anyhow::Result { .ok_or_else(|| anyhow!("create response did not include a session id: {value}")) } -pub async fn load_or_create_key(path: &Path) -> anyhow::Result { +async fn load_or_create_key(path: &Path) -> anyhow::Result { match tokio::fs::read(path).await { Ok(bytes) if bytes.len() == 32 => { let mut key = [0u8; 32]; @@ -415,7 +414,7 @@ pub async fn public_key_hex(path: &Path) -> anyhow::Result { Ok(public_hex(&secret)) } -pub fn public_hex(secret: &StaticSecret) -> String { +fn public_hex(secret: &StaticSecret) -> String { hex::encode(PublicKey::from(secret).as_bytes()) } @@ -428,11 +427,11 @@ pub fn enrollment_url(cp_url: &str, pubkey_hex: &str, label: &str) -> String { ) } -pub fn health_url(base_url: &str) -> String { +fn health_url(base_url: &str) -> String { format!("{}/health", normalize_http_base(base_url)) } -pub fn noise_ws_url(base_url: &str) -> String { +fn noise_ws_url(base_url: &str) -> String { let base = normalize_http_base(base_url); let ws_base = if let Some(rest) = base.strip_prefix("https://") { format!("wss://{rest}") diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 0f2dae7..4be905d 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -30,9 +30,16 @@ static NEXT_STREAM_ID: AtomicU64 = AtomicU64::new(1); static STREAMS: OnceLock>> = OnceLock::new(); #[no_mangle] -pub extern "C" fn dd_client_agent_request(request_json: *const c_char) -> *mut c_char { - let result = agent_request_response(request_json); - into_c_string(result) +pub extern "C" fn dd_client_import_key( + key_path: *const c_char, + key_content: *const c_char, +) -> *mut c_char { + into_c_string(import_key_response(key_path, key_content)) +} + +#[no_mangle] +pub extern "C" fn dd_client_replay_session(request_json: *const c_char) -> *mut c_char { + into_c_string(replay_session_response(request_json)) } #[no_mangle] @@ -81,7 +88,6 @@ fn attach_stream_start( serde_json::from_str(&request_json).map_err(|e| format!("parse request_json: {e}"))?; let opts = connection_options_from_request(&request)?; let id = required_json_string(&request, "id")?; - let tail = bool_json_field(&request, "tail")?.unwrap_or(true); let (shutdown, shutdown_rx) = watch::channel(false); let handle = NEXT_STREAM_ID.fetch_add(1, Ordering::Relaxed); @@ -102,7 +108,6 @@ fn attach_stream_start( attach_session_stream( conn, &id, - tail, shutdown_rx, || { emit_stream_event( @@ -176,8 +181,8 @@ fn streams() -> &'static Mutex> { STREAMS.get_or_init(|| Mutex::new(HashMap::new())) } -fn agent_request_response(request_json: *const c_char) -> serde_json::Value { - match agent_request(request_json) { +fn import_key_response(key_path: *const c_char, key_content: *const c_char) -> serde_json::Value { + match import_key(key_path, key_content) { Ok(value) => value, Err(error) => serde_json::json!({ "ok": false, @@ -186,33 +191,12 @@ fn agent_request_response(request_json: *const c_char) -> serde_json::Value { } } -fn agent_request(request_json: *const c_char) -> Result { - let request_json = required_c_string(request_json, "request_json")?; - let request: serde_json::Value = - serde_json::from_str(&request_json).map_err(|e| format!("parse request_json: {e}"))?; - let operation = required_json_string(&request, "operation")?; - - match operation.as_str() { - "import_key" => import_key_request(&request), - "replay_session" => { - let opts = connection_options_from_request(&request)?; - let id = required_json_string(&request, "id")?; - let max_bytes = usize_json_field(&request, "max_bytes")? - .unwrap_or(DEFAULT_REPLAY_MAX_BYTES) - .min(MAX_REPLAY_BYTES); - let value = block_on_ffi_result(move || async move { - let mut conn = connect(&opts).await?; - replay_session(&mut conn, &id, Some(max_bytes)).await - })?; - Ok(ok_value("replay_session", value)) - } - _ => Err(format!("unsupported operation: {operation}")), - } -} - -fn import_key_request(request: &serde_json::Value) -> Result { - let key_path = required_json_string(request, "key_path")?; - let key_content = required_json_string(request, "key_content")?; +fn import_key( + key_path: *const c_char, + key_content: *const c_char, +) -> Result { + let key_path = required_c_string(key_path, "key_path")?; + let key_content = required_c_string(key_content, "key_content")?; let bytes = parse_key_content(&key_content)?; persist_key(Path::new(&key_path), &bytes)?; Ok(serde_json::json!({ @@ -222,6 +206,32 @@ fn import_key_request(request: &serde_json::Value) -> Result serde_json::Value { + match replay_session_request(request_json) { + Ok(value) => value, + Err(error) => serde_json::json!({ + "ok": false, + "error": error, + }), + } +} + +fn replay_session_request(request_json: *const c_char) -> Result { + let request_json = required_c_string(request_json, "request_json")?; + let request: serde_json::Value = + serde_json::from_str(&request_json).map_err(|e| format!("parse request_json: {e}"))?; + let opts = connection_options_from_request(&request)?; + let id = required_json_string(&request, "id")?; + let max_bytes = usize_json_field(&request, "max_bytes")? + .unwrap_or(DEFAULT_REPLAY_MAX_BYTES) + .min(MAX_REPLAY_BYTES); + let value = block_on_ffi_result(move || async move { + let mut conn = connect(&opts).await?; + replay_session(&mut conn, &id, Some(max_bytes)).await + })?; + Ok(ok_value("replay_session", value)) +} + fn connection_options_from_request( request: &serde_json::Value, ) -> Result { @@ -422,31 +432,14 @@ fn into_c_string(value: serde_json::Value) -> *mut c_char { mod tests { use super::*; - #[test] - fn agent_request_rejects_unknown_operation() { - let request = CString::new(r#"{"operation":"bogus"}"#).unwrap(); - - let value = agent_request_response(request.as_ptr()); - - assert_eq!(value["ok"], false); - assert!(value["error"] - .as_str() - .unwrap() - .contains("unsupported operation")); - } - #[test] fn import_key_accepts_hex_content() { let dir = tempfile::tempdir().unwrap(); let key_path = dir.path().join("noise.key"); - let request = CString::new(format!( - r#"{{"operation":"import_key","key_path":"{}","key_content":"{}"}}"#, - key_path.display(), - "07".repeat(32) - )) - .unwrap(); + let key_path_c = CString::new(key_path.to_string_lossy().as_ref()).unwrap(); + let key_content_c = CString::new("07".repeat(32)).unwrap(); - let value = agent_request_response(request.as_ptr()); + let value = import_key_response(key_path_c.as_ptr(), key_content_c.as_ptr()); assert_eq!(value["ok"], true); assert_eq!(std::fs::read(key_path).unwrap(), vec![7u8; 32]); @@ -455,7 +448,6 @@ mod tests { #[test] fn connection_options_requires_ita_key_when_verification_enabled() { let request: serde_json::Value = serde_json::json!({ - "operation": "replay_session", "agent_url": "https://agent.example.com", "key_path": "/tmp/noise.key", "insecure_skip_quote_verify": false From 1992afc418f392928e98a62d0f0f7d1def64749a Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 16:30:03 -0400 Subject: [PATCH 13/18] Add iOS CI and TestFlight upload workflow --- .github/workflows/ios.yml | 35 ++++++ .github/workflows/testflight.yml | 57 +++++++++ apps/ios/Config/Signing.xcconfig | 2 + .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 31872 bytes .../AppIcon.appiconset/AppIcon-120.png | Bin 0 -> 10827 bytes .../AppIcon.appiconset/AppIcon-152.png | Bin 0 -> 13000 bytes .../AppIcon.appiconset/AppIcon-167.png | Bin 0 -> 14136 bytes .../AppIcon.appiconset/AppIcon-180.png | Bin 0 -> 15242 bytes .../AppIcon.appiconset/AppIcon-20.png | Bin 0 -> 1970 bytes .../AppIcon.appiconset/AppIcon-29.png | Bin 0 -> 2890 bytes .../AppIcon.appiconset/AppIcon-40.png | Bin 0 -> 3914 bytes .../AppIcon.appiconset/AppIcon-58.png | Bin 0 -> 5403 bytes .../AppIcon.appiconset/AppIcon-60.png | Bin 0 -> 5667 bytes .../AppIcon.appiconset/AppIcon-76.png | Bin 0 -> 7203 bytes .../AppIcon.appiconset/AppIcon-80.png | Bin 0 -> 6447 bytes .../AppIcon.appiconset/AppIcon-87.png | Bin 0 -> 8043 bytes .../AppIcon.appiconset/Contents.json | 116 ++++++++++++++++++ .../Assets.xcassets/Contents.json | 6 + apps/ios/DevOpsDefender/Info.plist | 4 +- apps/ios/README.md | 19 +++ apps/ios/Scripts/archive-testflight.sh | 113 +++++++++++++++++ apps/ios/project.yml | 4 + 22 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ios.yml create mode 100644 .github/workflows/testflight.yml create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/ios/DevOpsDefender/Assets.xcassets/Contents.json create mode 100755 apps/ios/Scripts/archive-testflight.sh diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..3ea3074 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,35 @@ +name: iOS + +on: + pull_request: + push: + branches: [main] + +jobs: + simulator-build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios-sim,x86_64-apple-ios + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: apps/ios + run: xcodegen generate + + - name: Build iOS simulator app + working-directory: apps/ios + run: | + xcodebuild \ + -project DevOpsDefender.xcodeproj \ + -scheme DevOpsDefender \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + DD_PRODUCT_BUNDLE_IDENTIFIER=com.devopsdefender.client.ci \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 0000000..00a2c83 --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,57 @@ +name: TestFlight + +on: + workflow_dispatch: + inputs: + bundle_id: + description: App Store Connect bundle identifier + required: true + default: com.posix4e.devopsdefender.client + marketing_version: + description: CFBundleShortVersionString + required: true + default: "0.1" + internal_only: + description: Restrict uploaded build to internal TestFlight testing + required: true + type: boolean + default: true + +jobs: + upload: + runs-on: macos-latest + environment: testflight + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Write App Store Connect API key + env: + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + ASC_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} + run: | + test -n "$ASC_KEY_ID" + test -n "$ASC_PRIVATE_KEY" + mkdir -p "$RUNNER_TEMP/appstoreconnect" + KEY_PATH="$RUNNER_TEMP/appstoreconnect/AuthKey_${ASC_KEY_ID}.p8" + printf '%s' "$ASC_PRIVATE_KEY" | perl -pe 's/\\n/\n/g' > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "ASC_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + + - name: Archive and upload to TestFlight + env: + DD_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} + DD_BUNDLE_ID: ${{ inputs.bundle_id }} + DD_MARKETING_VERSION: ${{ inputs.marketing_version }} + DD_BUILD_NUMBER: ${{ github.run_number }} + DD_TESTFLIGHT_INTERNAL_ONLY: ${{ inputs.internal_only }} + APP_STORE_CONNECT_API_KEY_PATH: ${{ env.ASC_KEY_PATH }} + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: apps/ios/Scripts/archive-testflight.sh diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig index 61fe954..3b6c453 100644 --- a/apps/ios/Config/Signing.xcconfig +++ b/apps/ios/Config/Signing.xcconfig @@ -1,3 +1,5 @@ DD_PRODUCT_BUNDLE_IDENTIFIER = dev.devopsdefender.client.team$(DEVELOPMENT_TEAM) +DD_MARKETING_VERSION = 0.1 +DD_BUILD_NUMBER = 1 #include? "Signing.local.xcconfig" diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..443b26c6649bf8bcbb8807c18ba8a59f434e56f1 GIT binary patch literal 31872 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 YF=n*Wv!6U(>&gI1a%!>_(nf**3m|RaG5`Po literal 0 HcmV?d00001 diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c9afcb9a26e19e308985c980cee1a224641479 GIT binary patch literal 15242 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 RxKb5CQBF;^O2#bo{{j|xu-O0r literal 0 HcmV?d00001 diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png new file mode 100644 index 0000000000000000000000000000000000000000..a2ddd2d24a720268420af03ea5b80ea567da10e8 GIT binary patch literal 1970 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 literal 0 HcmV?d00001 diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png new file mode 100644 index 0000000000000000000000000000000000000000..df6131bf5db165366f6bdd405843d8173b7939aa GIT binary patch literal 3914 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 deSrN@D+nXu;iokQ|GrQGx>^RBv`}1% zQ}p%yUhHb5_|DGf`|JDppyxl*5 zSgGl10RRC{0f4Y50O01IC~OA+@D>CB_TT`3R3-pG<(AX>Qs&+7fg{VHbF)3<&XFN#Ht5E}t`uZZB*%I1XVrk*y20eQHUm1-e|N`Fcw zl@z`uh$@g^VC=GVL}}YiG7moev>a#~5_;ewUciZA8F6$gBU7}T)#58BEwY`{;#s7V zU!bg!I-U(tI0dkF(UEMfYU#igPE%3c5u02J;lM$?gTKpRPNKAgnazXPbpzda#*5QO zcP=IveNTPxFzBPmfl(yt&mg$TtMS9c;an6?cAf{sn1K$gS%<^arweEXtb`5IF%pOD zUSaC$9c9dPf^5#xNE`g?k>M+9lsH(07gnv3J4vk>)mTmzT=ijfY{}TVhGfM=aQH1p z;61Wby-?wJcK?D5k(>+>>sG7P`f`O6h(xd_)WIDcAIDE9sTrJK{2U9*d^fBHf~Drm z90dw8cZD`Ty0?$be2%HdIL8WzIt_bm7wq&oqH#oI-jukqNml!cTW6hnG8WhY6PTTX za%&_n5#Xwr@u#v8-@#Y%B~$dZ!{v&6_Xy#Rwl%gd0R4Sv?xe_`5J6GNJ0n5C*~Px_ zYZYGX9)NKBoYk*BWjL7dH1OaVE*8ssW+K6$h^)$(9T!x2MM)(5I_AVTcgl==NprdIzgt1UicFJj3R1%W8hqwlMUYb5yYZVO>jIz? z@nXQ@F@RT7rrS)smY@bvcbXshP&JR}rT9)`+m*E(aCJ2epKc1YzrtV-50R{^Q2jgkc6V|P-dUz2H4LS} z&mU7d|2P^p9!tv2O|!|oz&(b=@oaz>9Or$8wB~R$225&P4*THBQq;0Ki0Ei0yreNH z+vORfg>rK|!&K6XBBL^2F6KRM|ERf2%TuIh_xOH7Ql;{VM~w_|&L*S{7hJKnb{JDn zzfIyMmex!}|D!iuNgOLiq}_4TWZdO&lIHE3d#Yh&{1I2m8>-_6F~duJzg`aBh+b|q zE@H@?C{l*^c=!cLHm#h*jWYVMVM7hjVOlfoDUcs)Qqyx6>$A>A*-M{9XnCV(7x}(! zLL8Dca|d;c_b%3r`Lcxn3Tcz>)k#>rxH~nf&kB!Qz+*n|{WyGqyXA`*lG^ZoGZogx z{F9piyg#%h*ftMf{>t!(UxBCM`9>^|ey1ZK@bB@kh|&d2O|$Y!WsqPEFo5UeGflkLh_xQ{;Kt{VCoW z^M>zH{?~)ISkz35*^Af*>&!2rk1#8mTE?dMuV&1fyYE<21d2AU+J_5It9jSQ?u^Z@ zzQ0}8CC2Pe9T2zp;C8_WW71~?kzN*9)IJZx;g@ds4ZCmnPp;6};@od~z?HE$W0jIdvIn0m`9geFAyb zXlB#iH^bk#&AH<{<7SF&nUN9LcCBMZm~DD??wTf+qQa!={1(1SerYwp{iky98nREIM(1zD zIO=XnTOx^LIT+jrMo3uKHbbq;MZIx~-lrzVhMM0;`&POBjGHc!%vE^cc>XsTTKUIB z)5I@qww;w26RGYf2|TdI*vAF$BYT-fv@N(kDX6H~hX8lE&NjumwQi7?$QWMXaP3KR z6)eIcR^pU>gxL|*-v?)rhh@U;TH4|w69GJwcsBBt7&GhNZ|C(*;Y;u(R~gV!4~5y~ zGTbKQs<(@pED7P za{KPl_S?%WU6g#|>oP$#Ga0Lk*xqiNU=cnDOOf5%kssYQa<-)K^%jamcZ8>4kcbi) zu_s;gY#z<_0kL$*gUi+sQ#b9Vw#0@4H_c43_KFy*z*VHZ=zvn;hdUhGW7sRNI}zh4 zi*t@u28^$DZO-ukY2`-;HUf{N>*t>0Hm6cMNwXERJgA#Yx|uf+tN49(*5gk}S9C;L zCd~?RqI<&_Yg!!|OqlznfgQHQlFt2bC>JrQ#*!}4CVpXLYRma4e`=`5aD$P$yZ)vF zqTC}!^bX|sSmRku_lv1ut^M3xPI&MeuzBD{W1l@ous2Mw*MppAxG(x6FNAy!`Pv|) z7nhv^mm>$Q(JVmBsM!s}p~(sTKoY?qO#)*hHF0m#1t)YX;xc2OE8UkpfTvaWK;F1< zyM5RjK*Z6cnFD}EuIDe4XKB;dnU4;s4>;%20R`FMzIzqALIO2MduA;Ra&mHXptR=L zl>FXzR9{64H*iyE*F_Y|s`Mn)3s@Hvap@ZvWu$XM*8z3KnbBXadKc!7oS5VUPHF_G z8+FHnUL@R};`d%*yyQqVH30r9gVG2l$MyCJK@xf?diP;-Z}E+N$9?x!P%0J2(`JI& zv~S{%q1v!mCpO49rg}JOCS$SuIC*#V!Tv$b^Nk_GDVHD`TCwWY>QO8bA=Y+{>unUr zndEBnvioFi^%Bvpku;S}QO}Fw48zw#4Cx*9r-SA;VHZ_mUvC(TavRc~pi1^%1@O=3 zHC~`+qCO>vmbzQH+j%r38A#9+Y$;#H9pD9k&4qFgl9U5Hs2G?xsz;(mn9=7azX|7L z@IGFQbyYcGZQ-8Ddddt~og$W=Lo;8!UB@vs$(Wma{%hRPtXdRrk;s8^(TRg%U(d-S zDHh6GOGfw>q40D(^W8LXO7vrvx5d;rpT?KOu!X|yXue9I{f|lEDOM=>gXvOO{}l!| ze6y>2sre-AL33=POR;U=b$8K{lED8q%j!i#c;@>Zjsiv;*?3nBK^Q7HVc!=0FF8`7 zQc-AIcW!H>29CMXw|ZQYW}3RJ+(n!A*r?|iqttiy&kx62&Fe$r8N-d_n1l;fzmiB) z=&0w9DU7Z05OhCR_Q3HisU?C+O~oIMTU=G`zcGl(4_wU@FBb}$f(MjI4np_`$s|Z( zu|TD~lqJciA=$J(0~s9F%MLSJHTlxJH@)rPm)+ufXgW3a_;iHqVf8vECfm#GALC_j zVi=p2@F}Nw+F$b1uEbH-I(r5F3350RCs$}4V(@qq;=9E(=xg4}SP-+@#H`4&Lh#}p z(TAoBM{hs@l~P7rIYFMe=CBi?p};)IAYx`AVDL3KkJ2XHU*wyt zk)6W<)b7#n=Q3X#bjQ9QTUBPqtTpinl>d?b#WRy}i}5PQ*I%NVk>Ffz(J9|xPqfr4 zf!7)e(WOc=4En$sT|z)bli1`UZeHVO;h)g+j}x~AqtF=ICL$+qYL-3aI|@dO0CWhf z3wAkBQZwc4&C~pM_cH@s5IAk_>9=q4M4vO~K1Fs{TKb6hM+7|AGKx`Ws5(IlcsypN9wLMvp;M7B!W>FkOF;^IgYn7O z4)NNvB`CLk#X`$GfNBP!LUAkN=xYkWJH%&Z@mmVcX&xWl0}>s_3+-T>qU!0SMN~kk z*`B=`OwR49XB~Zf{=m$I-yJX>Fhon_QPGfQh_c=JE=6NWZi$TBCCtaCPn68*2E}cV zW9m6dejo?Bi~RaR5yG(Z+;unHche`5AoXFADuJDz&gaf${^VrzN!wOrMO=mbtTe=Y zm`WJ0LF1>l`+GZzPX0<-rLhm(RQ}{UpS&EGCA*|g?*94xog?L{W$q6rR>@mmYCIJ# zR_snk5!;dW^>Lw=vphzmLJ3u?%v`-VZ)Tg|?~;seze&GtJCL!larcuiwOBPXR5+Z6 zrZTrxBUPQNZ;UwwZ)k$!ep)Dej7}6Fiqt1ypYm(Mbx~VWF~ea12}YT>DV@P zhHpwAcb-3Bpe-wP4NXy`@ukFU^(nG`@fgbCSBzW#9>a+gyxR)QoI6CV=&(*>8~-w+ z?Qnt|Fw{x7tahM1*Q&|dVtU4g(`#NCfQ^U7Dk1Pj|8~bVd9SF)z^=yaCY2_1nlpN& z!=$F2(noy3+s#*XUfS99ESS||u(cSJ?hBYPBGj)TblcpR#g?uFb9thWeZTYmLi6qjMf`of6@pQt+_Rjy2+bszu$-!Fo|9uE zw2kjHG$~Y$U^SAiANy&9wjq|c@PNsN;a5TrtlPho=Uy|07XYHu{1BWli&Ja!sI?(%D2EUWkKX%pDzV8c4vY3ZBF04(a~wsM%&?3c-Md2*rIiqRF55a;6JX{^Ge zi3vZNbL@`nDU|en+c^i6)PZcf?b0vAcQjhhO$F_F@j-2oC#{YfJ8P2`^b zS+HwcRBO0{>I+OfW;-{WF#z67Y` zDwEhN-;tfjk6-QHUa(5;3Gw$FUlAuivSx;YPbF!KX|$Rd{lY0LvC=-vl#?tybP>@s zRY&Y4>OVFv7Q%BxiDH=Ojf(mO-^4yumBW(Nd&F`*r<19NqZW0vgHOpJ~2fP1#|yUBSHI==9bH_X;xmpzEefXIMhlJyt6j$lS9$O z7h|AfIG>)VZdaK9`B98V7?!*ALr$k+=IK7QexAXuaj($z=Fh}Wafdi10my*AUe2Z0 zdO+VhT6D=G@gft3O?~)AH{tZ!YZS}Bc>7866=Jn-C^Y0>m$&@yZa%geXjm4py2@K`}EiW~fF zE&hy%#AJoFKc%F`+p<5?IO8mGhVdl3fUG*2HwBIR&qZa)4-OBS7WaF_oU6GEC9$bB zWE!E)F9T=jP#imn7bOtAw}|QJbBQSp>E*SMCC?gLN;ir79qc#@ikgVgX>n8f(J!)e z$$Nk!3)VN#l3lM!1xvPfWI8_XS^mXLVII5Ig+sWdx_jty{MOIag>(@@L)4b%m~*9- zoL44pN27icR?LFRb(;n#9Obh%p2xT#s?2uwnt}SYAn8KOK9kHJ?KAku@cndsS41<2 zWd*k>`Umiymr_y_uJpcyKCG&YZ^b68A>Fd_UBjh)OwKF8?R$SD?=#$2S+73T-9(bw z^GftVX9sy>0`W$Uq880199@4Lk}N;Hxf!5EF-<3KY+b2*^+A9C$vux<^rZI+ zF(Gg!WF$ILEmt!Y(YRLm>rUn?U?ZfkeuCsZTZG#hyT5i`MD&RUi{TwkQ6c-7rMG%?^SvWEZ;2wrNvB-9&Na=NNay5K z@EmwNesq#C=VWw)X2W8A`g?yy2f-N~-|Gf_Xuwe5spxoy>JCuYKxNodFmS&8(-XS^ z+T)OsdrHx+rV|0eyqE!V))LhmOu21G*(sw$oYo0tDX2~DNLp4d+`Y+q!$>T$#ry$% zQBnqJ`%TV;q4d{m^iME}lUV$(KKprL@BO&V=<%B4wN#6%=P{E_NA{BRvC$N=PbAX2 z#aSNHcU7iKh%!rlc+AeBg&aJy4s$K`l4#nt2qG(E>Q_8|cH3E+2oP`ja^xZL<=6jI z%2gAG!)4%im`O8WE&kVFC(45kdL$;D@xWZhIN53l_p&+hY)#mZer>tR%dlg-oJ-1lBA1k2y#W}xG(>yN&5*+k8*lKwpy z>Pz`l`LlVBMbLao_m1VX1en~7ILruM`;7#PTZ7UN2Jg$#`eD@LMqLs&192U86prd1 zW(m1#Q%vy4NrB&6G8j(4ztPh4si28#eQPN}>N+`=*eauT1@AErCoEdoG6k|cH9>1h zB6U=8w7O<2WA@v>i^v-7iDt3)@jahBlb0d)yi?YRtO_>7N05;Td!_T5JFhOLpItq0MH``_J0gTk`rZhWUFSVc>Gi~&(~A;g z*iJ0y)dB1~Z&Omf zU1Ah3B#-e6b#DdC>G=YaYSM0iy6M8Hm}RVuTo)NesaK}H7T4M|lW)%F0`Q*wV&Q)0 zjbYWny2J>g{~bc~XpJXZ*R`0kQKDR`#5G=HyzU^?3@Q#ymL#OE6tN>P;e+X8oc>*E zjNn0s;w4&LocE}klHyW*`h?<>d6nPa8U=Zq zs=6(VnVsH`R0f?NF#Q*7M8FCy0VL;C58vpEf_BCA-rl{V$7;S(Mu2biOE$U>@@oy&pE!m8I=iwd*d4SF>45dkYV zQ2+R358b7!Cb83FA#7l}uiuQo>Q~F zyOa^I9)k8d&PO|?atl+lIM)Wpx&Urb2-yUi?$=wwf9sZX5fM;>y{RXaJ~py1*xg2@ z^ftaxp=`=%XEPwm#SLtC!9rA>pL&txi^$fws#Svz*kl$se|<@nEn>s@3v4S!QOM%x z1R!1pCzsdyca!GspuxU-$^02tLyLO3aDobj2U8`z%wH&aL#%xmDnolbCfu#ye~yaY z5y8ys+#R!Q(QQu`$^z>}DsfusPLNOU@mED?D%rOHzGCB37i#y|E!y13TjfpxWmliN zlf6AIM^xm1ej%k`Q1!n=g4o{!0}HPy0YYTL(g){9GQ9eMC6u3 zdke2}F>k*YnYR1gjx|xIQggAe|Id%LF65(Z%zRG((%!cFNrMD(#n&+S!*16o;ae)11FI!Ic5DOPB$^JEZ1Le@+fCw`}U129w6{tScIb-!8~` zn8BPY<7g93`mg978jQn7w@Wo3H}Gc)7#O_`O*I-G#u8aUl9cUe}@1!PbdF?2cACvoAYZS zO&0)Qq0v!SGYgs8%zJd-%>1%zZcafGFYY1qiZU(**8~@0Lzh@(J3D1HSYN#F<287F zmepW`HnO(PnygMY%0eigq*=Q;H!WtgHou@GD`={vkvz`*m<@rfd@cMhc%@_u49JyLb*gYcbNb{e zd64XO^HFXSXmZv^=7`sK0*b}J920_o4TfZ}6*-(F4xT{`C-Uf81D!>VLaMXJ1Xa~A zAU|0sx>46cPc^jnd{@eKPrqH?MRncdxws7E`@^XRc3^9ndsTr@z8*_Iy4v{{WBxL4 zI~{PpNt`QA%|R^FW1F2^;%`HPBS0)YC(QjI<6D|hk%%$~x}t@%DE&S{28={sBgGW3 zMfsa7`Kms_0@s-9?GTVG#Pk==zM6Yfb9YhXhn`-Z&=Mm=LiilOvw4-v)HXTT@pTLk z(VU$FU?;>djW@4(e{(X}`OqhJ@x~ux@D?C#7e_lCCzc#tO*JRZWdE3G3`-!~q`NpO>P&0~*IVVYW!tr{^1IgA}!) zRSl~}k07;~f#I5a_J1e?58h#yyw-0t z@7RgP`jM~cE;N&lq8Q5)G3Ou7(&bd5XH-6g^^>5;Yjy;j!oKJ&40nkCmCJW8tEUPe zhIsud)-FI&PJA=6`!$C8vcz-4S)m8+?^sHL-DW|I z&KmfB!C6Q|^-v7Qle@5s*2{gdpc&Hq)0fhhUHF)x?@>l(ID52Q@B5)(k+OF5bv^fgqOo~aKP$|`9_y`A5pn*j7m33d{}4Yu!m zM5}kilOHAStl8rD#d@OQD(+j_BFs7RjF%Bb6FRqMye@ufsh$pvb_6J_l=nyUd%^G0 ziH)BfqtjG6H?1oX_atnr+x*mo_$NbOu0vozX@e&fSXu^#ox3ZWv^#bS=I@pX5{js& z(zFu=vu1zaOy|6|ttT6_i0vlME82>G$p^dTAivK(=FYWA^E2wCO~&qKDeCm>2@~p3 z@H_R2o)WI(StV8_QTb~>w>&H_D$?vR)jYsM>#8wJUlC zu=$=-e_fJlt=@AFH7aLZH5##|2hZls-P=z+W`FR@iz(W5x2g?59H^U_mb&pnG`HdK z1M?fyLUW_Pe6)V5{H|%%R&xr6V_M%!$H7@~-fS8GwG>^ieZnr~V0xZO3XczCYAqYF8V^Tn<7VmiW9;}3squ-bMz(d+E7T7s}^;x=f3YVz+PBw}fhU%QHhcr$A$DtxftexL3hCsz~*M+2wEF zEcCQRG}7-}#P!5{D=uaRHM`k}FXgP&im3i!7UHnMnsWzvx804bTsWq&!%atKZuK1UA0Yhe5Lhl@>Mefc=U}&9SR! zy@d}AYsmxT9DSH5RKCxq%TOF?p1;@v64XwRH_pjbrLAOcCM)YagO3qcdN8U?F=$PAo(IYTo(#d~e~d;wRz1jkPEO zXiZt#ECIqj)O0U_G#{0cP5sLDk(*b5~tE<|lQD!EmG&-#${sl!~{`5D6;`>CO^`6R0#0`OPGc2bS^TB$E-;l9GGO2E6)muDi1Hf)nKh|rvs+ARaomGX` zL{@rY!irm=dBxll1JnLjF@45AA~Af<4$Hs}jk91dAmVv+WrKWd6iU#7ExJ(Jzjx(FgH0ZWaT?2$?Y3arf zGju$_O^Z1YOyUHcWe}&GDyp*^^aHSPX-bYkEyWrc2km7x2mKfKqc(@Iwh{j z9e(Q6QRtGXioZDf!lY4VwWGCI`gMb+CD#i}q#H0plPjg2m^Du-iToJN^Uwb5xv|1V z(yQ!Qpnmb?zOW1ph0{%$YBS;>Z zI)4*39&APF|B|<2YutF|%^CA}=G2$)c`_NT6Ct{Z7_>&;vIfHwQj4PFQ^Ja%M~lZI z@ApZ9_FhcUpdpo3;Ea4G&TqE|y?#u&S)eFwCdsWM$QgxmgK|Ia=~EsBbbhWCvyXW^ zVE6X%7>A$T_sNuLPRAsVOc?%VMlDC*NME#p0e<+ihm08L7ECLhBb`nlDB+bGHS-hljRm8)Jm)^$`JwWot>JBKt%YDFacVgZNxm^vhbFtC# zb53I8<46_8KE1xK??!~80+$rd8fb@%@~C(BLt`eVyG$tI=}CxCf^8AVV2Dg~#L#H+ z>KbzZFRmdyCg!(yItopw)$CknOU`)l;aU9lgf}sFf;q^aMCCo1WA4Nh5ANnUiHK*8 zE$K3PYbko-uWh#dI2PfMeZ1Y;F%UJ>&*CKoJgq#qcOd8E(?`JHk;4TL-(RENFRA=u zfBRI?X}n|f71t4s?-K+|FPMrn-z7znoX&vC5>)9_ECP{`)!^f`3xxdD-Z>mLCoNS~ z&NMnEYl^K7Abq6zH!}?TSVAKol3x5?)`om@FU^+5|Cnr$F^)E8VmtWUoFiL+TKKdX z^!<;n?z$i4#PiD`z6M%+sWrQN((2lTJr>vK&#a^;v=nbEukzpPC7A&-$S%3U+r-TLRq zIe=`{@qap{ZgQ*`w@H2NT=*_|5^Drd0_N$jv*OY~Y%H4>=?0J?ih&R4%xD5ZKuT{lm9 zn_%Ux&R$7y_41H9z})B88D?bYEn&m@r}=-BWM=6JCx`0b5FZGug`2SD zu6ONU;5&GXX=L%%LCqVT=AUM=brY_^ZLB&>x-Z}^;a;GP)uyd;uq3=8a?fk%FzTg8 zxJx*B-x4=dRP}1M9pzE{(gu*>+XReGC~(nVJW%|JYZ%vNX)1I;AH%II=*>w(>Y0C# z5)V{f0<%(M=8$1k7T2DVOe8*08M>EkuVImRC)|HiQ51Fm#!HFUBg^<*e% zTD3*ba+RdyV~MLNABRV9nNx32cMDQ@Cl^xmK_pEJ$_`A2bwL@{abO zx)B+|2Fx3FGVd(g@+)L{R7`cc>bzhRdNPau zfZ{?bX`z}8&z7q_fM5H(rqH#}O~^oXoG2Pfw?U~m@yCawZT7e4r$>}_MF&-T}h}?v~Q|~Y2;^E4c_=M<-Vh;R1J?eA+#iys1LE! z>R@xR`vlfhNd>iR-t$D$B%n$KA_m0wzNuVa>5~%>2+wf@_`-=&tv{f3mUX34_K`LK zuV0ppGi3?w} zS=&v}^8EOrXyIJ1J;}JTbG=POg~c6J{dRVD4YhfGSXn#l6&GyL1)gcjD1QqXyI@-5 z_rg$aoNz2-x*)N3duaI)_KB(L$+s?vH+47iJB9IRO_(&YTzlJ|{lM6Vfrm5!_DLFa z8;N{WHEV+}-M1V78t%Tc#H-3r^MF{YgUV)oLROx`Y*e0J2P=EjFQ^%_o&*JwG~Hf6 ze|}(W2egn}1J>JVU_3pn8s`bLX90_ZmhWl`Xgkkruxz*aA&~KUl;q@B`Q)0q$*Biq z*M~)riY)rxCrtO&qPcHgMx4sPBUCj*PP{@OR-4_jI++k-mcWlL`ScVL{fWJ-AnYW} zWL=8Xxtt{kOqxiM=ZfNvN8yZN59JrFj4u3wU@KKGLS@H+L|1NGU`RrU(HyJ>PSRyt>dZKO#wgZ$rXaukHPja*jqe|zF2F5%o_=)!Ko2OVJwa}2U+HW2TE$lswV6qn?5G$T-(pt3g|HFJ=NXF zp{w_-Ay5wGVcE$36w|=*08(5NYbmfL+;~Ci=$8vdc_3q1vhcuRq221HUH&5LD-n*W za@O`W0G+#5p#LvnNh!jz%}mZYd>cZ+zrDrJ?xK7sAed_t# zjW~f5Qde!Nue0V$hJNj%91MeL2Rzu113Q{SRVb46_PhbCR0`AZyEBc9Pf#fb0(2B4 zZ9YTuZ!Jx&-c)Oh3XZWJCGO|#nyelz7{SY2%s9=NIl4B&D3DQXB@f54hA+j$7-!RX zsut1Btht*nm>8v@%lQQppIxxBDJ-f>t*3U1(Wi0^#r(n83(W7^Lm%d?jMMH-u*JRM zZWpo({~G*tvP^@ej3?bighb((ytxCv;@(#J8d%Mf@4ij3vs--BEX@-eVqBTv)k%X= z$$P9}NLNPRY1IKJ=OyyKU(oE$OC$>a1g?6d7fawv69Uy#9kx%k@cr{a|A<#W)}n37 z$62O%7oQm@YnX`7NQh6et2Hr7%3?gHiW7Gzyt?y1xyuY)f(Osfg}gR~4l5^t@^gBpUwLuI7|*MqGuWU(muG+F=y5*2e{qx+;&xJ^E5Tsfl>sumE}VA{_ZDVg`mG143;UFu&N*3!_uXsouTbX{*o+& zmXSx?Wj$+5V`PIX-RAHe(VB+mPb^I~g|>i~aW^S?py?F{Wws!VcFZO)>ZC3ZylEkqJWFNpZ7rfyN!Q#FFPBrmr}MKFaHIAFkD!SA1=x-1UC?d zO9_ih2?@jCa49&vyNK-3{{wjKYUgPG=Kl|;g9Y^<|@@I;gwh*ll%2bnGpgh*U2_or7CY`>0& zme?&RhQ{qL2Mv6Gv2`ltiZCbF7H`1hJBW+V1Sn5@SYhP$c8eoPL^pC&tOhE9bteam z)rXO@#`U;mfM>CY3kDv+8pqWpqrgwVg839Szi^0~0cQmyy}xM_f{-frcv@KM6#fyc zq!%sBN|zT~4cLB5?Ow&QxJooJW5;N*!c;it6k|ptY*nj9v_7NtXqD&p{g2%yUcVC*8cW!C~NOd2C&$tu!!LHyQ&Cb>7^xnBLZH&sLh;7-kB|*P2|ms0 z)eL?KQkEs`d-wXwEHL)r#FwdrMgNgE*7lNaL5V@_R3yB!=R@`P@)|`_Vpl#gPh@Pt zEZ8RSC5mGU@!`tpGiDKCH7>fEg&<8GGp9*Cd82oRQlP8JpTewvkh}~Geq|ww_~#&W z1cB1?tV%gU=rH|gRS~9-pl)Vtrhz%jS(v;)vg4={HXum@k22hrqx?$?te0g!IH4*7 zWaK5{Cx0fc3vFmP0bU&;q^X#oMikYmH+hbbilk2$R5%8F^=E=`Q_w~7$9J({Y41Bi&7I|kMXtG>o-n+Xbp_%zKMl-TC66IGA@>+PYK?@ z5RgxBS|YBj&lkplw~z_fPG;kPG5ZSAiXiH0s%@l#Rcu9Qo}*>WulDCYRlvP^DCMia z-GYlIE^N#OA%=#WXj|<|&?70kaxaly6QD^2m1db%w#s3rKDX*_UcYPSr4~AiN{n?f zgzNTJrjyMlY#@GF4_LB?A4eQSaV)xOlgDejE=oZC`TtYm?M zi0JfFOj`&=Rai|C(*01qof^s@Wiw4`l40#cjwKl}|#@6Lz} zYt_~f!s4F=Pmr&9#Luj_Y`CAKG@dd<3Z}KQd>mViVi8q*?2{iR(^j}Q7vQU$vWW!+PQOp&HTeq3@- z6eRv+jHlhm;|GkuRuxlI7hAh=K8`$X5^Vl%1O}G#e!Np^$S&KE!kI+pIS?%UO5RLPrr{slL9 z5X;rTlYTb|*NKm&n23i>t&-oP2*m};rnP)dew^gr zyR53Y?iJ)|#eM0w{z_h`>_oFp#)r+=luz-MQWm=;S63Rl%G2X0JbU8ag_IL>w8NvM zhtm^NN+IF$epum+KZ5A$1Pb`#i|q~-?Kbi*8Qyb~^C&A8;Ssn*w>1nws`FXcQBaYB$5#4(p_flxAr8hzyyXfi(6E9+ z=hQ2DHxZq=Pm)Y_Ew`9vP|>H4u%v@qz^rl58DN5<%~&3=Qa!E-BboX#65R6qR@6^m zvY+;X)k|dYVbrU3E&3suF#);Y#WP+dPuR;2NA(6+)&1?_`kb4~llU96XSKGRswx=* zARvtxpRwPbj9t%cJsoB1)K|?hXg@^aXtDMl#d!FNaAZ^V24I}grW+j$g6@}i;ak`d zG!ugX4@m40>{p}iJ>=RtzF7p_T~W3puHcP_RMezO&l9NI$;{-um(zA9A1N^OJQ0@1 z5Ojy=*}wDxc2}+tZ?vEqWPWP1B6w+OJwTKj?O!U*plDjEq&$!qFZB;H>qgJV<5_Q5 ze!nGq+*6#$b_BF*I(sY^y82YMAcc--HG*VZ2WW+2Vr|F$*C(8}xLxcfIglJJh`y)(NP?>TGv^vPx5;z9(;p6C0zhc0Y_b2JWc5P8B2G&lCs= zoHK1sq82sMGSKhvn>(MJTr7@*F!bua={4TXzn|pL>Y?Kv;hD2!Xz9)Epiz@?NbjIu z^L)ArMRI2C;;>YHr<|w;#F{ET<_%Lfa-joOW?0)%A%3BM8Q(n(cv<(QY7?ya@^dsn zY1O-IdiWlHEXnu{^NYFX=A?9Sox8gUvrRPp#f-a@twBNP zO&YS;r>JU^Z1wYM%j!h#-&%j9(_X}MsZ--}H~Mv0>H8fSxbdLzQwzPl7e6Vc${(h~ zyyd!^=rPDOj04w~H zFpok+d=pNI6g!vU;*X4ZVlB-4D_;c_+@iKLg^-8F>%aD`|c zD7})SA#lfXitm%SvvlBxQn?^x$RJ;1PiF80SpsjOe7#inw_{}8q#fvY(6v;faZV{_ zpv##{8h@Jg03^1Cf+_6Q`gm&kMxQ8~nVQ-HtePJ&iTXaULMOvSDSYvE@@kbcHOkxqPsBbjj75{XsT}T{Y^8)!J#7&S zKuGh9jo43S+DH{9P_DRX@|%DI;&JTNWCM_ec_hxq+x%Y(yu~uBJNHUGA{#n z8jKW0+3Bf*KhS8978Z;hZHf}{QXOYMp}pid4qOdA%X#HBi_z0TZGk7$d>?pNAB5C# z^dS>zGigVf)}4Q!HDENDEx=na*Fd`{GU_-h)kdBi!z8`%-a8WnH?oVtw$Y3{|FMQP z>;OV*O5@G@LYM*BT#IR2wyO-}uFcr6&_a+PIK@Bb9!RNkZpj73Eex{vY{X3SdwTRhIM z`(No=7nYCtHegG2*@{GzvWUu==<#0qroO2#0Pja155Q~j1T5<1iqs9ikzPIv3YUGL zIippqso!oeOIyN?_Da24vA#7&BT;K0EHRI1zMt8556b0t+OR{?LcwU|uGP%B8DpJL zIstBpUdP`78)V+%Jwj8ktx#G$HPD5HY|^86(I?LOMs(*cznY0j_QEOpwcuJVkeSw* z)798p)(3gnvt-nK^7z2asUJSSDb_V=>f{29t;U(Vq%)*QINTHL;4!~hhBeRwNv46M zb_vS#Jvx;Z^zvOR(SwXyHm9hDg0->G101(-zQ8t9%U0`K-T0})ngLd%M{l_kOj@2| z1iKL>C#6pEjhG{`CtL#)AV?^IZ$?sDX^)~WS9V+reX#vPrwkEe_3Y^~r^(UI^vkWN z_IkpnuS7Q#O)hsT^YL|KfKMyV1gkEI zdBEcaJ>Ti-Dq<}CT0p%G3tKeD&!+(2{$i}U9lNMk?5lBzg5z{`Wp=49cv+elZPUBDl$53w*P1rh9m@;s$<8(+XL zgq7$|TfmretI{?*6Ar(ELOK$qj{h83t!?I9O3yLdxVAXY{dc4L(`!jLF{TH;uTBp5 zbvcT9b{`7Cy%)RhcVXY!-~U+VFbk0RxaMt3=$1v3g?Cz^a}(iPUapW%qvxhF+a}Mi zOJYiTl}%`WZ3L~v^gutQ5PWt2O7#`v7C3vbP7|{jm~e#UtOYo-^XFM%%=%ysNtMMs zlW7fiN1&9BKOF~Ip5XPElTCjfCPsge^GQp$T-V{HD3IJz{^#VLwzX8N!}#h*nhx%H zSy8&7*e7Yh!Q$ylTHn7$#WxF2eY_TB!uXyKeX*6Bl~_^0MDe@Pn*H^_9Ay!He)o{t z4+E6Z+U>b`ueqwI+g1_by;47MIPgf@9m=C(x+Hh0@zft=RAM5>)vv?|+C&E1lS%H@ z1a|K4GW-8sDK4t}GG~vtcMjt|?-Pr^MW92!N9(fy7lh-&SGbJMp2r=~7J9bI zg+Cq5G**DH;rnT{e3=K;81N%G=Ud9XbSaAGi=M`>4u0!7+bJ1L{|+#p?DNqL8%2oH zmRpPCk6t-NLn-(W31*tP8ph3f#HDVyM{o3)!|Ipa=ayb{%ytGANRnD;S)4UQC=SpF zrLOns<|!L^*s<$&k25+n)%~vY z?%MdRXfp**fpGGZJB;~`A>kO~JF@rL%#T?fLRIp@eeot_jVqh*@VUWv46{_~-uyKM zE5y1nj_O;PbLJO19a@zKkZ@ju&oY!l_6nh1)*t7aFc6IE-+2{jj@c1Vv4{Fu$3vk*IGZwsSj zTRzzfEt14mMd{uK;6m>Vt+Px%0Nvj{r-nz!P`SAw$1V|;`=M{KHsvKJR^Enhg$=E~Quk*prlQP<| zi+Gu~aBY+|0#DI2oXmieuG}T;cX008KwyjX3Ga9deId>XoY4MiRhS~$>Y2`RfVJ$d zBTOugx9lVPEqPc>>mB>Gxr~!Hroz})PjP}-)qT5`<7-@79r@!sHXmsbb^_i z>U6Hz7f_dKr7uL`iFJ&SF8gI;tdZ_F-WZWZ?MKR{@F8eb3~!Et0iWSPL@d(ca5kSu z&0RNWOy7x*p=tEB6J5neq#ymL`bp9yPdgMc$sQTa3KZE+W~xs&V&rc5pX7_q+d*N4 z4~x=yNp=-?m2}{KOEPu-(`e)eLG;xGW{&(cu6QlLO_dtGQao%aCITa{_-)H^ zcLT^A$33Y}my9Jii(B(IlTD;7KnzsSov- zNl2{hn|C*M7-u4lF?YEy_6grS z^>vsxL&Zc>`axx}T_=Q{cD;~UH?Cuz_^S57HIsb{9NC42AgYC!#kse>cuT1O=O|aZ zVDsi4C_KW2HhgnyHD7AX!{oH&wy;Kq_MqLkE>rXo;V@q{4M#l8CB_~@4$r@;?h#x@ z+(j>}GVPL_ST~e9mhK>2ZJyc}LjpB+d96`A4>|Eh^*0xsh_W&~PScJQZA^dkt&bT^ zrd76$bvWjvjrcfpqu{i;|Kv@@VXy^m&VBbI+~#xzThOiW0~x^0%tPXh9COjPWuE-} z%DLp<$@p}m1i$$Sea`18JS=K#1Zkg0^CML{hZSK(*A`6VG9eDX6WDTsN>WBkP=`B% zxbC%^c-&#iDfxB1)7X;eLVNhvdr?-efc(bdne%?aN)KX4$*eb=VL&E{l#rBi0+HN= z(a7qFVfBK@AA&Yp+nGh}m!+Al^TR|E=nSrQeZ=vsxZ zeX^`X(kg*x7t2Rj!Z8c2=TUOX#pVY5zeP$DjmTEOj)JdwywHP=pNeIvMdt!oSZ3ve?cF1K_iGG*VNT^`_%mlvciU^6^n=2Y;IHB$2V0ro$B*94O4bWgDI}-fBQ*> z#Gh6!aCj^YOi^Bx`(u}moHLIkb8no!Df|)cpDR z!zl}8i?Q_~k*)<9S=jrgWw=D_RwSNoO-!qk_|PBO(1eNi)B%TK6G64bqeqAMgvMc?*ASc;MOWd z%?)Zme-*=KLU%0AcM&vsvC_{44yfX1pIx}({or>vj^}KKOuc6e zs9a**IY`3f&9M484LqQ7MQaZG9O5sshDXaJ+a5k>Hr90N@#|Ul4mLbQ#*c0ND5@T3 zb?sL@xPucT(l@)Z8)+6l#0R|mNQyr(Iv|~e?MyxRWMp#4OIvL6WsYAEE&HQ#L4;*; z+1w|jl=ce&POenw>-`h9?-%nn-I?+cSxGAl@siVF9>f)t3bI@f+`S0_i;zZ4NP`dR z`p13w08Ozz6V;}*l}gsT#JS32pYHa2q(Ur5%k!_IqGi9ubJ>~uR`gkbygDj zhh_Svx4p*5X7-Yp>w6`;{p`0HrqRIDKX2(!+T2|hH#=p)B_g)J$OmwiC-{YxT~a5c z2+s3Q%|K<847C!&^Bid5NSBhX?M5*aZNh1yP=4$4Pe!DnFKPF(!Ue<4pe%mDolSVG z@G%lMNriW;Py<2RZ&YAXy}@L<3dy*Z7qmqGBxl*D(3y)0YzpB#l@$9pgsi2dRqJm0b z=Gh(SI_|{}G?~dyB`bmn?$}V%9rp_dL@ls=e8hB>GN-VWDWN%KH0Ij{Y1~~ zZ0=Z4jDDq~&~-R^0=qIn6k-1c=aCd{aOiv(#cmRVJTvJhU6SN%kCWNIAL<=XE8n{UjAVVX4~emxQdLoYL=hlkJy->7yO38>lK z^*Hy!ErT9j;NXGWd|$tjZ1S1(O?O9{bez6WRq#`c$F3A6gNEGR&0XIJsQ`&dtU8|g zbK{&+q2HLn$j<1*Zv+zTmD67-2!}kgKr3CqL)BZ!vTG{QDwAL2Qz;K-gbDuJBFVWd zF5;!)Q6YoUFocpw-`*azT;8Au%Ti8WTza^X_e`f+kw_Hz6vwP&B5qdNx<%i)eE?`& z7?6RGzXpOgtV92|H^Q(?ihBdC&yGC?@l8U89HRl?2X}eJvO61On`of{o{!Q$q^yP* zk}vTJ*@|L#@sw1fEh2Gy7f9ag-WDp>FuR3$ODV436Y1uroi`Am0T5UBaq@ADkg1+@{!CFf8w(tLj(R3>?RrVh%OMgCy@FAj z|F|yT74*1OCg@V?CVlF^{fL~`k2y0t<;_dZCZa;O6E>%ba`T|`aTh+9uZt=^JCsVa)eh`=SEL<1KnMa;`XjRT>v6hphBzv`2>b;i_;U7M6 zlB|F;1k6MK$>*Nh+>6eQ0OQy@uZtMI{=xA>?zWW`>T`uE$%HZ)0`kMsL-4cuv`%hB zmR%xoU#_sh$Gt0=pN*jN-+zRx6Rp;<o?zx}CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.1 + $(MARKETING_VERSION) CFBundleURLTypes @@ -29,7 +29,7 @@ CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS UIApplicationSceneManifest diff --git a/apps/ios/README.md b/apps/ios/README.md index 7950b91..4cac50d 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -78,3 +78,22 @@ The Xcode project is generated from `project.yml`. Keep `SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES` and `SUPPORTS_MACCATALYST = NO`; this target is iOS compatibility mode, not Catalyst. + +## CI And TestFlight + +Pull requests run `.github/workflows/ios.yml`, which generates the project and +builds the iOS simulator app without code signing. + +TestFlight uploads are manual from `.github/workflows/testflight.yml`. Configure +the `testflight` GitHub environment with: + +```bash +gh secret set APPLE_TEAM_ID --env testflight +gh secret set APP_STORE_CONNECT_API_KEY_ID --env testflight +gh secret set APP_STORE_CONNECT_API_ISSUER_ID --env testflight +gh secret set APP_STORE_CONNECT_API_PRIVATE_KEY --env testflight < AuthKey_XXXX.p8 +``` + +Then run the `TestFlight` workflow and set the App Store Connect bundle id. The +workflow uses the GitHub run number as `CFBundleVersion` and uploads as an +internal-only TestFlight build by default. diff --git a/apps/ios/Scripts/archive-testflight.sh b/apps/ios/Scripts/archive-testflight.sh new file mode 100755 index 0000000..4ef6c28 --- /dev/null +++ b/apps/ios/Scripts/archive-testflight.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PROJECT="DevOpsDefender.xcodeproj" +SCHEME="DevOpsDefender" +CONFIGURATION="${CONFIGURATION:-Release}" +DEVELOPMENT_TEAM="${DD_DEVELOPMENT_TEAM:-}" +BUNDLE_ID="${DD_BUNDLE_ID:-}" +MARKETING_VERSION="${DD_MARKETING_VERSION:-0.1}" +BUILD_NUMBER="${DD_BUILD_NUMBER:-1}" +BUILD_DIR="${DD_IOS_BUILD_DIR:-$PWD/build}" +ARCHIVE_PATH="${DD_ARCHIVE_PATH:-$BUILD_DIR/DevOpsDefender.xcarchive}" +EXPORT_PATH="${DD_EXPORT_PATH:-$BUILD_DIR/TestFlight}" +ASC_KEY_PATH="${APP_STORE_CONNECT_API_KEY_PATH:-${DD_APP_STORE_CONNECT_API_KEY_PATH:-}}" +ASC_KEY_ID="${APP_STORE_CONNECT_API_KEY_ID:-${DD_APP_STORE_CONNECT_API_KEY_ID:-}}" +ASC_ISSUER_ID="${APP_STORE_CONNECT_API_ISSUER_ID:-${DD_APP_STORE_CONNECT_API_ISSUER_ID:-}}" +INTERNAL_ONLY="${DD_TESTFLIGHT_INTERNAL_ONLY:-true}" + +require_env() { + if [ -z "${!1:-}" ]; then + echo "error: $1 is required" >&2 + exit 1 + fi +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: $1 not found" >&2 + exit 1 + fi +} + +require_env DEVELOPMENT_TEAM +require_env BUNDLE_ID +require_command xcodebuild +require_command xcodegen + +AUTH_ARGS=() +if [ -n "$ASC_KEY_PATH$ASC_KEY_ID$ASC_ISSUER_ID" ]; then + require_env ASC_KEY_PATH + require_env ASC_KEY_ID + require_env ASC_ISSUER_ID + AUTH_ARGS=( + -authenticationKeyPath "$ASC_KEY_PATH" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + ) +fi + +if [ "$INTERNAL_ONLY" = "true" ] || [ "$INTERNAL_ONLY" = "YES" ] || [ "$INTERNAL_ONLY" = "1" ]; then + INTERNAL_ONLY_PLIST=true +else + INTERNAL_ONLY_PLIST=false +fi + +xcodegen generate + +mkdir -p Config "$BUILD_DIR" "$EXPORT_PATH" +cat > Config/Signing.local.xcconfig < "$EXPORT_OPTIONS" < + + + + destination + upload + method + app-store-connect + teamID + $DEVELOPMENT_TEAM + signingStyle + automatic + manageAppVersionAndBuildNumber + + testFlightInternalTestingOnly + <$INTERNAL_ONLY_PLIST/> + uploadSymbols + + + +EOF + +xcodebuild \ + -allowProvisioningUpdates \ + "${AUTH_ARGS[@]}" \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_OPTIONS" diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2d9cf9c..381085e 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -20,10 +20,14 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: "$(DD_PRODUCT_BUNDLE_IDENTIFIER)" PRODUCT_NAME: DevOpsDefender + MARKETING_VERSION: "$(DD_MARKETING_VERSION)" + CURRENT_PROJECT_VERSION: "$(DD_BUILD_NUMBER)" + VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES SUPPORTS_MACCATALYST: NO INFOPLIST_FILE: DevOpsDefender/Info.plist + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ENABLE_USER_SCRIPT_SANDBOXING: NO SWIFT_OBJC_BRIDGING_HEADER: DevOpsDefender/DDClientFFI.h LIBRARY_SEARCH_PATHS: From a805c6284077e57f1bb1f6b43fd3ce66456bf76b Mon Sep 17 00:00:00 2001 From: alex newman Date: Sun, 10 May 2026 16:43:26 -0400 Subject: [PATCH 14/18] Keep attached sessions alive while idle --- crates/dd-client-core/src/lib.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index c1efa13..07df743 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -1,6 +1,7 @@ mod ita; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{anyhow, Context}; use base64::Engine as _; @@ -19,6 +20,7 @@ use x25519_dalek::{PublicKey, StaticSecret}; const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; const MAX_NOISE_MSG: usize = 65535; const ATTACH_CHUNK: usize = 4096; +const ATTACH_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(25); const CTRL_D: u8 = 0x04; const CTRL_RIGHT_BRACKET: u8 = 0x1d; @@ -207,9 +209,13 @@ pub async fn attach_session( let mut stdin = tokio::io::stdin(); let mut stdout = tokio::io::stdout(); let mut in_buf = [0u8; ATTACH_CHUNK]; + let mut keepalive = attach_keepalive(); loop { tokio::select! { + _ = keepalive.tick() => { + conn.sink.send(WsMessage::Ping(Vec::new().into())).await?; + } n = stdin.read(&mut in_buf) => { let n = n?; if n == 0 { @@ -264,9 +270,13 @@ where anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); } on_open()?; + let mut keepalive = attach_keepalive(); loop { tokio::select! { + _ = keepalive.tick() => { + conn.sink.send(WsMessage::Ping(Vec::new().into())).await?; + } changed = shutdown.changed() => { if changed.is_err() || *shutdown.borrow() { break; @@ -286,6 +296,15 @@ where Ok(()) } +fn attach_keepalive() -> tokio::time::Interval { + let mut interval = tokio::time::interval_at( + tokio::time::Instant::now() + ATTACH_KEEPALIVE_INTERVAL, + ATTACH_KEEPALIVE_INTERVAL, + ); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + interval +} + #[derive(Debug, Eq, PartialEq)] enum AttachInputAction { Forward, From f3418765fd3e7a045ba86c18011be137a18759eb Mon Sep 17 00:00:00 2001 From: alex newman Date: Mon, 11 May 2026 10:01:03 -0400 Subject: [PATCH 15/18] Make iOS an interactive Claude Code client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the passive-viewer invariant from this PR's initial scope so mobile can drive a live Claude Code session, per issue #2. Bytes flow both directions over the same Noise channel, and the UI adapts to what the agent is doing. Rust + FFI - attach_session_stream now takes an Option>> for input; the select loop writes incoming bytes via send_encrypted on the existing transport. - dd_client_attach_stream_send(handle, bytes, len) FFI exposes the input channel; stream registry holds the sender alongside the shutdown watch. - Header updates: , , the new send signature. iOS terminal pipeline - TerminalScreenRenderer stores StyledCell instead of UnicodeScalar. Full SGR parser: bold/dim/italic/underline/inverse, 30-37/90-97 fg, 40-47/100-107 bg, 38;5;n / 48;5;n indexed-256, 38;2;r;g;b / 48;2;r;g;b truecolor, 39/49 default. Resolved against a SwiftUI Color palette. - Dual buffer: main scrollback + alternate screen (CSI ?1049h/l, ?47, ?1047). The alt buffer is discarded on exit; rendered output never exposes it, so TUI redraws no longer pollute the iOS transcript. - ESC 7 / ESC 8 / CSI s / CSI u cursor save+restore. CSI L / CSI M insert/delete lines. CSI ?1048h/l mapped to save/restore. - renderedAttributedString runs through main only and accepts a lastRows cap so per-frame work is bounded. Detection + keyboard surface - OSCSniffer extracts OSC and bare-BEL payloads from the raw byte stream before the renderer strips them. Handles 7-bit and 8-bit variants plus split-across-chunk parsing. - TitleClassifier turns OSC 0 titles into typed events (.generating, .working, .ready, .unknown), strips decorative leading glyphs from the dingbat / geometric / misc-symbols blocks, and processes events incrementally via byteOffset so replay + idle ticks don't double-count. - AppDetector picks Claude Code / Codex / OpenClaw / raw shell from transcript markers; EffectiveAppResolver fuses that with OSC title evidence so a quiet transcript can still classify as Claude Code. - KeyboardModeResolver collapses everything to one mode: .disconnected | .generating(label) | .choose(options) | .confirm | .idle(latest) | .rawShell. Title freshness beats transcript heuristics for distinguishing generating vs idle; explicit awaitingChoice / awaitingYesNo menus still win. Keyboard UI - Replaces the single action-row with mode-driven panels: .generating shows a single full-width Interrupt button; .choose mirrors Claude's numbered list as full-width tappable rows; .confirm shows Claude's actual 3-row menu (Yes / Yes-don't-ask / No-tell-me-different); .idle shows an integrated composer. - Menu rail under the contextual panel: Commands (searchable slash catalog), History (sent-input recall), Mode (Normal/Plan/Auto with Shift+Tab cycler), Keys (Tab, Shift+Tab, Esc Esc, Ctrl-R, Ctrl-L, Ctrl-C, Ctrl-D, arrows, Backspace, Newline). Persistent nav strip (↑ ↓ ⏎ Esc · ⌃C) below. - SpecialKey covers Claude Code chords: newline (0x0A), shiftTab (ESC [ Z), escEsc, ctrlR, ctrlL. - Transcript pane runs on a terminal-dark background so the ANSI palette has the contrast it was designed for; the rest of the chrome stays cream. Reliability - apply() feeds bytes into renderer + OSC sniffer immediately but schedules UI state updates at most every 80ms; every @Published assignment is gated by an equality check so identical menu options don't force SwiftUI to rebuild every row. - 1s idle tick keeps refreshKeyboardMode() running while the PTY is silent so the UI can transition out of .generating without new bytes arriving. - Auto-reconnect with 1/2/4/8/16/30s exponential backoff on stream close/error; manual "Reconnect" button surfaced when disconnected. - Events debug sheet exposes captured OSC 0 titles + raw OSC/BEL payloads so the schema can be iterated against live agents. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ios/DevOpsDefender/AppDetector.swift | 228 ++++ apps/ios/DevOpsDefender/ContentView.swift | 1121 ++++++++++++++++- apps/ios/DevOpsDefender/DDClientBridge.swift | 702 +++++++++-- apps/ios/DevOpsDefender/DDClientFFI.h | 3 + apps/ios/DevOpsDefender/KeyboardMode.swift | 105 ++ apps/ios/DevOpsDefender/Menus.swift | 74 ++ apps/ios/DevOpsDefender/OSCSniffer.swift | 134 ++ apps/ios/DevOpsDefender/TerminalStyle.swift | 102 ++ apps/ios/DevOpsDefender/TitleClassifier.swift | 144 +++ crates/dd-client-core/src/lib.rs | 18 +- crates/dd-client-ffi/src/lib.rs | 37 +- 11 files changed, 2554 insertions(+), 114 deletions(-) create mode 100644 apps/ios/DevOpsDefender/AppDetector.swift create mode 100644 apps/ios/DevOpsDefender/KeyboardMode.swift create mode 100644 apps/ios/DevOpsDefender/Menus.swift create mode 100644 apps/ios/DevOpsDefender/OSCSniffer.swift create mode 100644 apps/ios/DevOpsDefender/TerminalStyle.swift create mode 100644 apps/ios/DevOpsDefender/TitleClassifier.swift diff --git a/apps/ios/DevOpsDefender/AppDetector.swift b/apps/ios/DevOpsDefender/AppDetector.swift new file mode 100644 index 0000000..35b3605 --- /dev/null +++ b/apps/ios/DevOpsDefender/AppDetector.swift @@ -0,0 +1,228 @@ +import Foundation + +enum DetectedApp: Equatable { + case claudeCode + case codex + case openClaw + case rawShell + + var displayName: String { + switch self { + case .claudeCode: return "Claude Code" + case .codex: return "Codex" + case .openClaw: return "OpenClaw" + case .rawShell: return "Shell" + } + } + + var isFancy: Bool { + switch self { + case .claudeCode, .codex, .openClaw: return true + case .rawShell: return false + } + } +} + +enum DetectedActivity: Equatable { + case idle + case running + case awaitingYesNo + case awaitingChoice(options: [String]) + case awaitingInput + + var summary: String { + switch self { + case .idle: return "Idle" + case .running: return "Running" + case .awaitingYesNo: return "Needs y/n" + case .awaitingChoice(let options): + return "Needs choice (\(options.count))" + case .awaitingInput: return "Awaiting input" + } + } + + var needsAttention: Bool { + switch self { + case .idle, .running: return false + case .awaitingYesNo, .awaitingChoice, .awaitingInput: return true + } + } +} + +struct DetectionResult: Equatable { + var app: DetectedApp + var activity: DetectedActivity + + static let empty = DetectionResult(app: .rawShell, activity: .idle) +} + +enum AppDetector { + /// Inspect the rendered transcript and infer which agent is running plus + /// what (if anything) it is currently waiting on. + static func detect(transcript: String) -> DetectionResult { + let lines = transcript + .components(separatedBy: CharacterSet.newlines) + .map { line -> String in + line.trimmingCharacters(in: .whitespaces) + } + let nonEmpty = lines.filter { !$0.isEmpty } + let tail = Array(nonEmpty.suffix(60)) + let haystack = tail.joined(separator: "\n").lowercased() + + let app: DetectedApp + if haystack.contains("claude code") || haystack.contains("anthropic") || haystack.contains("bypassing permissions") { + app = .claudeCode + } else if haystack.contains("openclaw") { + app = .openClaw + } else if haystack.contains("codex") || haystack.contains("openai codex") { + app = .codex + } else if looksLikeFancyTui(tail: tail) { + app = .claudeCode + } else { + app = .rawShell + } + + let activity = detectActivity(tail: tail, app: app) + return DetectionResult(app: app, activity: activity) + } + + private static func looksLikeFancyTui(tail: [String]) -> Bool { + let arrowMarkers = tail.filter { line in + line.contains("❯") || line.contains("▌") || line.contains("▎") + } + return arrowMarkers.count >= 1 + } + + private static func detectActivity(tail: [String], app: DetectedApp) -> DetectedActivity { + guard !tail.isEmpty else { return .idle } + let recent = Array(tail.suffix(30)) + + if let options = numberedOptions(in: tail), options.count >= 2 { + return .awaitingChoice(options: options) + } + + let recentJoined = recent.joined(separator: "\n").lowercased() + if recentJoined.contains("(y/n)") + || recentJoined.contains("[y/n]") + || recentJoined.contains("yes/no") + || recentJoined.contains("proceed?") { + return .awaitingYesNo + } + + if let last = recent.last, endsWithPromptGlyph(last) { + return app.isFancy ? .awaitingInput : .idle + } + if app.isFancy && looksLikeFancyPrompt(in: recent) { + return .awaitingInput + } + if let last = recent.last, endsWithShellPrompt(last) { + return .idle + } + if recentJoined.contains("press enter") || recentJoined.contains("continue?") { + return .awaitingInput + } + return .running + } + + private static func looksLikeFancyPrompt(in lines: [String]) -> Bool { + let tail = lines.suffix(8) + let joined = tail.joined(separator: "\n").lowercased() + if joined.contains("? for shortcuts") || joined.contains("for shortcuts") { + return true + } + return tail.contains { line in + let stripped = line.trimmingCharacters(in: .whitespaces) + return stripped == ">" || stripped == "❯" || stripped == "│ >" || stripped == "│ ❯" + } + } + + private static func numberedOptions(in lines: [String]) -> [String]? { + var bestRun: [(Int, String)] = [] + var current: [(Int, String)] = [] + for line in lines.suffix(60) { + guard let match = numberedOptionMatch(line) else { + if current.count > bestRun.count { bestRun = current } + current.removeAll() + continue + } + if let last = current.last, match.0 != last.0 + 1 { + if current.count > bestRun.count { bestRun = current } + current.removeAll() + } + current.append(match) + } + if current.count > bestRun.count { bestRun = current } + guard bestRun.count >= 2, bestRun.first?.0 == 1 else { return nil } + return bestRun.map { $0.1 } + } + + /// Match lines like "1. label", "─2.─Run …", " 3) label", "▎ 4: label". + /// Tolerates leading whitespace, box-drawing glyphs, bullets, and the + /// "─" runs Claude Code uses for option separators. + private static func numberedOptionMatch(_ line: String) -> (Int, String)? { + let scalars = Array(line.unicodeScalars) + var i = 0 + while i < scalars.count, !CharacterSet.decimalDigits.contains(scalars[i]) { + let value = scalars[i].value + // Only skip benign prefix glyphs; bail on letters so "abc 1." is rejected. + let isWhitespace = (value == 0x20 || value == 0x09) + let isBoxOrSeparator = (value >= 0x2500 && value <= 0x257F) + || value == 0x2022 // • + || value == 0x00B7 // · + || value == 0x002A // * + || value == 0x003E // > + || value == 0x276F // ❯ + || value == 0x258E // ▎ + || value == 0x258C // ▌ + || value == 0x002D // - + || value == 0x2013 // – + || value == 0x2014 // — + guard isWhitespace || isBoxOrSeparator else { return nil } + i += 1 + } + guard i < scalars.count else { return nil } + + var digits = "" + while i < scalars.count, CharacterSet.decimalDigits.contains(scalars[i]) { + digits.unicodeScalars.append(scalars[i]) + i += 1 + } + guard let value = Int(digits), value >= 1, value <= 9 else { return nil } + + guard i < scalars.count else { return nil } + let punct = scalars[i].value + guard punct == 0x2E || punct == 0x29 || punct == 0x3A else { return nil } // . ) : + i += 1 + + // After the punctuation we expect a separator before the label content. + // Tolerate runs of whitespace and box-drawing dashes. + while i < scalars.count { + let v = scalars[i].value + let isSep = (v == 0x20 || v == 0x09) + || (v >= 0x2500 && v <= 0x257F) + || v == 0x002D || v == 0x2013 || v == 0x2014 + if !isSep { break } + i += 1 + } + + var rest = "" + while i < scalars.count { + rest.unicodeScalars.append(scalars[i]) + i += 1 + } + let label = rest.trimmingCharacters(in: .whitespaces) + guard !label.isEmpty else { return nil } + return (value, label) + } + + private static func endsWithPromptGlyph(_ line: String) -> Bool { + let stripped = line.trimmingCharacters(in: .whitespaces) + return stripped.hasSuffix("❯") || stripped.hasSuffix("▌") || stripped.hasSuffix("▎") + || stripped.contains("│ >") || stripped.contains("│ ❯") + } + + private static func endsWithShellPrompt(_ line: String) -> Bool { + let stripped = line.trimmingCharacters(in: .whitespaces) + return stripped.hasSuffix("$") || stripped.hasSuffix("#") || stripped.hasSuffix("%") || stripped.hasSuffix(">") + } +} diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index 83371a9..5c0e7b5 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,67 +1,1128 @@ import SwiftUI +enum ActiveSheet: Identifiable { + case events + case commands + case history + case mode + case keys + + var id: String { + switch self { + case .events: return "events" + case .commands: return "commands" + case .history: return "history" + case .mode: return "mode" + case .keys: return "keys" + } + } +} + struct ContentView: View { @StateObject private var viewModel = ClientViewModel() + @State private var activeSheet: ActiveSheet? var body: some View { - ZStack(alignment: .topLeading) { + ZStack(alignment: .top) { Palette.background.ignoresSafeArea() - ScrollView([.horizontal, .vertical]) { - Text(transcriptText) - .font(.system(size: 15, design: .monospaced)) + VStack(spacing: 0) { + statusBar + transcriptArea + KeyboardSurface(viewModel: viewModel, activeSheet: $activeSheet) + } + } + .sheet(item: $activeSheet) { sheet in + switch sheet { + case .events: + EventsSheet(viewModel: viewModel, onDismiss: { activeSheet = nil }) + case .commands: + CommandsSheet(viewModel: viewModel, onDismiss: { activeSheet = nil }) + case .history: + HistorySheet(viewModel: viewModel, onDismiss: { activeSheet = nil }) + case .mode: + ModeSheet(viewModel: viewModel, onDismiss: { activeSheet = nil }) + case .keys: + KeysSheet(viewModel: viewModel, onDismiss: { activeSheet = nil }) + } + } + .onOpenURL { url in + viewModel.openMobileLink(url) + } + .onChange(of: viewModel.debugAutoOpenEvents) { _, newValue in + if newValue { + activeSheet = .events + } + } + .onChange(of: viewModel.debugAutoOpenSheet) { _, sheet in + switch sheet { + case "commands": activeSheet = .commands + case "history": activeSheet = .history + case "mode": activeSheet = .mode + case "keys": activeSheet = .keys + case "events": activeSheet = .events + default: break + } + } + } + + // MARK: - Status bar + + private var statusBar: some View { + VStack(spacing: 4) { + HStack(spacing: 10) { + Circle() + .fill(statusDotColor) + .frame(width: 8, height: 8) + + Text(primaryLabel) + .font(.callout.weight(.semibold)) .foregroundStyle(Palette.text) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 8) + + Button { + activeSheet = .events + } label: { + HStack(spacing: 4) { + Image(systemName: "bell.badge") + .font(.caption2) + Text("\(viewModel.oscEvents.count)") + .font(.caption.monospacedDigit()) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Palette.muted.opacity(0.18))) + } + .buttonStyle(.plain) + .foregroundStyle(Palette.text) + .accessibilityLabel("Events") + + if viewModel.hasLinkedSession { + Text(shortSessionLabel) + .font(.caption.monospaced()) + .foregroundStyle(Palette.muted) + } + } + + HStack(spacing: 6) { + Text(secondaryLabel) + .font(.caption2.monospaced()) + .foregroundStyle(secondaryLabelColor) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + if let error = viewModel.lastSendError { + Text(error) + .font(.caption2) + .foregroundStyle(Palette.error) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Palette.surface) + .overlay(Rectangle().frame(height: 0.5).foregroundStyle(Palette.divider), alignment: .bottom) + } + + private var transcriptArea: some View { + ScrollViewReader { proxy in + ScrollView([.horizontal, .vertical]) { + Text(transcriptDisplay) + .font(transcriptFont) + .foregroundStyle(Palette.transcriptDefaultText) .textSelection(.enabled) .fixedSize(horizontal: true, vertical: true) - .frame(alignment: .topLeading) - .padding(.horizontal, 16) - .padding(.top, 46) - .padding(.bottom, 24) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .id("transcriptBottom") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Palette.transcriptBackground) + .onChange(of: viewModel.transcript) { _, _ in + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo("transcriptBottom", anchor: .bottomLeading) + } + } + } + } + + private var transcriptDisplay: AttributedString { + if !viewModel.transcript.isEmpty { + return viewModel.attributedTranscript + } + if viewModel.hasLinkedSession { + return AttributedString(viewModel.status) + } + return AttributedString("Open a DevOps Defender session link from the desktop CLI.") + } + + private var transcriptFont: Font { + viewModel.effectiveApp.isFancy + ? .system(size: 14, design: .monospaced) + : .system(size: 13, design: .monospaced) + } + + // MARK: - Status text + + private var primaryLabel: String { + guard viewModel.hasLinkedSession else { return "DevOps Defender" } + switch viewModel.keyboardMode { + case .generating(let label): + return label.isEmpty ? "Working" : label + case .choose: + return "Choose one" + case .confirm: + return "Confirm" + case .idle(let latest): + return latest?.nonEmpty ?? viewModel.effectiveApp.displayName + case .rawShell: + return "Shell" + case .disconnected: + return viewModel.effectiveApp.displayName + } + } + + private var secondaryLabel: String { + switch viewModel.keyboardMode { + case .generating: + return "Tap Interrupt to send Esc" + case .choose(let options): + return "\(options.count) options · tap one or use ↑↓⏎" + case .confirm: + return "Yes / No" + case .idle(let latest): + if let latest, !latest.isEmpty { return "Last: \(latest)" } + return viewModel.status + case .rawShell: + return viewModel.status + case .disconnected: + return viewModel.status + } + } + + private var secondaryLabelColor: Color { + switch viewModel.keyboardMode { + case .choose, .confirm: return Palette.attention + case .generating: return Palette.busy + default: return Palette.muted + } + } + + private var statusDotColor: Color { + if viewModel.isBusy { return Palette.busy } + if !viewModel.hasLinkedSession { return Palette.muted } + if !viewModel.isStreamConnected { return Palette.busy } + switch viewModel.keyboardMode { + case .generating: return Palette.busy + case .choose, .confirm: return Palette.attention + default: return Palette.ready + } + } + + private var shortSessionLabel: String { + guard viewModel.hasLinkedSession else { return "" } + return String(viewModel.selectedSessionID.prefix(8)) + } +} + +// MARK: - Keyboard surface + +private struct KeyboardSurface: View { + @ObservedObject var viewModel: ClientViewModel + @Binding var activeSheet: ActiveSheet? + + var body: some View { + VStack(spacing: 10) { + content + if showMenuRail { + MenuRail(viewModel: viewModel, activeSheet: $activeSheet) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + navStrip + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Palette.surface) + .overlay(Rectangle().frame(height: 0.5).foregroundStyle(Palette.divider), alignment: .top) + } + + private var showMenuRail: Bool { + switch viewModel.keyboardMode { + case .disconnected, .generating: return false + case .choose, .confirm, .idle, .rawShell: return true + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.keyboardMode { + case .disconnected: + disconnectedContent + case .generating(let label): + InterruptPanel(label: label) { + viewModel.sendKey(.escape) + } + case .choose(let options): + ChoosePanel(options: options) { index in + viewModel.sendText("\(index)\n") + } + case .confirm: + ConfirmPanel(viewModel: viewModel) + case .idle(let latest): + IdleComposer(viewModel: viewModel, hint: latest) + case .rawShell: + RawShellPanel(viewModel: viewModel) + } + } + @ViewBuilder + private var disconnectedContent: some View { + if viewModel.canReconnect { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "wifi.exclamationmark") + .foregroundStyle(Palette.busy) + Text("Stream disconnected") + .font(.callout.weight(.semibold)) + .foregroundStyle(Palette.text) + Spacer() + } + Button { + viewModel.manualReconnect() + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise.circle.fill") + Text("Reconnect") + .font(.callout.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(Palette.accent) + } + } else { + HStack { + Image(systemName: "link.badge.plus") + Text("Open a DevOps Defender session link to begin.") + .font(.callout) + } + .foregroundStyle(Palette.muted) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + } + + @ViewBuilder + private var navStrip: some View { + if showNavStrip { HStack(spacing: 8) { - Circle() - .fill(viewModel.isBusy ? Palette.busy : Palette.ready) - .frame(width: 7, height: 7) + NavChip(label: "↑") { viewModel.sendKey(.arrowUp) } + NavChip(label: "↓") { viewModel.sendKey(.arrowDown) } + NavChip(label: "⏎") { viewModel.sendKey(.enter) } + NavChip(label: "Esc") { viewModel.sendKey(.escape) } + Spacer() + NavChip(label: "⌃C", destructive: true) { viewModel.sendKey(.ctrlC) } + } + .disabled(!viewModel.isStreamConnected) + .opacity(viewModel.isStreamConnected ? 1.0 : 0.4) + } + } + + private var showNavStrip: Bool { + switch viewModel.keyboardMode { + case .disconnected, .generating: return false + case .choose, .confirm, .idle, .rawShell: return true + } + } +} + +// MARK: - Panels + +private struct InterruptPanel: View { + let label: String + let action: () -> Void + + var body: some View { + VStack(spacing: 6) { + Text(label) + .font(.callout.monospaced()) + .foregroundStyle(Palette.busy) + .lineLimit(1) + .truncationMode(.tail) + Button(role: .destructive, action: action) { + HStack(spacing: 8) { + Image(systemName: "stop.circle.fill") + Text("Interrupt") + .font(.title3.weight(.bold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .tint(Palette.error) + } + } +} + +private struct ChoosePanel: View { + let options: [String] + let onChoose: (Int) -> Void + + var body: some View { + VStack(spacing: 6) { + ForEach(Array(options.prefix(9).enumerated()), id: \.offset) { entry in + let index = entry.offset + 1 + Button { + onChoose(index) + } label: { + HStack(spacing: 10) { + Text("\(index).") + .font(.body.monospaced().weight(.semibold)) + .foregroundStyle(Palette.accent) + .frame(width: 26, alignment: .leading) + Text(entry.element) + .font(.body) + .foregroundStyle(Palette.text) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Palette.background) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Palette.divider.opacity(0.6), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + } + } +} + +private struct ConfirmPanel: View { + @ObservedObject var viewModel: ClientViewModel + + var body: some View { + VStack(spacing: 6) { + ConfirmRow( + index: 1, + title: "Yes", + systemImage: "checkmark.circle.fill", + tint: Palette.ready + ) { + viewModel.sendText("1\n") + } + ConfirmRow( + index: 2, + title: "Yes, don't ask again this session", + systemImage: "checkmark.circle.fill", + tint: Palette.accent + ) { + viewModel.sendText("2\n") + } + ConfirmRow( + index: 3, + title: "No, tell me what to do differently", + systemImage: "arrow.uturn.left.circle.fill", + tint: Palette.error + ) { + viewModel.sendText("3\n") + } + } + } +} + +private struct ConfirmRow: View { + let index: Int + let title: String + let systemImage: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Text("\(index).") + .font(.body.monospaced().weight(.semibold)) + .foregroundStyle(tint) + .frame(width: 26, alignment: .leading) + Image(systemName: systemImage) + .foregroundStyle(tint) + Text(title) + .font(.body) + .foregroundStyle(Palette.text) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10).fill(Palette.background) + ) + .overlay( + RoundedRectangle(cornerRadius: 10).stroke(tint.opacity(0.3), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } +} + +private struct IdleComposer: View { + @ObservedObject var viewModel: ClientViewModel + let hint: String? + @FocusState private var focused: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField(placeholder, text: $viewModel.inputDraft, axis: .vertical) + .focused($focused) + .lineLimit(1...5) + .font(.body) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(Palette.background) + ) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Palette.divider.opacity(0.6), lineWidth: 0.5) + ) + .disabled(!viewModel.isStreamConnected) + + Button { + viewModel.submitDraft(appendNewline: true) + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title) + } + .buttonStyle(.plain) + .foregroundStyle(canSend ? Palette.accent : Palette.muted.opacity(0.5)) + .disabled(!canSend) + .accessibilityLabel("Send") + } + } + + private var canSend: Bool { + viewModel.isStreamConnected && !viewModel.inputDraft.isEmpty + } + + private var placeholder: String { + if let hint, !hint.isEmpty { + return "Reply to Claude…" + } + return "Type a message…" + } +} - Text(statusText) - .font(.caption.monospaced()) +private struct SlashChip: View { + let label: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.footnote.monospaced().weight(.semibold)) + .foregroundStyle(Palette.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Palette.accent.opacity(0.12))) + } + .buttonStyle(.plain) + } +} + +private struct RawShellPanel: View { + @ObservedObject var viewModel: ClientViewModel + + var body: some View { + VStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 8) { + TextField("Type a command…", text: $viewModel.inputDraft, axis: .vertical) + .lineLimit(1...4) + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(Palette.background) + ) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Palette.divider.opacity(0.6), lineWidth: 0.5) + ) + .disabled(!viewModel.isStreamConnected) + + Button { + viewModel.submitDraft(appendNewline: true) + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title) + } + .buttonStyle(.plain) + .foregroundStyle(canSend ? Palette.accent : Palette.muted.opacity(0.5)) + .disabled(!canSend) + .accessibilityLabel("Send") + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + NavChip(label: "Tab") { viewModel.sendKey(.tab) } + NavChip(label: "←") { viewModel.sendKey(.arrowLeft) } + NavChip(label: "→") { viewModel.sendKey(.arrowRight) } + NavChip(label: "⌃D", destructive: true) { viewModel.sendKey(.ctrlD) } + SlashChip(label: "clear") { viewModel.sendText("clear\n") } + SlashChip(label: "ls") { viewModel.sendText("ls\n") } + SlashChip(label: "pwd") { viewModel.sendText("pwd\n") } + } + } + .disabled(!viewModel.isStreamConnected) + } + } + + private var canSend: Bool { + viewModel.isStreamConnected && !viewModel.inputDraft.isEmpty + } +} + +// MARK: - Menu rail + +private struct MenuRail: View { + @ObservedObject var viewModel: ClientViewModel + @Binding var activeSheet: ActiveSheet? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + MenuLauncher( + label: "Commands", + systemImage: "command.square", + badge: nil + ) { activeSheet = .commands } + + MenuLauncher( + label: "History", + systemImage: "clock.arrow.circlepath", + badge: viewModel.sentHistory.isEmpty ? nil : "\(viewModel.sentHistory.count)" + ) { activeSheet = .history } + + MenuLauncher( + label: "Mode", + systemImage: "rectangle.stack", + badge: nil + ) { activeSheet = .mode } + + MenuLauncher( + label: "Keys", + systemImage: "keyboard.chevron.compact.left", + badge: nil + ) { activeSheet = .keys } + } + .padding(.horizontal, 2) + } + .disabled(!viewModel.isStreamConnected) + .opacity(viewModel.isStreamConnected ? 1.0 : 0.4) + } +} + +private struct MenuLauncher: View { + let label: String + let systemImage: String + let badge: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.callout) + Text(label) + .font(.callout.weight(.semibold)) + if let badge { + Text(badge) + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Palette.accent.opacity(0.22))) + } + } + .foregroundStyle(Palette.text) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Palette.muted.opacity(0.16))) + } + .buttonStyle(.plain) + } +} + +// MARK: - Menu sheets + +private struct CommandsSheet: View { + @ObservedObject var viewModel: ClientViewModel + let onDismiss: () -> Void + @State private var query: String = "" + + var body: some View { + NavigationStack { + List { + ForEach(SlashCatalog.filtered(by: query)) { command in + Button { + if command.takesArgument { + viewModel.inputDraft = "\(command.name) " + onDismiss() + } else { + viewModel.sendText("\(command.name)\n") + onDismiss() + } + } label: { + HStack(spacing: 12) { + Text(command.name) + .font(.system(.body, design: .monospaced).weight(.semibold)) + .foregroundStyle(Palette.accent) + .frame(width: 110, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(command.summary) + .font(.body) + .foregroundStyle(Palette.text) + if command.takesArgument { + Text("takes an argument — fills the composer") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer() + Image(systemName: "arrow.up.forward.circle") + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .listStyle(.insetGrouped) + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always)) + .navigationTitle("Slash commands") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { onDismiss() } + } + } + } + } +} + +private struct HistorySheet: View { + @ObservedObject var viewModel: ClientViewModel + let onDismiss: () -> Void + + var body: some View { + NavigationStack { + Group { + if viewModel.sentHistory.isEmpty { + ContentUnavailableView( + "No messages sent yet", + systemImage: "clock.arrow.circlepath", + description: Text("Anything you send to the linked session will appear here so you can recall and resend it.") + ) + } else { + List { + ForEach(Array(viewModel.sentHistory.enumerated().reversed()), id: \.offset) { entry in + Button { + viewModel.inputDraft = entry.element.text + onDismiss() + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(entry.element.text) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Palette.text) + .lineLimit(4) + Text(entry.element.at, style: .time) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { onDismiss() } + } + } + } + } +} + +private struct ModeSheet: View { + @ObservedObject var viewModel: ClientViewModel + let onDismiss: () -> Void + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 14) { + Text("Claude Code cycles modes with Shift+Tab. Tap to send Shift+Tab once.") + .font(.footnote) .foregroundStyle(Palette.muted) + .padding(.horizontal, 4) + + ForEach(ClaudeMode.allCases) { mode in + Button { + viewModel.sendKey(.shiftTab) + onDismiss() + } label: { + HStack(spacing: 14) { + Image(systemName: mode.systemImage) + .font(.title3) + .foregroundStyle(Palette.accent) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(mode.displayName) + .font(.body.weight(.semibold)) + .foregroundStyle(Palette.text) + Text(mode.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "arrow.up.forward.circle") + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12).fill(Palette.background) + ) + .overlay( + RoundedRectangle(cornerRadius: 12).stroke(Palette.divider.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + + Button { + viewModel.sendKey(.shiftTab) + } label: { + Label("Send Shift+Tab again", systemImage: "arrow.triangle.2.circlepath") + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .tint(Palette.muted.opacity(0.35)) + .padding(.top, 6) + + Spacer() + } + .padding(16) + .navigationTitle("Mode") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { onDismiss() } + } + } + } + } +} + +private struct KeysSheet: View { + @ObservedObject var viewModel: ClientViewModel + let onDismiss: () -> Void + + private let cols = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: cols, spacing: 10) { + KeyTile(label: "Tab", systemImage: "arrow.right.to.line") { viewModel.sendKey(.tab) } + KeyTile(label: "Shift+Tab", systemImage: "arrow.left.to.line") { viewModel.sendKey(.shiftTab) } + KeyTile(label: "Esc", systemImage: "escape") { viewModel.sendKey(.escape) } + KeyTile(label: "Esc Esc", systemImage: "escape") { viewModel.sendKey(.escEsc) } + KeyTile(label: "Enter", systemImage: "return") { viewModel.sendKey(.enter) } + KeyTile(label: "Newline", systemImage: "text.insert") { viewModel.sendKey(.newline) } + KeyTile(label: "↑", systemImage: "arrow.up") { viewModel.sendKey(.arrowUp) } + KeyTile(label: "↓", systemImage: "arrow.down") { viewModel.sendKey(.arrowDown) } + KeyTile(label: "←", systemImage: "arrow.left") { viewModel.sendKey(.arrowLeft) } + KeyTile(label: "→", systemImage: "arrow.right") { viewModel.sendKey(.arrowRight) } + KeyTile(label: "Ctrl-R", systemImage: "doc.text.magnifyingglass") { viewModel.sendKey(.ctrlR) } + KeyTile(label: "Ctrl-L", systemImage: "eraser") { viewModel.sendKey(.ctrlL) } + KeyTile(label: "Ctrl-C", systemImage: "stop.circle", destructive: true) { viewModel.sendKey(.ctrlC) } + KeyTile(label: "Ctrl-D", systemImage: "power", destructive: true) { viewModel.sendKey(.ctrlD) } + KeyTile(label: "Backspace", systemImage: "delete.left") { viewModel.sendKey(.backspace) } + } + .padding(16) + } + .navigationTitle("Keys") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { onDismiss() } + } + } + } + } +} + +private struct KeyTile: View { + let label: String + let systemImage: String + var destructive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.title3) + Text(label) + .font(.caption.weight(.semibold)) .lineLimit(1) - .truncationMode(.middle) + .minimumScaleFactor(0.7) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Palette.background.opacity(0.94)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 10).fill(tile) + ) + .foregroundStyle(foreground) } - .onOpenURL { url in - viewModel.openMobileLink(url) + .buttonStyle(.plain) + } + + private var tile: Color { + destructive ? Palette.error.opacity(0.14) : Palette.muted.opacity(0.18) + } + + private var foreground: Color { + destructive ? Palette.error : Palette.text + } +} + +// MARK: - Shared chips + +private struct NavChip: View { + let label: String + var destructive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.callout.monospaced().weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule().fill(background) + ) + .foregroundStyle(foreground) } + .buttonStyle(.plain) } - private var transcriptText: String { - if !viewModel.transcript.isEmpty { - return viewModel.transcript + private var background: Color { + destructive ? Palette.error.opacity(0.14) : Palette.muted.opacity(0.18) + } + + private var foreground: Color { + destructive ? Palette.error : Palette.text + } +} + +// MARK: - Events sheet + +private struct EventsSheet: View { + @ObservedObject var viewModel: ClientViewModel + let onDismiss: () -> Void + + var body: some View { + NavigationStack { + Group { + if viewModel.oscEvents.isEmpty { + ContentUnavailableView( + "No events yet", + systemImage: "bell.slash", + description: Text( + "OSC and BEL escapes captured from the live PTY stream will appear here. Drive the agent through a state change to see what it emits." + ) + ) + } else { + List { + if !viewModel.titles.isEmpty { + Section("Title timeline (\(viewModel.titles.count))") { + ForEach(Array(viewModel.titles.enumerated().reversed()), id: \.offset) { entry in + TitleRow(event: entry.element) + } + } + } + Section("Raw events (\(viewModel.oscEvents.count))") { + ForEach(viewModel.oscEvents.reversed()) { event in + EventRow(event: event) + } + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Captured events") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { onDismiss() } + } + } } - if viewModel.hasLinkedSession { - return viewModel.status + } +} + +private struct TitleRow: View { + let event: TitleEvent + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(kindLabel) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(kindColor.opacity(0.18))) + .foregroundStyle(kindColor) + Text(TitleRow.timeFormatter.string(from: event.at)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(event.display) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) } - return "Open a DevOps Defender session link." + .padding(.vertical, 2) } - private var statusText: String { - if viewModel.hasLinkedSession { - return "\(viewModel.linkedSessionTitle) \(viewModel.status)" + private var kindLabel: String { + switch event.kind { + case .generating: return "WORK" + case .ready: return "READY" + case .working: return "TITLE" + case .unknown: return "?" + } + } + + private var kindColor: Color { + switch event.kind { + case .generating: return Color(red: 0.70, green: 0.43, blue: 0.12) + case .ready: return Color(red: 0.20, green: 0.48, blue: 0.31) + case .working: return Color(red: 0.32, green: 0.30, blue: 0.62) + case .unknown: return .gray + } + } +} + +private struct EventRow: View { + let event: OSCEvent + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(badgeText) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(badgeColor.opacity(0.18))) + .foregroundStyle(badgeColor) + Text(EventRow.timeFormatter.string(from: event.at)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Spacer() + Text("@\(event.byteOffset)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + + if event.kind == .osc { + Text(displayPayload) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.vertical, 4) + } + + private var badgeText: String { + switch event.kind { + case .osc: + if let ps = event.oscPs { return "OSC \(ps)" } + return "OSC" + case .bell: return "BEL" } - return viewModel.status + } + + private var badgeColor: Color { + switch event.kind { + case .osc: return Color(red: 0.32, green: 0.30, blue: 0.62) + case .bell: return Color(red: 0.85, green: 0.39, blue: 0.10) + } + } + + private var displayPayload: String { + let raw = event.raw + guard !raw.isEmpty else { return "(empty)" } + return raw + .replacingOccurrences(of: "\u{1B}", with: "\\e") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") } } +// MARK: - Palette + private enum Palette { static let background = Color(red: 0.96, green: 0.94, blue: 0.90) + static let surface = Color(red: 0.99, green: 0.97, blue: 0.93) static let text = Color(red: 0.16, green: 0.14, blue: 0.11) static let muted = Color(red: 0.42, green: 0.37, blue: 0.30) + static let divider = Color(red: 0.70, green: 0.63, blue: 0.52) static let ready = Color(red: 0.20, green: 0.48, blue: 0.31) static let busy = Color(red: 0.70, green: 0.43, blue: 0.12) + static let attention = Color(red: 0.85, green: 0.39, blue: 0.10) + static let error = Color(red: 0.78, green: 0.18, blue: 0.18) + static let accent = Color(red: 0.32, green: 0.30, blue: 0.62) + static let transcriptBackground = Color(red: 0.10, green: 0.10, blue: 0.11) + static let transcriptDefaultText = Color(red: 0.91, green: 0.89, blue: 0.84) +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } } diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 2cfd6e5..0029980 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI struct AgentSettings: Sendable { var agentURL: String @@ -119,6 +120,23 @@ final class AttachStream { self.handle = handle } + @discardableResult + func send(_ data: Data) -> Bool { + guard !data.isEmpty else { return true } + return data.withUnsafeBytes { raw -> Bool in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { + return false + } + return dd_client_attach_stream_send(handle, base, raw.count) + } + } + + @discardableResult + func send(_ text: String) -> Bool { + guard let data = text.data(using: .utf8) else { return false } + return send(data) + } + func stop() { dd_client_attach_stream_stop(handle) } @@ -168,18 +186,54 @@ enum AppDefaults { final class ClientViewModel: ObservableObject { @Published var selectedSessionID = "" @Published var transcript = "" + @Published var attributedTranscript = AttributedString("") @Published var status = "Open a mobile link from desktop" @Published var isBusy = false + @Published var detection: DetectionResult = .empty + @Published var inputDraft = "" + @Published var isStreamConnected = false + @Published var lastSendError: String? + @Published var oscEvents: [OSCEvent] = [] + @Published var debugAutoOpenEvents = false + @Published var debugAutoOpenSheet: String? = nil + @Published var keyboardMode: KeyboardMode = .disconnected + @Published var titles: [TitleEvent] = [] + @Published var effectiveApp: DetectedApp = .rawShell + @Published var sentHistory: [SentInput] = [] private var agentURL = "" private var keyPath = AppDefaults.appSupportNoiseKeyPath private var attachStream: AttachStream? private var terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) + private var oscSniffer = OSCSniffer() + private var titleClassifier = TitleClassifier() + private var refreshScheduled = false + private var lastRenderedText = "" + private let refreshInterval: TimeInterval = 0.08 + private let visibleTailRows: Int = 80 + private var idleTickTimer: Timer? + private let idleTickInterval: TimeInterval = 1.0 + private var reconnectAttempts = 0 + private var reconnectWorkItem: DispatchWorkItem? + private let reconnectMaxDelay: TimeInterval = 30 var hasLinkedSession: Bool { !selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + /// We have enough state to reattach the same Noise stream without + /// requiring the user to re-tap the desktop link. + var canReconnect: Bool { + hasLinkedSession + && !agentURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func manualReconnect() { + cancelScheduledReconnect() + reconnectAttempts = 0 + startAttachStream() + } + var linkedSessionTitle: String { let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) if id.isEmpty { @@ -209,17 +263,35 @@ final class ClientViewModel: ObservableObject { attachStream?.stop() attachStream = nil + isStreamConnected = false + stopIdleTick() + cancelScheduledReconnect() + reconnectAttempts = 0 agentURL = agent selectedSessionID = id keyPath = AppDefaults.appSupportNoiseKeyPath transcript = "" + attributedTranscript = AttributedString("") + detection = .empty + inputDraft = "" + lastSendError = nil terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) + oscSniffer.reset() + oscEvents = [] + lastRenderedText = "" + refreshScheduled = false + titleClassifier.reset() + titles = [] + keyboardMode = .disconnected + sentHistory = [] guard let key = query["key"], !key.isEmpty else { status = "Mobile link missing key" return } + debugAutoOpenEvents = (query["debug_events"] == "1") + debugAutoOpenSheet = query["debug_sheet"] importKeyAndLoadTranscript(key) } @@ -289,6 +361,11 @@ final class ClientViewModel: ObservableObject { switch event.type { case "open": status = "Connected \(id)" + isStreamConnected = true + reconnectAttempts = 0 + cancelScheduledReconnect() + startIdleTick() + refreshKeyboardMode() case "bytes": guard let data = event.data, let text = String(data: data, encoding: .utf8) else { @@ -297,20 +374,233 @@ final class ClientViewModel: ObservableObject { apply(ClientUpdate(status: "Connected \(id)", terminalText: text)) case "error": status = event.message ?? "Stream error" + isStreamConnected = false + attachStream = nil + stopIdleTick() + refreshKeyboardMode() + scheduleReconnectIfPossible() case "close": status = "Disconnected \(id)" + isStreamConnected = false + attachStream = nil + stopIdleTick() + refreshKeyboardMode() + scheduleReconnectIfPossible() default: break } } + /// Pick the next reconnect delay (1, 2, 4, 8, 16, 30 seconds, then + /// capped). Returns nil when we shouldn't auto-reconnect — e.g. no + /// linked session yet. + private func scheduleReconnectIfPossible() { + guard canReconnect else { return } + cancelScheduledReconnect() + let delay = min(pow(2.0, Double(reconnectAttempts)), reconnectMaxDelay) + reconnectAttempts += 1 + status = "Reconnecting in \(Int(delay))s · attempt \(reconnectAttempts)" + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor in + self?.attemptReconnect() + } + } + reconnectWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func cancelScheduledReconnect() { + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + } + + private func attemptReconnect() { + reconnectWorkItem = nil + guard canReconnect else { return } + guard !isStreamConnected else { return } + status = "Reconnecting…" + startAttachStream() + } + + private func startIdleTick() { + stopIdleTick() + let timer = Timer.scheduledTimer(withTimeInterval: idleTickInterval, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refreshKeyboardMode() + } + } + idleTickTimer = timer + } + + private func stopIdleTick() { + idleTickTimer?.invalidate() + idleTickTimer = nil + } + + /// Feed bytes into the renderer + sniffer immediately, but defer the + /// expensive UI updates (AttributedString construction, detection, + /// keyboard-mode resolution, @Published assignments) to a coalesced + /// refresh that fires at most ~12fps. Claude can emit dozens of PTY + /// chunks per second; rendering on every one of them is the lag. private func apply(_ update: ClientUpdate) { - guard !update.terminalText.isEmpty else { - return + guard !update.terminalText.isEmpty else { return } + let scalars = update.terminalText.unicodeScalars + terminalRenderer.feed(scalars) + oscSniffer.feed(scalars) + scheduleRefresh() + } + + private func scheduleRefresh() { + guard !refreshScheduled else { return } + refreshScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + refreshInterval) { [weak self] in + guard let self else { return } + self.refreshScheduled = false + self.performRefresh() } - terminalRenderer.feed(update.terminalText.unicodeScalars) + } + + private func performRefresh() { let rendered = terminalRenderer.renderedText() - transcript = rendered.isEmpty ? "(no transcript output before idle timeout)" : rendered + let textChanged = rendered != lastRenderedText + if textChanged { + lastRenderedText = rendered + let plain = rendered.isEmpty + ? "(no transcript output before idle timeout)" + : rendered + if plain != transcript { + transcript = plain + } + attributedTranscript = rendered.isEmpty + ? AttributedString("(no transcript output before idle timeout)") + : terminalRenderer.renderedAttributedString( + defaultForeground: Color(red: 0.91, green: 0.89, blue: 0.84), + lastRows: visibleTailRows + ) + let newDetection = AppDetector.detect(transcript: rendered) + if newDetection != detection { + detection = newDetection + } + } + if oscEvents.count != oscSniffer.events.count + || oscEvents.last?.id != oscSniffer.events.last?.id { + oscEvents = oscSniffer.events + } + titleClassifier.feed(oscSniffer.events) + if titles.count != titleClassifier.titles.count + || titles.last?.display != titleClassifier.titles.last?.display { + titles = titleClassifier.titles + } + refreshKeyboardMode() + } + + private func refreshKeyboardMode() { + let resolvedApp = EffectiveAppResolver.resolve( + detection: detection.app, + titles: titleClassifier.titles + ) + if resolvedApp != effectiveApp { + effectiveApp = resolvedApp + } + let newMode = KeyboardModeResolver.resolve( + detection: detection, + effectiveApp: resolvedApp, + latestTitle: titleClassifier.latest, + isStreamConnected: isStreamConnected, + quietSeconds: titleClassifier.quietSeconds() + ) + if newMode != keyboardMode { + keyboardMode = newMode + } + } + + func sendBytes(_ data: Data) { + guard let stream = attachStream else { + lastSendError = "Not connected" + return + } + if !stream.send(data) { + lastSendError = "Send failed" + } else { + lastSendError = nil + } + } + + func sendText(_ text: String) { + guard let data = text.data(using: .utf8) else { + lastSendError = "Could not encode text" + return + } + recordSent(text) + sendBytes(data) + } + + private func recordSent(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let entry = SentInput(text: trimmed, at: Date()) + if sentHistory.last?.text == trimmed { return } + sentHistory.append(entry) + if sentHistory.count > 200 { + sentHistory.removeFirst(sentHistory.count - 200) + } + } + + func sendKey(_ key: SpecialKey) { + sendBytes(key.bytes) + } + + func submitDraft(appendNewline: Bool = true) { + let payload = appendNewline ? inputDraft + "\n" : inputDraft + guard !payload.isEmpty else { return } + sendText(payload) + inputDraft = "" + } +} + +struct SentInput: Identifiable, Equatable { + let id = UUID() + let text: String + let at: Date +} + +enum SpecialKey { + case enter + case newline + case escape + case escEsc + case tab + case shiftTab + case backspace + case ctrlC + case ctrlD + case ctrlL + case ctrlR + case arrowUp + case arrowDown + case arrowLeft + case arrowRight + case raw(Data) + + var bytes: Data { + switch self { + case .enter: return Data([0x0D]) + case .newline: return Data([0x0A]) + case .escape: return Data([0x1B]) + case .escEsc: return Data([0x1B, 0x1B]) + case .tab: return Data([0x09]) + case .shiftTab: return Data([0x1B, 0x5B, 0x5A]) + case .backspace: return Data([0x7F]) + case .ctrlC: return Data([0x03]) + case .ctrlD: return Data([0x04]) + case .ctrlL: return Data([0x0C]) + case .ctrlR: return Data([0x12]) + case .arrowUp: return Data([0x1B, 0x5B, 0x41]) + case .arrowDown: return Data([0x1B, 0x5B, 0x42]) + case .arrowRight: return Data([0x1B, 0x5B, 0x43]) + case .arrowLeft: return Data([0x1B, 0x5B, 0x44]) + case .raw(let data): return data + } } } @@ -395,12 +685,32 @@ private func firstString(for keys: [String], in value: Any?) -> String? { return nil } -private final class TerminalScreenRenderer { +final class TerminalScreenRenderer { + /// Snapshot of the main (scrollback) screen state taken when we + /// enter the alternate buffer. The alternate buffer is for TUI-only + /// content (Claude's prompt redraws) which we intentionally do not + /// surface to the iOS transcript pane. + private struct AltBackup { + var rows: [[StyledCell]] + var row: Int + var column: Int + var style: CellStyle + var savedRow: Int + var savedColumn: Int + var savedStyle: CellStyle + } + private let width: Int private let maxRows: Int - private var rows: [[UnicodeScalar]] + private var rows: [[StyledCell]] private var row = 0 private var column = 0 + private var currentStyle = CellStyle.plain + private var savedRow = 0 + private var savedColumn = 0 + private var savedStyle = CellStyle.plain + private var inAltScreen = false + private var altBackup: AltBackup? init(width: Int, maxRows: Int) { self.width = width @@ -408,56 +718,117 @@ private final class TerminalScreenRenderer { self.rows = [Self.blankRow(width: width)] } + /// What we expose to the UI: the main scrollback contents, *not* the + /// alt-screen TUI grid. While we're inside an alt screen, scrollback + /// is frozen at whatever it was on entry (matches how real terminals + /// behave — scrollback doesn't update during a TUI session). + private var renderableRows: [[StyledCell]] { + if inAltScreen { return altBackup?.rows ?? [] } + return rows + } + func feed(_ scalars: String.UnicodeScalarView) { feed(Array(scalars)) } func renderedText() -> String { - rows - .map { trimTrailingSpaces(String(String.UnicodeScalarView($0))) } + renderableRows + .map { row -> String in + let scalars = String.UnicodeScalarView(row.map { $0.scalar }) + return trimTrailingSpaces(String(scalars)) + } .joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) } - private func feed(_ scalars: [UnicodeScalar]) { - var index = 0 + /// Build an AttributedString from the styled grid, grouping runs of + /// adjacent cells that share the same style. `lastRows` caps the + /// number of rows included from the bottom — the buffer itself + /// stays large for detection, but per-frame work shrinks to a + /// fixed visible window so rapid PTY streams stay responsive. + func renderedAttributedString(defaultForeground: Color, lastRows: Int = .max) -> AttributedString { + let source = renderableRows + let start = max(0, source.count - max(1, lastRows)) + var output = AttributedString("") + for rowIndex in start..= 0x20 { put(scalar) + } + index += 1 } - index += 1 - } } private func put(_ scalar: UnicodeScalar) { clampCursor() - rows[row][column] = scalar + rows[row][column] = StyledCell(scalar: scalar, style: currentStyle) column += 1 if column >= width { column = 0 row += 1 clampCursor() - } } + } private func clampCursor() { row = max(0, row) @@ -507,10 +876,7 @@ private final class TerminalScreenRenderer { private func handleEscapeSequence(in scalars: [UnicodeScalar], from start: Int) -> Int { let index = start + 1 - guard index < scalars.count else { - return index - } - + guard index < scalars.count else { return index } let introducer = scalars[index].value if introducer == 0x5B { return handleCSISequence(in: scalars, from: index + 1) @@ -518,38 +884,97 @@ private final class TerminalScreenRenderer { if introducer == 0x5D { return skipOSCSequence(in: scalars, from: index + 1) } - + if introducer == 0x37 { // ESC 7 — DECSC, save cursor + style + saveCursor() + return index + 1 + } + if introducer == 0x38 { // ESC 8 — DECRC, restore cursor + style + restoreCursor() + return index + 1 + } return min(index + 1, scalars.count) } + private func saveCursor() { + savedRow = row + savedColumn = column + savedStyle = currentStyle + } + + private func restoreCursor() { + row = savedRow + column = savedColumn + currentStyle = savedStyle + clampCursor() + } + + private func clearScreen() { + rows = [Self.blankRow(width: width)] + row = 0 + column = 0 + } + + /// Switch the active write target to a fresh alternate screen + /// buffer. The main scrollback (and its cursor / saved cursor) is + /// stashed so it can be restored unmodified when the alt screen + /// exits. While in the alt buffer, every write goes there — and the + /// rendered output exposed to the UI ignores it. + private func enterAltScreen() { + if inAltScreen { return } + altBackup = AltBackup( + rows: rows, + row: row, + column: column, + style: currentStyle, + savedRow: savedRow, + savedColumn: savedColumn, + savedStyle: savedStyle + ) + inAltScreen = true + rows = [Self.blankRow(width: width)] + row = 0 + column = 0 + currentStyle = .plain + savedRow = 0 + savedColumn = 0 + savedStyle = .plain + } + + private func exitAltScreen() { + if !inAltScreen { return } + inAltScreen = false + if let backup = altBackup { + rows = backup.rows + row = backup.row + column = backup.column + currentStyle = backup.style + savedRow = backup.savedRow + savedColumn = backup.savedColumn + savedStyle = backup.savedStyle + } + altBackup = nil + } + private func skipOSCSequence(in scalars: [UnicodeScalar], from start: Int) -> Int { var index = start while index < scalars.count { let value = scalars[index].value - if value == 0x07 { - return index + 1 - } + if value == 0x07 { return index + 1 } if value == 0x1B, index + 1 < scalars.count, scalars[index + 1].value == 0x5C { return index + 2 - } + } index += 1 } return index } private func skipOrphanCSIFragmentIfPresent(in scalars: [UnicodeScalar], from start: Int) -> Int? { - guard start < scalars.count else { - return nil - } + guard start < scalars.count else { return nil } let first = scalars[start].value - guard first == 0x3B || first == 0x3F || first == 0x5B else { - return nil - } + guard first == 0x3B || first == 0x3F || first == 0x5B else { return nil } var index = start - if first == 0x5B { - index += 1 - } + if first == 0x5B { index += 1 } let scanLimit = min(index + 20, scalars.count) while index < scanLimit { @@ -563,7 +988,6 @@ private final class TerminalScreenRenderer { index += 1 continue } - if isCSIControlFinal(value) { return index + 1 } @@ -588,7 +1012,7 @@ private final class TerminalScreenRenderer { let value = scalars[index].value if value >= 0x40, value <= 0x7E { applyCSI(raw, final: Character(UnicodeScalar(value)!)) - index += 1 + index += 1 return index } raw.unicodeScalars.append(scalars[index]) @@ -598,31 +1022,135 @@ private final class TerminalScreenRenderer { } private func applyCSI(_ raw: String, final: Character) { + let isPrivate = raw.hasPrefix("?") let params = parseCSIParams(raw) let amount = max(1, params.first ?? 1) + if isPrivate, final == "h" || final == "l" { + applyPrivateMode(params: params, set: final == "h") + clampCursor() + return + } + switch final { - case "A": - row -= amount - case "B": - row += amount - case "C": - column += amount - case "D": - column -= amount - case "G": - column = max(0, amount - 1) + case "A": row -= amount + case "B": row += amount + case "C": column += amount + case "D": column -= amount + case "G": column = max(0, amount - 1) case "H", "f": row = max(0, (params.first ?? 1) - 1) column = max(0, (params.dropFirst().first ?? 1) - 1) - case "J": - eraseDisplay(mode: params.first ?? 0) - case "K": - eraseLine(mode: params.first ?? 0) - default: - break + case "J": eraseDisplay(mode: params.first ?? 0) + case "K": eraseLine(mode: params.first ?? 0) + case "L": insertLines(amount) + case "M": deleteLines(amount) + case "s": saveCursor() + case "u": restoreCursor() + case "m": applySGR(params) + default: break + } + clampCursor() + } + + /// DEC private modes. The ones that matter for Claude Code: + /// 1049 / 1047 / 47 — alternate screen buffer enter (h) / exit (l). + /// We don't keep two grids; we just blank the + /// screen on both transitions so frames don't + /// layer on top of each other. + /// 1048 — save/restore cursor (paired with 1049). + /// 25 — show/hide cursor (we don't render a cursor). + private func applyPrivateMode(params: [Int], set: Bool) { + for code in params { + switch code { + case 47, 1047, 1049: + if set { enterAltScreen() } else { exitAltScreen() } + case 1048: + if set { saveCursor() } else { restoreCursor() } + default: + break + } + } + } + + private func insertLines(_ count: Int) { + clampCursor() + for _ in 0.. maxRows { + rows.removeLast(rows.count - maxRows) } + } + + private func deleteLines(_ count: Int) { clampCursor() + let n = min(count, rows.count - row) + guard n > 0 else { return } + rows.removeSubrange(row..<(row + n)) + while rows.count < row + 1 { + rows.append(Self.blankRow(width: width)) + } + } + + /// Walk SGR params and update `currentStyle`. Handles 16-color, + /// 256-color (`38;5;n` / `48;5;n`), and truecolor (`38;2;r;g;b` / + /// `48;2;r;g;b`). An empty parameter list resets, per spec. + private func applySGR(_ params: [Int]) { + if params.isEmpty { + currentStyle.reset() + return + } + var i = 0 + while i < params.count { + let code = params[i] + switch code { + case 0: currentStyle.reset() + case 1: currentStyle.bold = true + case 2: currentStyle.dim = true + case 3: currentStyle.italic = true + case 4: currentStyle.underline = true + case 7: currentStyle.inverse = true + case 22: + currentStyle.bold = false + currentStyle.dim = false + case 23: currentStyle.italic = false + case 24: currentStyle.underline = false + case 27: currentStyle.inverse = false + case 30...37: currentStyle.fg = .named(code - 30) + case 38: + if i + 1 < params.count, params[i + 1] == 5, i + 2 < params.count { + currentStyle.fg = .indexed(params[i + 2]) + i += 2 + } else if i + 1 < params.count, params[i + 1] == 2, i + 4 < params.count { + currentStyle.fg = .rgb( + UInt8(clamping: params[i + 2]), + UInt8(clamping: params[i + 3]), + UInt8(clamping: params[i + 4]) + ) + i += 4 + } + case 39: currentStyle.fg = .default + case 40...47: currentStyle.bg = .named(code - 40) + case 48: + if i + 1 < params.count, params[i + 1] == 5, i + 2 < params.count { + currentStyle.bg = .indexed(params[i + 2]) + i += 2 + } else if i + 1 < params.count, params[i + 1] == 2, i + 4 < params.count { + currentStyle.bg = .rgb( + UInt8(clamping: params[i + 2]), + UInt8(clamping: params[i + 3]), + UInt8(clamping: params[i + 4]) + ) + i += 4 + } + case 49: currentStyle.bg = .default + case 90...97: currentStyle.fg = .named(code - 90 + 8) + case 100...107: currentStyle.bg = .named(code - 100 + 8) + default: break + } + i += 1 + } } private func eraseDisplay(mode: Int) { @@ -637,7 +1165,7 @@ private final class TerminalScreenRenderer { let end = y == row ? column : width - 1 guard end >= 0 else { continue } for x in 0...end { - rows[y][x] = " " + rows[y][x] = StyledCell.blank } } default: @@ -645,7 +1173,7 @@ private final class TerminalScreenRenderer { let start = y == row ? column : 0 guard start < width else { continue } for x in start.. [Int] { let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "?=><")) - if cleaned.isEmpty { - return [] - } + if cleaned.isEmpty { return [] } return cleaned.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) ?? 0 } } - private static func blankRow(width: Int) -> [UnicodeScalar] { - Array(repeating: " ", count: width) + private static func blankRow(width: Int) -> [StyledCell] { + Array(repeating: StyledCell.blank, count: width) + } + + private func trimTrailingBlanks(_ row: [StyledCell]) -> [StyledCell] { + var end = row.count + while end > 0, row[end - 1].scalar == " ", row[end - 1].style == .plain { + end -= 1 + } + return Array(row[0.. String { } return line } + +/// Palette colors that the renderer needs to reach. Mirrors the values in +/// ContentView's private Palette enum. +private enum Palette { + static let transcriptBackground = Color(red: 0.10, green: 0.10, blue: 0.11) +} diff --git a/apps/ios/DevOpsDefender/DDClientFFI.h b/apps/ios/DevOpsDefender/DDClientFFI.h index 4db8997..587bfb5 100644 --- a/apps/ios/DevOpsDefender/DDClientFFI.h +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -5,6 +5,8 @@ extern "C" { #endif +#include +#include #include typedef void (*dd_client_stream_callback)(uint64_t handle, const char *event_json, void *context); @@ -13,6 +15,7 @@ char *dd_client_import_key(const char *key_path, const char *key_content); char *dd_client_replay_session(const char *request_json); uint64_t dd_client_attach_stream_start(const char *request_json, dd_client_stream_callback callback, void *context); void dd_client_attach_stream_stop(uint64_t handle); +bool dd_client_attach_stream_send(uint64_t handle, const uint8_t *bytes, size_t len); void dd_client_string_free(char *value); #ifdef __cplusplus diff --git a/apps/ios/DevOpsDefender/KeyboardMode.swift b/apps/ios/DevOpsDefender/KeyboardMode.swift new file mode 100644 index 0000000..3f63733 --- /dev/null +++ b/apps/ios/DevOpsDefender/KeyboardMode.swift @@ -0,0 +1,105 @@ +import Foundation + +/// What surface the keyboard should present right now. Fused from the +/// transcript-based AppDetector + OSC-0 TitleClassifier. +enum KeyboardMode: Equatable { + /// Claude is busy. The only useful action is interrupt. + case generating(label: String) + + /// Claude is showing a numbered list of options. We replicate them as + /// full-width tappable rows. + case choose(options: [String]) + + /// Claude is asking a yes/no question. + case confirm + + /// Claude is at its idle prompt — show a composer. + case idle(latest: String?) + + /// Raw shell (no fancy TUI detected) — show terminal-style chips. + case rawShell + + /// We don't have a connected session yet. + case disconnected +} + +enum KeyboardModeResolver { + /// Pick a single KeyboardMode from the transcript-based detection + /// and the most recent OSC-0 title. The detector's awaitingChoice / + /// awaitingYesNo signals are authoritative because they come from + /// the actual prompt on screen; the title is used to distinguish + /// "generating" from "idle" and to refine the app classification. + /// Time we'll trust a "generating" title for before treating Claude + /// as idle. Claude refreshes its OSC 0 title roughly every second + /// while working; if we've gone this long without one, the agent has + /// almost certainly gone quiet. + static let staleTitleThreshold: TimeInterval = 2.5 + + static func resolve( + detection: DetectionResult, + effectiveApp: DetectedApp, + latestTitle: TitleEvent?, + isStreamConnected: Bool, + quietSeconds: TimeInterval? + ) -> KeyboardMode { + guard isStreamConnected else { return .disconnected } + + if !effectiveApp.isFancy { + return .rawShell + } + + // Explicit menus from the transcript always win — Claude is + // waiting on a specific answer. + switch detection.activity { + case .awaitingChoice(let options): + return .choose(options: options) + case .awaitingYesNo: + return .confirm + default: + break + } + + // Fresh OSC 0 title with a "working" classification is the most + // reliable "agent is busy" signal. Claude keeps re-emitting its + // status title every ~1s while generating; in steady state on a + // prompt, titles stop changing. This beats transcript heuristics + // which can be fooled by a `>` cursor still being on screen + // while output continues to stream in below it. + let titleIsFresh = (quietSeconds ?? .greatestFiniteMagnitude) <= staleTitleThreshold + if titleIsFresh, let kind = latestTitle?.kind { + switch kind { + case .generating, .working: + return .generating(label: latestTitle?.display ?? "Working") + case .ready, .unknown: + break + } + } + + return .idle(latest: latestTitle?.display) + } +} + +/// Combine the transcript-based detector with OSC-0 title evidence. +/// Heavy title traffic with Claude-typical phrasing is enough evidence +/// to call this a Claude Code session even when the prompt block isn't +/// currently on screen. +enum EffectiveAppResolver { + static func resolve(detection: DetectedApp, titles: [TitleEvent]) -> DetectedApp { + if detection != .rawShell { return detection } + guard !titles.isEmpty else { return detection } + let recent = titles.suffix(40) + let generatingCount = recent.filter { $0.kind == .generating }.count + let combined = recent.map { $0.display.lowercased() }.joined(separator: "\n") + let claudeMarkers = [ + "tokens", "claude", "thinking", "cogitating", "generating", + "sautéed", "sauteed", "brewing", "noodling", "esc to interrupt" + ] + let hits = claudeMarkers.reduce(0) { acc, m in + acc + (combined.contains(m) ? 1 : 0) + } + if generatingCount >= 2 || hits >= 1 { + return .claudeCode + } + return detection + } +} diff --git a/apps/ios/DevOpsDefender/Menus.swift b/apps/ios/DevOpsDefender/Menus.swift new file mode 100644 index 0000000..ba26db3 --- /dev/null +++ b/apps/ios/DevOpsDefender/Menus.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A single entry in the slash-command palette. Mirrors what Claude Code +/// itself exposes via `/`. +struct SlashCommand: Identifiable, Hashable { + let name: String + let summary: String + /// True if the command takes an argument and should land in the + /// composer ("/save ") rather than firing immediately. + let takesArgument: Bool + + var id: String { name } +} + +enum SlashCatalog { + /// Curated to match Claude Code's documented commands. Order roughly + /// reflects how often they get used. + static let all: [SlashCommand] = [ + .init(name: "/help", summary: "Show available commands", takesArgument: false), + .init(name: "/clear", summary: "Clear conversation history", takesArgument: false), + .init(name: "/compact", summary: "Summarize and compress context", takesArgument: false), + .init(name: "/cost", summary: "Show session token + cost usage", takesArgument: false), + .init(name: "/model", summary: "Switch the active model", takesArgument: true), + .init(name: "/permissions", summary: "Edit tool permissions", takesArgument: false), + .init(name: "/agents", summary: "Pick a subagent for this turn", takesArgument: false), + .init(name: "/mcp", summary: "Manage MCP server connections", takesArgument: false), + .init(name: "/resume", summary: "Resume a previous session", takesArgument: true), + .init(name: "/save", summary: "Save the current session", takesArgument: true), + .init(name: "/init", summary: "Bootstrap CLAUDE.md for this repo", takesArgument: false), + .init(name: "/review", summary: "Review a PR", takesArgument: true), + .init(name: "/exit", summary: "Exit Claude Code", takesArgument: false) + ] + + static func filtered(by query: String) -> [SlashCommand] { + let q = query.trimmingCharacters(in: .whitespaces).lowercased() + guard !q.isEmpty else { return all } + return all.filter { cmd in + cmd.name.lowercased().contains(q) || cmd.summary.lowercased().contains(q) + } + } +} + +/// Claude Code modes. Cycling order matches Shift+Tab. +enum ClaudeMode: String, CaseIterable, Identifiable { + case normal + case plan + case autoAccept + + var id: String { rawValue } + + var displayName: String { + switch self { + case .normal: return "Normal" + case .plan: return "Plan" + case .autoAccept: return "Auto-accept" + } + } + + var subtitle: String { + switch self { + case .normal: return "Confirm each action" + case .plan: return "Propose a plan first, no edits" + case .autoAccept: return "Auto-approve safe edits" + } + } + + var systemImage: String { + switch self { + case .normal: return "person.fill.questionmark" + case .plan: return "list.bullet.rectangle" + case .autoAccept: return "bolt.circle.fill" + } + } +} diff --git a/apps/ios/DevOpsDefender/OSCSniffer.swift b/apps/ios/DevOpsDefender/OSCSniffer.swift new file mode 100644 index 0000000..b9eb6ef --- /dev/null +++ b/apps/ios/DevOpsDefender/OSCSniffer.swift @@ -0,0 +1,134 @@ +import Foundation + +/// One captured terminal "notification-class" event. We keep the schema +/// deliberately raw for the observation pass: enough to see what arrives, +/// not yet a typed Claude Code event. +struct OSCEvent: Identifiable, Equatable { + enum Kind: String, Equatable { + case osc + case bell + } + + let id: UUID + let kind: Kind + /// For `.osc`, the payload between `ESC ]` and the terminator (BEL or ST), + /// not including either delimiter. For `.bell`, the empty string. + let raw: String + let at: Date + let byteOffset: Int + + init(kind: Kind, raw: String, at: Date = Date(), byteOffset: Int) { + self.id = UUID() + self.kind = kind + self.raw = raw + self.at = at + self.byteOffset = byteOffset + } + + /// Parsed `Ps` (OSC command number) when the payload starts with digits + /// followed by a semicolon. Returns nil for malformed OSC or bell. + var oscPs: Int? { + guard kind == .osc else { return nil } + var digits = "" + for scalar in raw.unicodeScalars { + if CharacterSet.decimalDigits.contains(scalar) { + digits.unicodeScalars.append(scalar) + continue + } + break + } + return Int(digits) + } +} + +/// Streaming parser that watches a PTY byte stream and emits OSC + bell +/// notifications. Stateful so it can be fed in chunks: a sequence that +/// spans two `feed(_:)` calls is still parsed correctly. +/// +/// Recognizes the standard openings: +/// - 7-bit `ESC ]` (0x1B 0x5D) ... `BEL` (0x07) or `ESC \` (ST) +/// - 8-bit `OSC` (0x9D) ... `BEL` or `0x9C` (ST) +/// +/// Bare BEL outside an OSC sequence is captured as a `.bell` event. +final class OSCSniffer { + private enum State { + case idle + case afterEsc + case inOSC + case inOSCAfterEsc + } + + private var state: State = .idle + private var buffer = String.UnicodeScalarView() + private var bytesSeen: Int = 0 + + private(set) var events: [OSCEvent] = [] + var maxEvents: Int = 500 + + func reset() { + state = .idle + buffer.removeAll() + bytesSeen = 0 + events.removeAll() + } + + func feed(_ scalars: String.UnicodeScalarView) { + for scalar in scalars { + consume(scalar) + bytesSeen += 1 + } + } + + private func consume(_ scalar: UnicodeScalar) { + let v = scalar.value + switch state { + case .idle: + if v == 0x1B { + state = .afterEsc + } else if v == 0x9D { + state = .inOSC + buffer.removeAll() + } else if v == 0x07 { + append(.init(kind: .bell, raw: "", byteOffset: bytesSeen)) + } + case .afterEsc: + if v == 0x5D { + state = .inOSC + buffer.removeAll() + } else { + state = .idle + } + case .inOSC: + if v == 0x07 || v == 0x9C { + flushOSC() + state = .idle + } else if v == 0x1B { + state = .inOSCAfterEsc + } else { + buffer.append(scalar) + } + case .inOSCAfterEsc: + if v == 0x5C { + flushOSC() + state = .idle + } else { + buffer.append(UnicodeScalar(0x1B)!) + buffer.append(scalar) + state = .inOSC + } + } + } + + private func flushOSC() { + let raw = String(buffer) + buffer.removeAll() + append(.init(kind: .osc, raw: raw, byteOffset: bytesSeen)) + } + + private func append(_ event: OSCEvent) { + events.append(event) + if events.count > maxEvents { + events.removeFirst(events.count - maxEvents) + } + } +} diff --git a/apps/ios/DevOpsDefender/TerminalStyle.swift b/apps/ios/DevOpsDefender/TerminalStyle.swift new file mode 100644 index 0000000..0870c1e --- /dev/null +++ b/apps/ios/DevOpsDefender/TerminalStyle.swift @@ -0,0 +1,102 @@ +import SwiftUI + +/// One ANSI color slot. `default` means "use the terminal's default +/// foreground/background", which lets the renderer pick a theme color +/// rather than baking one in. +enum ANSIColor: Equatable, Hashable { + case `default` + /// Named palette index 0–15. 0–7 are the standard CGA colors, 8–15 + /// are the bright variants (selected via SGR 90–97 / 100–107). + case named(Int) + /// Indexed xterm 256-color palette entry (0–255). + case indexed(Int) + /// 24-bit truecolor (SGR 38;2;r;g;b / 48;2;r;g;b). + case rgb(UInt8, UInt8, UInt8) + + /// Resolve to a SwiftUI Color against a dark terminal theme. Named + /// colors are picked to match common terminal palettes (close to + /// Apple Terminal "Basic" / iTerm2 default) so output looks normal. + func resolved(default fallback: Color) -> Color { + switch self { + case .default: + return fallback + case .named(let i): + return ANSIColor.namedPalette[max(0, min(15, i))] + case .indexed(let i): + return ANSIColor.indexedColor(i) + case .rgb(let r, let g, let b): + return Color( + red: Double(r) / 255.0, + green: Double(g) / 255.0, + blue: Double(b) / 255.0 + ) + } + } + + // MARK: - Palette + + private static let namedPalette: [Color] = [ + Color(red: 0.00, green: 0.00, blue: 0.00), // 0 black + Color(red: 0.80, green: 0.18, blue: 0.18), // 1 red + Color(red: 0.30, green: 0.69, blue: 0.31), // 2 green + Color(red: 0.83, green: 0.68, blue: 0.21), // 3 yellow + Color(red: 0.30, green: 0.55, blue: 0.86), // 4 blue + Color(red: 0.77, green: 0.33, blue: 0.79), // 5 magenta + Color(red: 0.31, green: 0.73, blue: 0.76), // 6 cyan + Color(red: 0.85, green: 0.85, blue: 0.85), // 7 white (light gray) + Color(red: 0.50, green: 0.50, blue: 0.50), // 8 bright black (gray) + Color(red: 0.95, green: 0.36, blue: 0.36), // 9 bright red + Color(red: 0.55, green: 0.89, blue: 0.47), // 10 bright green + Color(red: 0.98, green: 0.84, blue: 0.36), // 11 bright yellow + Color(red: 0.51, green: 0.72, blue: 0.99), // 12 bright blue + Color(red: 0.93, green: 0.54, blue: 0.96), // 13 bright magenta + Color(red: 0.51, green: 0.93, blue: 0.95), // 14 bright cyan + Color(red: 1.00, green: 1.00, blue: 1.00) // 15 bright white + ] + + private static func indexedColor(_ i: Int) -> Color { + let i = max(0, min(255, i)) + if i < 16 { + return namedPalette[i] + } + if i < 232 { + // 6×6×6 color cube: index 16 + 36r + 6g + b + let n = i - 16 + let r = (n / 36) % 6 + let g = (n / 6) % 6 + let b = n % 6 + let steps: [Double] = [0.0, 0.37, 0.53, 0.69, 0.84, 1.0] + return Color(red: steps[r], green: steps[g], blue: steps[b]) + } + // 232–255: 24-step grayscale ramp + let step = Double(i - 232) + let value = (step * 10.0 + 8.0) / 255.0 + return Color(red: value, green: value, blue: value) + } +} + +/// Visual attributes for a single terminal cell. +struct CellStyle: Equatable { + var fg: ANSIColor = .default + var bg: ANSIColor = .default + var bold: Bool = false + var dim: Bool = false + var italic: Bool = false + var underline: Bool = false + var inverse: Bool = false + + static let plain = CellStyle() + + mutating func reset() { + self = .plain + } +} + +/// One cell on the terminal screen — a printable scalar plus its visual +/// style at the moment it was written. +struct StyledCell: Equatable { + var scalar: UnicodeScalar + var style: CellStyle + + static let blank = StyledCell(scalar: " ", style: .plain) +} diff --git a/apps/ios/DevOpsDefender/TitleClassifier.swift b/apps/ios/DevOpsDefender/TitleClassifier.swift new file mode 100644 index 0000000..5e5ce21 --- /dev/null +++ b/apps/ios/DevOpsDefender/TitleClassifier.swift @@ -0,0 +1,144 @@ +import Foundation + +/// One OSC-0 title, cleaned for display and classified. +struct TitleEvent: Equatable { + enum Kind: Equatable { + case generating + case working + case ready + case unknown + } + + var raw: String + /// `raw` with the leading status glyph + whitespace stripped. + var display: String + var kind: Kind + var at: Date +} + +/// Stateful classifier that turns the OSC-0 firehose into a deduped +/// sequence of TitleEvents. Consecutive identical titles are coalesced. +/// +/// The classifier is called repeatedly with a growing buffer of OSC +/// events, so it tracks the last `byteOffset` it processed and skips +/// anything at or before that. Without this, replays and idle ticks +/// re-process the same events and inflate the timeline. +final class TitleClassifier { + private(set) var titles: [TitleEvent] = [] + var maxTitles: Int = 200 + private var lastProcessedOffset: Int = -1 + + func reset() { + titles.removeAll() + lastProcessedOffset = -1 + } + + /// Feed the cumulative captured OSC event buffer. Only events whose + /// `byteOffset` is past the last seen one are actually classified. + func feed(_ events: [OSCEvent]) { + for event in events { + guard event.byteOffset > lastProcessedOffset else { continue } + lastProcessedOffset = event.byteOffset + guard event.kind == .osc else { continue } + guard let body = oscZeroBody(event.raw) else { continue } + let display = cleanDisplay(body) + if display.isEmpty { continue } + if titles.last?.display == display { continue } + let kind = classify(display) + titles.append(TitleEvent(raw: body, display: display, kind: kind, at: event.at)) + if titles.count > maxTitles { + titles.removeFirst(titles.count - maxTitles) + } + } + } + + var latest: TitleEvent? { titles.last } + + /// Time since the most recent title — used to detect "the agent went + /// quiet, probably awaiting input." + func quietSeconds(now: Date = Date()) -> TimeInterval? { + guard let last = latest else { return nil } + return now.timeIntervalSince(last.at) + } + + private func oscZeroBody(_ raw: String) -> String? { + // OSC 0 payload is `0;`. Some terminals also use `1;` or + // `2;` for icon name / window title separately — accept all three + // because they all describe the running app's state. + for prefix in ["0;", "1;", "2;"] { + if raw.hasPrefix(prefix) { + return String(raw.dropFirst(prefix.count)) + } + } + return nil + } + + /// Strip Claude Code's leading status glyphs (✻, ●, ⏺, ▌, ▎, dingbats…) + /// so the display string is just the human-readable label. We strip + /// any leading whitespace plus characters in the dingbat / geometric / + /// misc-symbols / arrows blocks — basically "any decorative glyph that + /// appears before the first letter or digit". Caps the strip so we + /// don't eat real content. + private func cleanDisplay(_ body: String) -> String { + var scalars = Array(body.unicodeScalars) + var stripped = 0 + while let first = scalars.first, stripped < 8 { + let v = first.value + let isWhitespace = v == 0x20 || v == 0x09 + let isLetter = (v >= 0x41 && v <= 0x5A) || (v >= 0x61 && v <= 0x7A) + let isDigit = v >= 0x30 && v <= 0x39 + if isLetter || isDigit { break } + let isDecorativeBlock = + (v >= 0x2190 && v <= 0x21FF) // Arrows + || (v >= 0x2200 && v <= 0x22FF) // Math operators (∗ etc.) + || (v >= 0x2300 && v <= 0x23FF) // Misc technical (⏺ etc.) + || (v >= 0x2500 && v <= 0x259F) // Box drawing + block elements + || (v >= 0x25A0 && v <= 0x25FF) // Geometric shapes (●, ▎, etc.) + || (v >= 0x2600 && v <= 0x26FF) // Misc symbols + || (v >= 0x2700 && v <= 0x27BF) // Dingbats (✻, ❯, ★, etc.) + || v == 0x2022 // • + || v == 0x00B7 // · + || v == 0x002A // * + || v == 0x002D // - + || v == 0x2013 // – + || v == 0x2014 // — + || v == 0x003E // > (rare prompt glyph) + if isWhitespace || isDecorativeBlock { + scalars.removeFirst() + stripped += 1 + continue + } + break + } + return String(String.UnicodeScalarView(scalars)) + .trimmingCharacters(in: .whitespaces) + } + + private func classify(_ text: String) -> TitleEvent.Kind { + let lower = text.lowercased() + let workingPhrases = [ + "generating", "cogitating", "thinking", "brewing", "sautéing", + "sauteed", "sautéed", "considering", "pondering", "noodling", + "investigating", "exploring", "reading", "scanning", "spinning", + "running", "executing", "tooling", "fetching", "compiling", + "writing", "applying", "patching", "diffing", "planning", + "noodling", "deliberating", "musing" + ] + if workingPhrases.contains(where: { lower.contains($0) }) { + return .generating + } + let readyPhrases = ["ready", "awaiting", "done", "complete", "finished"] + if readyPhrases.contains(where: { lower.contains($0) }) { + return .ready + } + if lower.contains("for ") && lower.range(of: #" for \d+"#, options: .regularExpression) != nil { + // "Cogitated for 32s" / "Sautéed for 9s" — a verb-past + "for Ns". + // These appear right after Claude finishes a phase; treat as + // ".generating" still because more output usually follows. The + // hint that we're idle comes from the "no new title for N + // seconds" check, not from any single title. + return .generating + } + return .working + } +} diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index 07df743..0e898fa 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -12,7 +12,7 @@ use serde_json::Value; use snow::{Builder, TransportState}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use x25519_dalek::{PublicKey, StaticSecret}; @@ -253,6 +253,7 @@ pub async fn attach_session_stream<F>( mut conn: NoiseConnection, id: &str, mut shutdown: watch::Receiver<bool>, + mut input: Option<mpsc::UnboundedReceiver<Vec<u8>>>, mut on_open: impl FnMut() -> anyhow::Result<()> + Send, mut on_bytes: F, ) -> anyhow::Result<()> @@ -282,6 +283,21 @@ where break; } } + maybe_bytes = async { + match input.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + let Some(bytes) = maybe_bytes else { + input = None; + continue; + }; + if bytes.is_empty() { + continue; + } + send_encrypted(&mut conn.transport, &mut conn.sink, &bytes).await?; + } frame = next_binary(&mut conn.stream) => { let Some(cipher) = frame? else { break; diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 4be905d..955a8cc 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -12,7 +12,8 @@ use dd_client_core::{ QuoteVerification, }; use std::collections::HashMap; -use tokio::sync::watch; +use std::slice; +use tokio::sync::{mpsc, watch}; const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; const DEFAULT_ITA_JWKS_URL: &str = "https://portal.trustauthority.intel.com/certs"; @@ -24,6 +25,7 @@ type StreamCallback = extern "C" fn(u64, *const c_char, *mut c_void); struct StreamControl { shutdown: watch::Sender<bool>, + input: mpsc::UnboundedSender<Vec<u8>>, } static NEXT_STREAM_ID: AtomicU64 = AtomicU64::new(1); @@ -65,6 +67,29 @@ pub extern "C" fn dd_client_attach_stream_stop(handle: u64) { } } +#[no_mangle] +/// # Safety +/// +/// `bytes` must point to a buffer of at least `len` bytes. Pass a null +/// pointer with `len == 0` to send an empty payload (no-op). +pub unsafe extern "C" fn dd_client_attach_stream_send( + handle: u64, + bytes: *const u8, + len: usize, +) -> bool { + if handle == 0 || len == 0 || bytes.is_null() { + return false; + } + let payload = unsafe { slice::from_raw_parts(bytes, len) }.to_vec(); + let Ok(map) = streams().lock() else { + return false; + }; + match map.get(&handle) { + Some(control) => control.input.send(payload).is_ok(), + None => false, + } +} + #[no_mangle] /// # Safety /// @@ -89,12 +114,19 @@ fn attach_stream_start( let opts = connection_options_from_request(&request)?; let id = required_json_string(&request, "id")?; let (shutdown, shutdown_rx) = watch::channel(false); + let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>(); let handle = NEXT_STREAM_ID.fetch_add(1, Ordering::Relaxed); streams() .lock() .map_err(|_| "stream registry lock poisoned".to_string())? - .insert(handle, StreamControl { shutdown }); + .insert( + handle, + StreamControl { + shutdown, + input: input_tx, + }, + ); let context_addr = context as usize; let worker = thread::Builder::new() @@ -109,6 +141,7 @@ fn attach_stream_start( conn, &id, shutdown_rx, + Some(input_rx), || { emit_stream_event( callback, From 6bf998be064c48f29f4e3d58fb2341b0ae817a51 Mon Sep 17 00:00:00 2001 From: alex newman <posix4e@gmail.com> Date: Mon, 11 May 2026 10:22:04 -0400 Subject: [PATCH 16/18] Add GitHub-auth fleet flow alongside mobile-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second way into the iOS app: sign in with GitHub on the device, browse the control plane's agents, list sessions per agent, attach. The mobile-link path is preserved — a launch chooser routes between the two on cold start. FFI - dd_client_ensure_key(path): generate the iOS device's persistent Noise key on demand, return the pubkey hex. Lets Swift avoid spelling X25519 in CryptoKit. Idempotent. - dd_client_list_sessions(request_json): wraps the existing shell.list_sessions Noise RPC behind a one-shot FFI call so the SessionListView doesn't reimplement the Noise stack in Swift. - DDClientFFI.h updated with both signatures. iOS services - KeychainStore: bearer token + CP URL override under a single service identifier; reset() wipes everything on sign-out. - AppKeyStore: owns Application Support/devopsdefender/ios.key, separate from the mobile-link key (noise.key) so the two flows don't overwrite each other. - OAuthService: ASWebAuthenticationSession wrapper that opens {cp}/oauth/ios/start?pubkey=&label= and parses the bearer token from the devopsdefender://oauth/callback?token=... redirect. Reuses the existing custom URL scheme. - FleetAPIClient: URLSession client for GET {cp}/api/v1/agents with Bearer auth. Stubbed under DEBUG_FAKE_FLEET so the UI is exercisable before the CP endpoint exists. Throws typed FleetError; 401 triggers signOutOfFleet() in the caller. iOS models / views - AgentSummary: decodable matching {id, label, agent_url, last_seen_at}. - SessionSummary: best-effort parser for the agent's list_sessions response (accepts {sessions: [...]} or a bare array, multiple field name variants for id/name/recipe/started_at). - LaunchView: cold-start chooser with "Sign in with GitHub" and a description of the mobile-link fallback. Drives the OAuth flow. - AgentListView: lists agents from FleetAPIClient.agents(). Sign-out button in the nav bar. Tap → SessionListView. - SessionListView: calls dd_client_list_sessions for the picked agent off the main actor; tap → viewModel.attachToFleetSession() which reuses the iOS device key (not the mobile-link key). Routing - ClientViewModel.appMode: .chooser | .fleet | .session. ContentView switches on it. openMobileLink advances to .session; enterFleet to .fleet; returnToChooser / signOutOfFleet back to .chooser. - Back-to-chooser chevron on the session status bar. Plan + CP dependencies - Plan file: .claude/plans/pure-watching-lamport.md. - Out of scope for this commit (depends on devopsdefender/dd): /oauth/ios/start endpoint (GitHub OAuth start), /oauth/ios/callback (302 to devopsdefender://oauth/callback?token=), /api/v1/agents (list authorized agents), agent-side polling of authorized pubkeys from the CP, rebuilding /admin/enroll as the dashboard. - Until those exist, set DEBUG_FAKE_FLEET to exercise the UI with stubbed agents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/ios/DevOpsDefender/ContentView.swift | 32 ++++- apps/ios/DevOpsDefender/DDClientBridge.swift | 101 +++++++++++++++ apps/ios/DevOpsDefender/DDClientFFI.h | 2 + .../DevOpsDefender/Models/AgentSummary.swift | 63 ++++++++++ .../DevOpsDefender/Services/AppKeyStore.swift | 57 +++++++++ .../Services/FleetAPIClient.swift | 83 +++++++++++++ .../Services/KeychainStore.swift | 67 ++++++++++ .../Services/OAuthService.swift | 88 +++++++++++++ .../DevOpsDefender/Views/AgentListView.swift | 113 +++++++++++++++++ .../ios/DevOpsDefender/Views/LaunchView.swift | 114 +++++++++++++++++ .../Views/SessionListView.swift | 116 ++++++++++++++++++ crates/dd-client-ffi/src/lib.rs | 58 ++++++++- 12 files changed, 888 insertions(+), 6 deletions(-) create mode 100644 apps/ios/DevOpsDefender/Models/AgentSummary.swift create mode 100644 apps/ios/DevOpsDefender/Services/AppKeyStore.swift create mode 100644 apps/ios/DevOpsDefender/Services/FleetAPIClient.swift create mode 100644 apps/ios/DevOpsDefender/Services/KeychainStore.swift create mode 100644 apps/ios/DevOpsDefender/Services/OAuthService.swift create mode 100644 apps/ios/DevOpsDefender/Views/AgentListView.swift create mode 100644 apps/ios/DevOpsDefender/Views/LaunchView.swift create mode 100644 apps/ios/DevOpsDefender/Views/SessionListView.swift diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index 5c0e7b5..67acb97 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -26,10 +26,13 @@ struct ContentView: View { ZStack(alignment: .top) { Palette.background.ignoresSafeArea() - VStack(spacing: 0) { - statusBar - transcriptArea - KeyboardSurface(viewModel: viewModel, activeSheet: $activeSheet) + switch viewModel.appMode { + case .chooser: + LaunchView(viewModel: viewModel) + case .fleet: + AgentListView(viewModel: viewModel) + case .session: + sessionView } } .sheet(item: $activeSheet) { sheet in @@ -66,11 +69,32 @@ struct ContentView: View { } } + /// The existing session UI (status bar + transcript + keyboard). + /// Kept as a subview so the top-level `body` can switch between it + /// and the fleet/launch screens. + private var sessionView: some View { + VStack(spacing: 0) { + statusBar + transcriptArea + KeyboardSurface(viewModel: viewModel, activeSheet: $activeSheet) + } + } + // MARK: - Status bar private var statusBar: some View { VStack(spacing: 4) { HStack(spacing: 10) { + Button { + viewModel.returnToChooser() + } label: { + Image(systemName: "chevron.backward.circle") + .font(.callout) + .foregroundStyle(Palette.muted) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to home") + Circle() .fill(statusDotColor) .frame(width: 8, height: 8) diff --git a/apps/ios/DevOpsDefender/DDClientBridge.swift b/apps/ios/DevOpsDefender/DDClientBridge.swift index 0029980..7eac9db 100644 --- a/apps/ios/DevOpsDefender/DDClientBridge.swift +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -38,6 +38,18 @@ enum DDClientBridge { ], using: dd_client_replay_session) } + static func listSessions(settings: AgentSettings) throws -> [String: Any] { + try request([ + "agent_url": settings.agentURL, + "key_path": settings.keyPath, + "insecure_skip_quote_verify": true, + "ita_api_key": "", + "ita_base_url": "", + "ita_jwks_url": "", + "ita_issuer": "" + ], using: dd_client_list_sessions) + } + static func startAttachStream( id: String, settings: AgentSettings, @@ -182,8 +194,15 @@ enum AppDefaults { } } +enum AppMode: Equatable { + case chooser + case fleet + case session +} + @MainActor final class ClientViewModel: ObservableObject { + @Published var appMode: AppMode = .chooser @Published var selectedSessionID = "" @Published var transcript = "" @Published var attributedTranscript = AttributedString("") @@ -234,6 +253,87 @@ final class ClientViewModel: ObservableObject { startAttachStream() } + /// Switch to the fleet flow. Called from the LaunchView's + /// "Sign in with GitHub" button. + func enterFleet() { + appMode = .fleet + } + + /// Return to the launch chooser. Called when sign-out happens or + /// the user explicitly backs out of a session. + func returnToChooser() { + attachStream?.stop() + attachStream = nil + isStreamConnected = false + stopIdleTick() + cancelScheduledReconnect() + reconnectAttempts = 0 + selectedSessionID = "" + agentURL = "" + transcript = "" + attributedTranscript = AttributedString("") + detection = .empty + titles = [] + sentHistory = [] + inputDraft = "" + status = "Open a session link or sign in with GitHub" + appMode = .chooser + } + + /// Wipe the CP-issued bearer token and return to the chooser. + func signOutOfFleet(keychain: KeychainStore = KeychainStore()) { + keychain.reset() + returnToChooser() + } + + /// Begin a session that was picked from the fleet flow. Uses the + /// iOS device's own Noise key (`AppKeyStore`), not the mobile-link + /// imported key. + func attachToFleetSession(agentURL: String, sessionID: String) { + let id = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + let agent = agentURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !agent.isEmpty else { + status = "Missing agent or session" + return + } + attachStream?.stop() + attachStream = nil + isStreamConnected = false + stopIdleTick() + cancelScheduledReconnect() + reconnectAttempts = 0 + + self.agentURL = agent + self.selectedSessionID = id + self.keyPath = AppKeyStore.shared.keyPath + self.transcript = "" + self.attributedTranscript = AttributedString("") + self.detection = .empty + self.titles = [] + self.sentHistory = [] + self.inputDraft = "" + self.lastSendError = nil + self.terminalRenderer = TerminalScreenRenderer(width: 96, maxRows: 160) + self.oscSniffer.reset() + self.oscEvents = [] + self.titleClassifier.reset() + self.keyboardMode = .disconnected + self.appMode = .session + + loadFleetTranscript() + } + + private func loadFleetTranscript() { + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + let settings = AgentSettings( + agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), + keyPath: keyPath.expandingTildePath + ) + run("Loading \(id)", startStreamAfterInitialLoad: true) { + initialTranscriptUpdate(id: id, settings: settings) + } + } + var linkedSessionTitle: String { let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) if id.isEmpty { @@ -284,6 +384,7 @@ final class ClientViewModel: ObservableObject { titles = [] keyboardMode = .disconnected sentHistory = [] + appMode = .session guard let key = query["key"], !key.isEmpty else { status = "Mobile link missing key" diff --git a/apps/ios/DevOpsDefender/DDClientFFI.h b/apps/ios/DevOpsDefender/DDClientFFI.h index 587bfb5..a6f1997 100644 --- a/apps/ios/DevOpsDefender/DDClientFFI.h +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -12,7 +12,9 @@ extern "C" { typedef void (*dd_client_stream_callback)(uint64_t handle, const char *event_json, void *context); char *dd_client_import_key(const char *key_path, const char *key_content); +char *dd_client_ensure_key(const char *key_path); char *dd_client_replay_session(const char *request_json); +char *dd_client_list_sessions(const char *request_json); uint64_t dd_client_attach_stream_start(const char *request_json, dd_client_stream_callback callback, void *context); void dd_client_attach_stream_stop(uint64_t handle); bool dd_client_attach_stream_send(uint64_t handle, const uint8_t *bytes, size_t len); diff --git a/apps/ios/DevOpsDefender/Models/AgentSummary.swift b/apps/ios/DevOpsDefender/Models/AgentSummary.swift new file mode 100644 index 0000000..6f79b45 --- /dev/null +++ b/apps/ios/DevOpsDefender/Models/AgentSummary.swift @@ -0,0 +1,63 @@ +import Foundation + +/// One agent the CP says this user has access to. Returned by +/// `GET /api/v1/agents`. +struct AgentSummary: Identifiable, Decodable, Equatable, Hashable { + let id: String + let label: String + let agentURL: String + let lastSeenAt: Date? + + enum CodingKeys: String, CodingKey { + case id + case label + case agentURL = "agent_url" + case lastSeenAt = "last_seen_at" + } +} + +/// One shell session on an agent. Returned by `shell.list_sessions` +/// over Noise — we don't strictly know the agent's response schema, +/// so this is best-effort decoding from a flexible JSON shape. +struct SessionSummary: Identifiable, Equatable { + let id: String + let name: String? + let recipe: String? + let startedAt: Date? + + /// Parse the agent's `list_sessions` response into a typed list. + /// We accept either `{ sessions: [...] }` or a top-level array, and + /// individual entries with any of: `id`, `session_id`, `uuid` for the + /// id field; `name` / `label` for the label; `recipe` / `recipe_id` + /// for the recipe; `started_at` / `created_at` for the timestamp. + static func parse(value: Any?) -> [SessionSummary] { + let array: [[String: Any]] = { + if let dict = value as? [String: Any], let nested = dict["sessions"] as? [[String: Any]] { + return nested + } + if let array = value as? [[String: Any]] { + return array + } + return [] + }() + return array.compactMap(parseEntry) + } + + private static func parseEntry(_ dict: [String: Any]) -> SessionSummary? { + let id = (dict["id"] as? String) + ?? (dict["session_id"] as? String) + ?? (dict["uuid"] as? String) + guard let id, !id.isEmpty else { return nil } + let name = (dict["name"] as? String) ?? (dict["label"] as? String) + let recipe = (dict["recipe"] as? String) ?? (dict["recipe_id"] as? String) + let timestamp = (dict["started_at"] as? String) ?? (dict["created_at"] as? String) + let date = timestamp.flatMap(SessionSummary.iso8601.date(from:)) + return SessionSummary(id: id, name: name, recipe: recipe, startedAt: date) + } + + private static let iso8601: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() +} diff --git a/apps/ios/DevOpsDefender/Services/AppKeyStore.swift b/apps/ios/DevOpsDefender/Services/AppKeyStore.swift new file mode 100644 index 0000000..4b8c9cd --- /dev/null +++ b/apps/ios/DevOpsDefender/Services/AppKeyStore.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Owns the iOS device's persistent Noise key — distinct from the +/// mobile-link key (`noise.key`) so the desktop-handoff flow and the +/// fleet flow never overwrite each other's material. +/// +/// On first use, calls `dd_client_ensure_key` (FFI) which generates a +/// fresh X25519 keypair if the file is missing. Subsequent calls just +/// re-derive the public key from the existing private key, so it's +/// idempotent. +struct AppKeyStore { + static let shared = AppKeyStore() + + /// File path the iOS device's own Noise private key lives at. + var keyPath: String { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first ?? URL(fileURLWithPath: NSHomeDirectory()) + return base + .appendingPathComponent("devopsdefender", isDirectory: true) + .appendingPathComponent("ios.key") + .path + } + + /// Ensure the iOS device key exists and return its hex-encoded public key. + @discardableResult + func ensurePubkeyHex() throws -> String { + let path = keyPath + let response = keyPath.withCString { _ in + dd_client_ensure_key(path) + } + guard let response else { + throw AppKeyStoreError.message("ensure_key returned null") + } + defer { dd_client_string_free(response) } + let json = String(cString: response) + guard let data = json.data(using: .utf8), + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AppKeyStoreError.message("failed to decode ensure_key response: \(json)") + } + if let ok = object["ok"] as? Bool, ok, + let pubkey = object["pubkey_hex"] as? String, !pubkey.isEmpty { + return pubkey + } + let detail = object["error"] as? String ?? json + throw AppKeyStoreError.message(detail) + } +} + +enum AppKeyStoreError: LocalizedError { + case message(String) + + var errorDescription: String? { + switch self { + case .message(let text): return text + } + } +} diff --git a/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift b/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift new file mode 100644 index 0000000..760e9e0 --- /dev/null +++ b/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift @@ -0,0 +1,83 @@ +import Foundation + +enum FleetError: LocalizedError { + case missingToken + case unauthorized + case transport(String) + case decode(String) + case http(Int, String) + + var errorDescription: String? { + switch self { + case .missingToken: return "Not signed in" + case .unauthorized: return "Sign-in expired" + case .transport(let m): return "Network error: \(m)" + case .decode(let m): return "Bad response: \(m)" + case .http(let code, let body): return "HTTP \(code): \(body.prefix(120))" + } + } +} + +/// Talks to the control plane on behalf of the signed-in iOS user. +/// Today: list the agents this user is authorized for. Per-agent +/// session listing happens via the agent's Noise RPC, not via the CP. +struct FleetAPIClient { + /// Default CP base URL. The README points at this host. + static let defaultCPBaseURL = URL(string: "https://app.devopsdefender.com")! + + var baseURL: URL + var keychain: KeychainStore + var session: URLSession + + init( + baseURL: URL = FleetAPIClient.defaultCPBaseURL, + keychain: KeychainStore = KeychainStore(), + session: URLSession = .shared + ) { + self.baseURL = baseURL + self.keychain = keychain + self.session = session + } + + func agents() async throws -> [AgentSummary] { + #if DEBUG_FAKE_FLEET + return [ + AgentSummary(id: "fake-a", label: "Laptop · macbook-pro", agentURL: "https://agent-a.example.com", lastSeenAt: Date()), + AgentSummary(id: "fake-b", label: "Workstation · linux-dev", agentURL: "https://agent-b.example.com", lastSeenAt: Date(timeIntervalSinceNow: -3600)) + ] + #else + guard let token = keychain.string(for: .bearerToken), !token.isEmpty else { + throw FleetError.missingToken + } + let url = baseURL.appendingPathComponent("/api/v1/agents") + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw FleetError.transport(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw FleetError.decode("non-HTTP response") + } + switch http.statusCode { + case 200: + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode([AgentSummary].self, from: data) + } catch { + throw FleetError.decode(error.localizedDescription) + } + case 401, 403: + throw FleetError.unauthorized + default: + let body = String(data: data, encoding: .utf8) ?? "" + throw FleetError.http(http.statusCode, body) + } + #endif + } +} diff --git a/apps/ios/DevOpsDefender/Services/KeychainStore.swift b/apps/ios/DevOpsDefender/Services/KeychainStore.swift new file mode 100644 index 0000000..3fe814d --- /dev/null +++ b/apps/ios/DevOpsDefender/Services/KeychainStore.swift @@ -0,0 +1,67 @@ +import Foundation +import Security + +/// Thin wrapper over the iOS Keychain for the small handful of secrets +/// the fleet flow needs: the CP-issued bearer token and the (optional) +/// CP URL override the user has configured. All entries live under one +/// service identifier so they wipe together on sign-out. +struct KeychainStore { + private let service = "com.devopsdefender.client.fleet" + + enum Key: String { + case bearerToken = "fleet.bearer_token" + case cpURL = "fleet.cp_url" + } + + func setString(_ value: String, for key: Key) { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var insert = query + insert.merge(attributes) { _, new in new } + SecItemAdd(insert as CFDictionary, nil) + } + } + + func string(for key: Key) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func remove(_ key: Key) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue + ] + SecItemDelete(query as CFDictionary) + } + + /// Wipe every entry in this service. Called on sign-out. + func reset() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/apps/ios/DevOpsDefender/Services/OAuthService.swift b/apps/ios/DevOpsDefender/Services/OAuthService.swift new file mode 100644 index 0000000..cafd510 --- /dev/null +++ b/apps/ios/DevOpsDefender/Services/OAuthService.swift @@ -0,0 +1,88 @@ +import AuthenticationServices +import Foundation +import UIKit + +enum OAuthError: LocalizedError { + case cancelled + case missingToken + case underlying(String) + + var errorDescription: String? { + switch self { + case .cancelled: return "Sign in cancelled" + case .missingToken: return "Sign-in did not return a token" + case .underlying(let m): return m + } + } +} + +/// Wraps `ASWebAuthenticationSession` for the iOS-side GitHub OAuth +/// dance. The CP at `<baseURL>/oauth/ios/start?pubkey=&label=` runs the +/// usual GitHub flow then 302s back to `devopsdefender://oauth/callback?token=<jwt>` +/// — we parse the token from the callback URL and hand it back. +@MainActor +final class OAuthService: NSObject { + static let callbackScheme = "devopsdefender" + static let callbackHost = "oauth" + static let callbackPath = "/callback" + + private var session: ASWebAuthenticationSession? + + /// Open the OAuth flow. Returns the bearer token from the callback. + func signIn(baseURL: URL, pubkey: String, label: String) async throws -> String { + let start = baseURL + .appendingPathComponent("/oauth/ios/start") + var components = URLComponents(url: start, resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "pubkey", value: pubkey), + URLQueryItem(name: "label", value: label) + ] + guard let url = components?.url else { + throw OAuthError.underlying("Could not build OAuth start URL") + } + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: OAuthService.callbackScheme + ) { callbackURL, error in + if let error { + if let auth = error as? ASWebAuthenticationSessionError, + auth.code == .canceledLogin { + continuation.resume(throwing: OAuthError.cancelled) + } else { + continuation.resume(throwing: OAuthError.underlying(error.localizedDescription)) + } + return + } + guard let callbackURL, + let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let token = components.queryItems?.first(where: { $0.name == "token" })?.value, + !token.isEmpty else { + continuation.resume(throwing: OAuthError.missingToken) + return + } + continuation.resume(returning: token) + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + if !session.start() { + continuation.resume(throwing: OAuthError.underlying("ASWebAuthenticationSession refused to start")) + } + } + } +} + +extension OAuthService: ASWebAuthenticationPresentationContextProviding { + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + // The session presentation needs a UIWindow on iOS. + DispatchQueue.main.sync { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + ?? UIWindow() + } + } +} diff --git a/apps/ios/DevOpsDefender/Views/AgentListView.swift b/apps/ios/DevOpsDefender/Views/AgentListView.swift new file mode 100644 index 0000000..840a8fe --- /dev/null +++ b/apps/ios/DevOpsDefender/Views/AgentListView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// First screen after fleet sign-in. Lists the agents the CP says +/// this user has access to. Tap → navigate to SessionListView for +/// that agent. +struct AgentListView: View { + @ObservedObject var viewModel: ClientViewModel + @State private var agents: [AgentSummary] = [] + @State private var loadError: String? + @State private var isLoading = false + @State private var selectedAgent: AgentSummary? + + private let api: FleetAPIClient + + init(viewModel: ClientViewModel, api: FleetAPIClient = FleetAPIClient()) { + self.viewModel = viewModel + self.api = api + } + + var body: some View { + NavigationStack { + Group { + if isLoading && agents.isEmpty { + ProgressView("Loading agents…") + .progressViewStyle(.circular) + } else if agents.isEmpty { + ContentUnavailableView( + "No agents", + systemImage: "server.rack", + description: Text(loadError ?? "Your account has no enrolled agents yet.") + ) + } else { + List { + ForEach(agents) { agent in + Button { + selectedAgent = agent + } label: { + AgentRow(agent: agent) + } + .buttonStyle(.plain) + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Agents") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Sign out") { + viewModel.signOutOfFleet() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading) + } + } + .navigationDestination(item: $selectedAgent) { agent in + SessionListView(viewModel: viewModel, agent: agent) + } + } + .task { + await load() + } + } + + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + agents = try await api.agents() + } catch FleetError.unauthorized { + viewModel.signOutOfFleet() + } catch { + loadError = error.localizedDescription + } + } +} + +private struct AgentRow: View { + let agent: AgentSummary + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(agent.label) + .font(.body.weight(.semibold)) + Text(agent.agentURL) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + if let last = agent.lastSeenAt { + Text("Last seen \(last.formatted(.relative(presentation: .named)))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } +} diff --git a/apps/ios/DevOpsDefender/Views/LaunchView.swift b/apps/ios/DevOpsDefender/Views/LaunchView.swift new file mode 100644 index 0000000..685de54 --- /dev/null +++ b/apps/ios/DevOpsDefender/Views/LaunchView.swift @@ -0,0 +1,114 @@ +import SwiftUI +import UIKit + +/// Initial screen when no session is linked. Two clear paths: +/// (1) sign in with GitHub and pick from the fleet, or (2) wait for a +/// desktop-generated `devopsdefender://session?...` deep link. +struct LaunchView: View { + @ObservedObject var viewModel: ClientViewModel + @State private var isAuthenticating = false + @State private var authError: String? + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 8) { + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 56, weight: .semibold)) + .foregroundStyle(LaunchPalette.accent) + Text("DevOps Defender") + .font(.title.weight(.semibold)) + .foregroundStyle(LaunchPalette.text) + Text("Mobile client for live Claude Code sessions") + .font(.subheadline) + .foregroundStyle(LaunchPalette.muted) + } + + Spacer() + + VStack(spacing: 14) { + Button { + Task { await signIn() } + } label: { + HStack(spacing: 10) { + if isAuthenticating { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } else { + Image(systemName: "logo.github") + .renderingMode(.template) + } + Text(isAuthenticating ? "Signing in…" : "Sign in with GitHub") + .font(.body.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .tint(LaunchPalette.accent) + .disabled(isAuthenticating) + + Text("or") + .font(.caption) + .foregroundStyle(LaunchPalette.muted) + + VStack(spacing: 6) { + Image(systemName: "link") + .font(.callout) + .foregroundStyle(LaunchPalette.muted) + Text("Open a devopsdefender:// link from your desktop CLI to attach to a specific session.") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(LaunchPalette.muted) + } + .padding(.horizontal, 16) + } + .padding(.horizontal, 24) + + if let authError { + Text(authError) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LaunchPalette.background) + } + + private func signIn() async { + guard !isAuthenticating else { return } + isAuthenticating = true + authError = nil + defer { isAuthenticating = false } + do { + let pubkey = try AppKeyStore.shared.ensurePubkeyHex() + let label = await MainActor.run { UIDevice.current.name } + let service = await MainActor.run { OAuthService() } + let token = try await service.signIn( + baseURL: FleetAPIClient.defaultCPBaseURL, + pubkey: pubkey, + label: label + ) + KeychainStore().setString(token, for: .bearerToken) + viewModel.enterFleet() + } catch let error as OAuthError { + if case .cancelled = error { return } + authError = error.localizedDescription + } catch { + authError = error.localizedDescription + } + } +} + +private enum LaunchPalette { + static let background = Color(red: 0.96, green: 0.94, blue: 0.90) + static let text = Color(red: 0.16, green: 0.14, blue: 0.11) + static let muted = Color(red: 0.42, green: 0.37, blue: 0.30) + static let accent = Color(red: 0.32, green: 0.30, blue: 0.62) +} diff --git a/apps/ios/DevOpsDefender/Views/SessionListView.swift b/apps/ios/DevOpsDefender/Views/SessionListView.swift new file mode 100644 index 0000000..89a7f97 --- /dev/null +++ b/apps/ios/DevOpsDefender/Views/SessionListView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// Second fleet screen: sessions on the picked agent. Calls +/// `shell.list_sessions` over Noise via the new FFI helper. Tap → route +/// into the existing keyboard/transcript surface. +struct SessionListView: View { + @ObservedObject var viewModel: ClientViewModel + let agent: AgentSummary + + @State private var sessions: [SessionSummary] = [] + @State private var loadError: String? + @State private var isLoading = false + + var body: some View { + Group { + if isLoading && sessions.isEmpty { + ProgressView("Loading sessions…") + } else if sessions.isEmpty { + ContentUnavailableView( + "No sessions", + systemImage: "terminal", + description: Text(loadError ?? "This agent has no live sessions. Start one from your laptop with `dd-client shell …`.") + ) + } else { + List { + ForEach(sessions) { session in + Button { + viewModel.attachToFleetSession( + agentURL: agent.agentURL, + sessionID: session.id + ) + } label: { + SessionRow(session: session) + } + .buttonStyle(.plain) + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle(agent.label) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading) + } + } + .task { + await load() + } + } + + /// Calls `shell.list_sessions` via the FFI helper. Runs off the + /// main actor so the synchronous Noise round-trip doesn't block + /// the UI. + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + let agentURL = agent.agentURL + let result = try await Task.detached(priority: .userInitiated) { () throws -> [SessionSummary] in + let settings = AgentSettings( + agentURL: agentURL, + keyPath: AppKeyStore.shared.keyPath + ) + let response = try DDClientBridge.listSessions(settings: settings) + return SessionSummary.parse(value: response["value"] ?? response) + }.value + sessions = result + } catch { + loadError = error.localizedDescription + sessions = [] + } + } +} + +private struct SessionRow: View { + let session: SessionSummary + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "terminal") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(session.name ?? session.id) + .font(.body.weight(.semibold)) + .lineLimit(1) + Text(session.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + if let recipe = session.recipe { + Text("recipe · \(recipe)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + if let started = session.startedAt { + Text("started \(started.formatted(.relative(presentation: .named)))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } +} diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 955a8cc..b34b546 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -8,8 +8,8 @@ use std::thread; use base64::Engine as _; use dd_client_core::{ - attach_session_stream, connect, replay_session, ConnectionOptions, IntelTrustAuthority, - QuoteVerification, + attach_session_stream, connect, list_sessions, public_key_hex, replay_session, + ConnectionOptions, IntelTrustAuthority, QuoteVerification, }; use std::collections::HashMap; use std::slice; @@ -44,6 +44,16 @@ pub extern "C" fn dd_client_replay_session(request_json: *const c_char) -> *mut into_c_string(replay_session_response(request_json)) } +#[no_mangle] +pub extern "C" fn dd_client_ensure_key(key_path: *const c_char) -> *mut c_char { + into_c_string(ensure_key_response(key_path)) +} + +#[no_mangle] +pub extern "C" fn dd_client_list_sessions(request_json: *const c_char) -> *mut c_char { + into_c_string(list_sessions_response(request_json)) +} + #[no_mangle] pub extern "C" fn dd_client_attach_stream_start( request_json: *const c_char, @@ -249,6 +259,50 @@ fn replay_session_response(request_json: *const c_char) -> serde_json::Value { } } +fn ensure_key_response(key_path: *const c_char) -> serde_json::Value { + match ensure_key_request(key_path) { + Ok(value) => value, + Err(error) => serde_json::json!({ + "ok": false, + "error": error, + }), + } +} + +fn ensure_key_request(key_path: *const c_char) -> Result<serde_json::Value, String> { + let key_path = required_c_string(key_path, "key_path")?; + let path = PathBuf::from(&key_path); + let pubkey = block_on_ffi_result(move || async move { public_key_hex(&path).await })?; + Ok(serde_json::json!({ + "ok": true, + "operation": "ensure_key", + "key_path": key_path, + "pubkey_hex": pubkey, + })) +} + +fn list_sessions_response(request_json: *const c_char) -> serde_json::Value { + match list_sessions_request(request_json) { + Ok(value) => value, + Err(error) => serde_json::json!({ + "ok": false, + "error": error, + }), + } +} + +fn list_sessions_request(request_json: *const c_char) -> Result<serde_json::Value, String> { + let request_json = required_c_string(request_json, "request_json")?; + let request: serde_json::Value = + serde_json::from_str(&request_json).map_err(|e| format!("parse request_json: {e}"))?; + let opts = connection_options_from_request(&request)?; + let value = block_on_ffi_result(move || async move { + let mut conn = connect(&opts).await?; + list_sessions(&mut conn).await + })?; + Ok(ok_value("list_sessions", value)) +} + fn replay_session_request(request_json: *const c_char) -> Result<serde_json::Value, String> { let request_json = required_c_string(request_json, "request_json")?; let request: serde_json::Value = From f4f6dabba8f551273c49c40b4991cf1a17b661e6 Mon Sep 17 00:00:00 2001 From: alex newman <posix4e@gmail.com> Date: Mon, 11 May 2026 10:26:29 -0400 Subject: [PATCH 17/18] Fix OAuth crash: replace DispatchQueue.main.sync with MainActor.assumeIsolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presentationAnchor delegate from ASWebAuthenticationSession is invoked on the main thread. The previous implementation called DispatchQueue.main.sync inside it, which is a re-entrant wait on the same queue and traps with EXC_BREAKPOINT (__DISPATCH_WAIT_FOR_QUEUE__) the moment the user taps "Sign in with GitHub". Use MainActor.assumeIsolated instead — it asserts we're on the main actor and provides synchronous access to main-actor-isolated UIKit state without dispatching. Same effect as accessing UIApplication.shared directly, without the compile error from a nonisolated context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/ios/DevOpsDefender/Services/OAuthService.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/ios/DevOpsDefender/Services/OAuthService.swift b/apps/ios/DevOpsDefender/Services/OAuthService.swift index cafd510..cfc9bf8 100644 --- a/apps/ios/DevOpsDefender/Services/OAuthService.swift +++ b/apps/ios/DevOpsDefender/Services/OAuthService.swift @@ -75,14 +75,18 @@ final class OAuthService: NSObject { } extension OAuthService: ASWebAuthenticationPresentationContextProviding { + /// ASWebAuthenticationSession calls this delegate on the main thread. + /// Using `DispatchQueue.main.sync` here deadlocks (re-entrant wait on + /// the same queue) — that's the EXC_BREAKPOINT we hit. Use + /// `MainActor.assumeIsolated` to access main-actor-isolated UIKit + /// state without an async hop. nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - // The session presentation needs a UIWindow on iOS. - DispatchQueue.main.sync { - UIApplication.shared.connectedScenes + MainActor.assumeIsolated { + let keyWindow = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } .first { $0.isKeyWindow } - ?? UIWindow() + return keyWindow ?? ASPresentationAnchor() } } } From eb3f825f0c4745d80f65f1de075dbdac1cd7f34d Mon Sep 17 00:00:00 2001 From: alex newman <posix4e@gmail.com> Date: Mon, 11 May 2026 10:55:00 -0400 Subject: [PATCH 18/18] Drop fleet stub, refresh iOS README, gitignore .claude/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FleetAPIClient: remove the DEBUG_FAKE_FLEET compile-flagged fake-agents branch. Real CP only; when the endpoint isn't there (currently the case), iOS surfaces the actual error from the API call. - apps/ios/README.md: replace the stale "intentionally does not create sessions, list recipes, browse agents, send input" paragraph (which described the pre-PR-#1 surface) with the current state — two paths (mobile-link and fleet sign-in), interactive client with keystroke forwarding, mode-driven keyboard. Document the planned Xcode Cloud migration path so the GH Actions ios.yml + testflight.yml workflows can be retired once the Xcode Cloud workflows are up. - .gitignore: ignore the .claude/ runtime scratch directory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .gitignore | 1 + .../Services/FleetAPIClient.swift | 7 -- apps/ios/README.md | 98 ++++++++++++++----- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 622d2bf..a2055a1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ apps/ios/DevOpsDefender.xcodeproj/ apps/ios/Config/Signing.local.xcconfig apps/ios/com.apple.DeveloperTools/ apps/ios/err +.claude/ diff --git a/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift b/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift index 760e9e0..0f703ba 100644 --- a/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift +++ b/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift @@ -40,12 +40,6 @@ struct FleetAPIClient { } func agents() async throws -> [AgentSummary] { - #if DEBUG_FAKE_FLEET - return [ - AgentSummary(id: "fake-a", label: "Laptop · macbook-pro", agentURL: "https://agent-a.example.com", lastSeenAt: Date()), - AgentSummary(id: "fake-b", label: "Workstation · linux-dev", agentURL: "https://agent-b.example.com", lastSeenAt: Date(timeIntervalSinceNow: -3600)) - ] - #else guard let token = keychain.string(for: .bearerToken), !token.isEmpty else { throw FleetError.missingToken } @@ -78,6 +72,5 @@ struct FleetAPIClient { let body = String(data: data, encoding: .utf8) ?? "" throw FleetError.http(http.statusCode, body) } - #endif } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 4cac50d..ebced8f 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,12 +1,21 @@ # iOS Client -Native SwiftUI companion for `dd-client` CLI sessions. +Native SwiftUI companion for `dd-client` CLI sessions. Two entry paths: -The desktop CLI owns agent selection, session creation, enrollment, and full -terminal attach. The iOS app only opens a desktop-generated session link, loads -bounded transcript history, and follows the live transcript. +1. **Mobile link from desktop CLI** — desktop generates a one-shot + `devopsdefender://session?...` link with an embedded Noise key. +2. **Fleet discovery on device** — sign in with GitHub against the control + plane at `app.devopsdefender.com`, browse the agents and sessions the + authenticated user has access to. The iOS device holds its own Noise key + distinct from the mobile-link key. -## Desktop Flow +The app is an interactive client, not a passive viewer: keystrokes flow back +through the existing Noise channel, the keyboard surface adapts to the running +agent (Claude Code option menus, yes/no confirmations, raw shell), and ANSI +colors render with a proper SGR parser. The mobile-link and fleet paths +coexist; the launch screen routes between them. + +## Desktop Flow (mobile-link path) Start or reattach a CLI session first: @@ -40,18 +49,30 @@ cargo run -p dd-client -- mobile-link \ ``` Open the printed `devopsdefender://session?...` link on iOS, or render the QR -with the printed `qrencode` command. The link contains the Noise private key and -the app imports it before loading history and following the transcript; treat -the link or QR as secret. - -## App Workflow - -- Open a `devopsdefender://session?...` link or scan its QR code. -- The app imports the embedded key. -- The app loads recent transcript history, then keeps following live output. - -The app intentionally does not create sessions, list recipes, browse agents, -send input, or take over terminal control. +with the printed `qrencode` command. The link contains the Noise private key +and the app imports it; treat the link or QR as secret. + +## Fleet Flow (GitHub sign-in path) + +Cold-launch the iOS app and tap **Sign in with GitHub**. The app: + +1. Ensures a persistent iOS device key exists at + `Application Support/devopsdefender/ios.key` (X25519, generated via the + `dd_client_ensure_key` FFI on first launch). +2. Opens an `ASWebAuthenticationSession` against + `https://app.devopsdefender.com/oauth/ios/start?pubkey=<hex>&label=<device>`. +3. The control plane runs the GitHub OAuth dance, records the + user ↔ pubkey ↔ label binding, mints a bearer token, and 302s back to + `devopsdefender://oauth/callback?token=<token>`. +4. The app stores the bearer in Keychain and calls + `GET /api/v1/agents` for the user's agent list. +5. Tap an agent → `dd_client_list_sessions` enumerates sessions on that agent + over Noise. Tap a session → the existing keyboard/transcript surface + engages. + +The fleet flow depends on three CP endpoints that live in `devopsdefender/dd` +(tracked at https://github.com/devopsdefender/dd/issues/266) plus agent-side +polling of authorized pubkeys from the CP. ## Prerequisites @@ -81,11 +102,40 @@ Catalyst. ## CI And TestFlight -Pull requests run `.github/workflows/ios.yml`, which generates the project and -builds the iOS simulator app without code signing. +### Xcode Cloud (planned) + +Xcode Cloud is the target CI/CD for this app. It runs on Apple infrastructure, +integrates directly with App Store Connect, automates signing, and removes the +need to manage App Store Connect API keys as repo secrets. The existing +`Build Rust FFI` script phase in `project.yml` runs unchanged under Xcode +Cloud. + +To set it up: + +1. Open `DevOpsDefender.xcodeproj` in Xcode. +2. **Product → Xcode Cloud → Create Workflow**. +3. Create two workflows: + - **PR build** — start condition: pull request to `main`. Actions: Build + (Any iOS Device, no archive). No post-actions. + - **TestFlight build** — start condition: tag matching `ios-v*`. Actions: + Archive (iOS), TestFlight Internal Distribution. +4. Environment variables on both: `DD_PRODUCT_BUNDLE_IDENTIFIER`, + `DD_DEVELOPMENT_TEAM`, `DD_MARKETING_VERSION`. Rust toolchain installs + in the `Scripts/build-rust.sh` prebuild script so no extra Xcode Cloud + env is needed. + +Once Xcode Cloud is green, retire `.github/workflows/ios.yml` and +`.github/workflows/testflight.yml`. Rust-side CI (cargo test/fmt/clippy on +the workspace) stays on GitHub Actions because Xcode Cloud only runs Apple +builds. + +### Legacy GitHub Actions (currently active) + +Pull requests run `.github/workflows/ios.yml`, which generates the project +and builds the iOS simulator app without code signing. -TestFlight uploads are manual from `.github/workflows/testflight.yml`. Configure -the `testflight` GitHub environment with: +TestFlight uploads are manual from `.github/workflows/testflight.yml`. +Configure the `testflight` GitHub environment with: ```bash gh secret set APPLE_TEAM_ID --env testflight @@ -94,6 +144,6 @@ gh secret set APP_STORE_CONNECT_API_ISSUER_ID --env testflight gh secret set APP_STORE_CONNECT_API_PRIVATE_KEY --env testflight < AuthKey_XXXX.p8 ``` -Then run the `TestFlight` workflow and set the App Store Connect bundle id. The -workflow uses the GitHub run number as `CFBundleVersion` and uploads as an -internal-only TestFlight build by default. +Then run the `TestFlight` workflow and set the App Store Connect bundle id. +The workflow uses the GitHub run number as `CFBundleVersion` and uploads as +an internal-only TestFlight build by default.