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/.gitignore b/.gitignore index 24f2189..a2055a1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ *.key *.key.tmp .DS_Store - +apps/ios/DevOpsDefender.xcodeproj/ +apps/ios/Config/Signing.local.xcconfig +apps/ios/com.apple.DeveloperTools/ +apps/ios/err +.claude/ diff --git a/Cargo.lock b/Cargo.lock index ae2018c..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", @@ -377,7 +347,9 @@ dependencies = [ name = "dd-client-ffi" version = "0.1.0" dependencies = [ + "base64", "dd-client-core", + "hex", "serde_json", "tempfile", "tokio", @@ -660,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" @@ -1363,6 +1311,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" @@ -1561,6 +1519,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1879,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 de1d927..e54e101 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 @@ -52,5 +44,19 @@ 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 +``` + +Open the printed `devopsdefender://session?...` link on iOS, or render it as a +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/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig new file mode 100644 index 0000000..3b6c453 --- /dev/null +++ b/apps/ios/Config/Signing.xcconfig @@ -0,0 +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/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/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..443b26c Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png new file mode 100644 index 0000000..dde7e63 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png new file mode 100644 index 0000000..d57d9a4 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png new file mode 100644 index 0000000..fa87628 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png differ 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 0000000..b0c9afc Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png differ 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 0000000..a2ddd2d Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png new file mode 100644 index 0000000..4c2d595 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png differ 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 0000000..df6131b Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png new file mode 100644 index 0000000..3b80efd Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png new file mode 100644 index 0000000..6423c25 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png new file mode 100644 index 0000000..7989470 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png new file mode 100644 index 0000000..4e3fb68 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png new file mode 100644 index 0000000..03b5603 Binary files /dev/null and b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png differ diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5420ff7 --- /dev/null +++ b/apps/ios/DevOpsDefender/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "filename" : "AppIcon-20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AppIcon-180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "AppIcon-76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "AppIcon-1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/DevOpsDefender/Assets.xcassets/Contents.json b/apps/ios/DevOpsDefender/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/ios/DevOpsDefender/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index aa47ed6..67acb97 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,24 +1,1152 @@ 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: .top) { + Palette.background.ignoresSafeArea() + + switch viewModel.appMode { + case .chooser: + LaunchView(viewModel: viewModel) + case .fleet: + AgentListView(viewModel: viewModel) + case .session: + sessionView + } + } + .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 + } + } + } + + /// 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) + + 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) + .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) + } + 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) { + 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…" + } +} + +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 { - Section("Pairing") { - LabeledContent("Device key", value: "Not generated") - Button("Generate enrollment URL") {} + 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 - Section("Agents") { - ContentUnavailableView("No agents", systemImage: "server.rack") + 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() } } } - .navigationTitle("DevOps Defender") } } } -#Preview { - ContentView() +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) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 10).fill(tile) + ) + .foregroundStyle(foreground) + } + .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 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() } + } + } + } + } +} + +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) + } + .padding(.vertical, 2) + } + + 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" + } + } + + 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 new file mode 100644 index 0000000..7eac9db --- /dev/null +++ b/apps/ios/DevOpsDefender/DDClientBridge.swift @@ -0,0 +1,1332 @@ +import Foundation +import SwiftUI + +struct AgentSettings: Sendable { + var agentURL: String + var keyPath: String +} + +struct DDClientError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } +} + +enum DDClientBridge { + static func importKey(keyPath: String, keyContent: String) throws { + 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([ + "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, + "max_bytes": 32768 + ], 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, + 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 + ] + 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], + 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 + 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") + } + 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) + } +} + +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 + } + + @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) + } + + 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) + .first ?? URL(fileURLWithPath: NSHomeDirectory()) + return base + .appendingPathComponent("devopsdefender", isDirectory: true) + .appendingPathComponent("noise.key") + .path + } +} + +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("") + @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() + } + + /// 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 { + return "No linked session" + } + return "Session \(String(id.prefix(8)))" + } + + func openMobileLink(_ url: URL) { + guard url.scheme == "devopsdefender", + url.host == "session", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + status = "Unsupported mobile link" + return + } + + var query: [String: String] = [:] + for item in components.queryItems ?? [] { + query[item.name] = item.value + } + + guard let agent = query["agent"], !agent.isEmpty, + let id = query["id"], !id.isEmpty else { + status = "Mobile link missing agent or session" + return + } + + 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 = [] + appMode = .session + + 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) + } + + 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", startStreamAfterInitialLoad: true) { + try DDClientBridge.importKey(keyPath: path, keyContent: key) + return initialTranscriptUpdate(id: id, settings: settings) + } + } + + private func run( + _ pendingStatus: String, + startStreamAfterInitialLoad: Bool = false, + work: @escaping @Sendable () throws -> ClientUpdate + ) { + guard !isBusy else { + status = "Already loading" + return + } + isBusy = true + status = pendingStatus + Task { + do { + let update = try await Task.detached(priority: .userInitiated) { + try work() + }.value + status = update.status + apply(update) + if startStreamAfterInitialLoad { + startAttachStream() + } + } catch { + status = error.localizedDescription + } + isBusy = false + } + } + + private func startAttachStream() { + let id = selectedSessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { + return + } + attachStream?.stop() + let settings = AgentSettings( + agentURL: agentURL.trimmingCharacters(in: .whitespacesAndNewlines), + keyPath: keyPath.expandingTildePath + ) + do { + attachStream = try DDClientBridge.startAttachStream(id: id, settings: settings) { [weak self] event in + Task { @MainActor in + self?.handleStreamEvent(event, id: id) + } + } + } catch { + status = error.localizedDescription + } + } + + private func handleStreamEvent(_ event: AttachStreamEvent, id: String) { + 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 { + return + } + 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 } + 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() + } + } + + private func performRefresh() { + let rendered = terminalRenderer.renderedText() + 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 + } + } +} + +private struct ClientUpdate: Sendable { + var status: String + var terminalText: String +} + +private extension String { + var expandingTildePath: String { + (self as NSString).expandingTildeInPath + } +} + +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 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 ClientUpdate(status: "Loaded \(id)", terminalText: "") +} + +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 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 { + 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 +} + +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: [[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 + self.maxRows = maxRows + 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 { + renderableRows + .map { row -> String in + let scalars = String.UnicodeScalarView(row.map { $0.scalar }) + return trimTrailingSpaces(String(scalars)) + } + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// 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 + } + } + + private func put(_ scalar: UnicodeScalar) { + clampCursor() + 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) + 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) + } + 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 == 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 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 isCSIControlFinal(_ value: UInt32) -> Bool { + switch value { + case 0x41, 0x42, 0x43, 0x44, 0x47, 0x48, 0x4A, 0x4B, 0x66, 0x68, 0x6C, 0x6D: + return true + default: + return false + } + } + + 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 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 "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) + 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) { + 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] = StyledCell.blank + } + } + 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) -> [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 { + var line = line + while line.last == " " || line.last == "\t" { + line.removeLast() + } + 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 new file mode 100644 index 0000000..a6f1997 --- /dev/null +++ b/apps/ios/DevOpsDefender/DDClientFFI.h @@ -0,0 +1,27 @@ +#ifndef DD_CLIENT_FFI_H +#define DD_CLIENT_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +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); +void dd_client_string_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/apps/ios/DevOpsDefender/Info.plist b/apps/ios/DevOpsDefender/Info.plist index 397d9c9..fa2476d 100644 --- a/apps/ios/DevOpsDefender/Info.plist +++ b/apps/ios/DevOpsDefender/Info.plist @@ -16,9 +16,20 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.1 + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + DevOps Defender Session + CFBundleURLSchemes + + devopsdefender + + + CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS UIApplicationSceneManifest @@ -28,4 +39,3 @@ - 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/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/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/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..0f703ba --- /dev/null +++ b/apps/ios/DevOpsDefender/Services/FleetAPIClient.swift @@ -0,0 +1,76 @@ +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] { + 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) + } + } +} 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..cfc9bf8 --- /dev/null +++ b/apps/ios/DevOpsDefender/Services/OAuthService.swift @@ -0,0 +1,92 @@ +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 `/oauth/ios/start?pubkey=&label=` runs the +/// usual GitHub flow then 302s back to `devopsdefender://oauth/callback?token=` +/// — 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) 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 { + /// 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 { + MainActor.assumeIsolated { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + return keyWindow ?? ASPresentationAnchor() + } + } +} 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/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/apps/ios/README.md b/apps/ios/README.md index 8e12b9e..ebced8f 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,55 +1,149 @@ # iOS Client -The iOS client should be a native SwiftUI app backed by the Rust client core. +Native SwiftUI companion for `dd-client` CLI sessions. Two entry paths: -Initial split: +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. -- 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. +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. -First screen to build: +## Desktop Flow (mobile-link path) -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. +Start or reattach a CLI session first: -The iOS app should not embed a browser shell or PWA. It should be a native -client using the same core as the CLI. +```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" +``` -macOS testing target: +Detach without closing the session with `Ctrl-]`, then list sessions if needed: -- 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 +cargo run -p dd-client -- sessions \ + --url "$AGENT_URL" \ + --key "$HOME/.config/devopsdefender/noise.key" \ + --insecure-skip-quote-verify +``` -Generate/open the starter project with XcodeGen: +Generate the iOS link: ```bash -cd apps/ios -xcodegen generate -open DevOpsDefender.xcodeproj +cargo run -p dd-client -- mobile-link \ + --url "$AGENT_URL" \ + --key "$HOME/.config/devopsdefender/noise.key" \ + --id "$SESSION_ID" +``` + +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; 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 + +```bash +brew install xcodegen +rustup target add aarch64-apple-ios aarch64-apple-ios-sim ``` -Run the iOS app on Apple Silicon macOS through iOS compatibility mode: +On Intel simulator hosts, also install: + +```bash +rustup target add x86_64-apple-ios +``` + +## Generate Project ```bash cd apps/ios -chmod +x run-designed-for-ipad-on-mac.sh -./run-designed-for-ipad-on-mac.sh +xcodegen generate +open DevOpsDefender.xcodeproj ``` -If destination discovery fails, pass the `My Mac (Designed for iPad)` id from -`xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender -showdestinations`: +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 + +### 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: ```bash -DD_IOS_MAC_DEVICE_ID=00008122-000121C20AF1001C ./run-designed-for-ipad-on-mac.sh +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 <<EOF +DEVELOPMENT_TEAM = $DEVELOPMENT_TEAM +DD_PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID +DD_MARKETING_VERSION = $MARKETING_VERSION +DD_BUILD_NUMBER = $BUILD_NUMBER +EOF + +xcodebuild \ + -allowProvisioningUpdates \ + "${AUTH_ARGS[@]}" \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "generic/platform=iOS" \ + -archivePath "$ARCHIVE_PATH" \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + DD_PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + DD_MARKETING_VERSION="$MARKETING_VERSION" \ + DD_BUILD_NUMBER="$BUILD_NUMBER" \ + archive + +EXPORT_OPTIONS="$BUILD_DIR/ExportOptions.TestFlight.plist" +cat > "$EXPORT_OPTIONS" <<EOF +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>destination</key> + <string>upload</string> + <key>method</key> + <string>app-store-connect</string> + <key>teamID</key> + <string>$DEVELOPMENT_TEAM</string> + <key>signingStyle</key> + <string>automatic</string> + <key>manageAppVersionAndBuildNumber</key> + <false/> + <key>testFlightInternalTestingOnly</key> + <$INTERNAL_ONLY_PLIST/> + <key>uploadSymbols</key> + <true/> +</dict> +</plist> +EOF + +xcodebuild \ + -allowProvisioningUpdates \ + "${AUTH_ARGS[@]}" \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_OPTIONS" diff --git a/apps/ios/Scripts/build-rust.sh b/apps/ios/Scripts/build-rust.sh new file mode 100755 index 0000000..7065c8d --- /dev/null +++ b/apps/ios/Scripts/build-rust.sh @@ -0,0 +1,102 @@ +#!/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 + 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..381085e 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -3,21 +3,38 @@ 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 + 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: + - "$(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..292f86c 100755 --- a/apps/ios/run-designed-for-ipad-on-mac.sh +++ b/apps/ios/run-designed-for-ipad-on-mac.sh @@ -5,9 +5,11 @@ cd "$(dirname "$0")" PROJECT="DevOpsDefender.xcodeproj" SCHEME="DevOpsDefender" -BUNDLE_ID="com.devopsdefender.client" -DEVICE_ID="${DD_IOS_MAC_DEVICE_ID:-}" +BUNDLE_ID="${DD_BUNDLE_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 @@ -19,35 +21,67 @@ 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 "$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=<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=<apple-team-id> $0" >&2 + echo "find team ids in Xcode Accounts or with: security find-identity -v -p codesigning" >&2 + 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 <<EOF +DEVELOPMENT_TEAM = $DEVELOPMENT_TEAM +DD_PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID +EOF + xcodebuild \ + -allowProvisioningUpdates \ -project "$PROJECT" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ - -destination "platform=macOS,id=$DEVICE_ID" \ + -destination "platform=macOS,id=$MAC_DESTINATION_ID" \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + DD_PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ build DERIVED_DATA_DIR="$( @@ -55,6 +89,8 @@ DERIVED_DATA_DIR="$( -project "$PROJECT" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + DD_PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ -showBuildSettings \ -json \ | plutil -extract 0.buildSettings.BUILT_PRODUCTS_DIR raw -o - - @@ -66,5 +102,33 @@ 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 <<EOF +Built signed iOS app for "My Mac (Designed for iPad)" at: +$APP + +Xcode exposes the local Mac compatibility destination to xcodebuild, but it is +not listed by CoreDevice/devicectl. To run it on this Mac, open: + + $PROJECT + +Then select "My Mac (Designed for iPad)" and press Run. + +The script also wrote Config/Signing.local.xcconfig so Xcode uses: + + DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM + DD_PRODUCT_BUNDLE_IDENTIFIER=$BUNDLE_ID + +For a physical iPhone or iPad, pass a CoreDevice id from: + + xcrun devicectl list devices + +Example: + + DD_DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM DD_BUNDLE_ID=$BUNDLE_ID DD_COREDEVICE_ID=<device-id> $0 +EOF diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 6778815..991a7b3 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -1,11 +1,11 @@ -use std::path::PathBuf; +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,32 +23,31 @@ struct Cli { #[derive(Subcommand)] enum Command { Keygen(KeygenArgs), - Pubkey(KeyOnlyArgs), - Recipes(ConnectArgs), + MobileLink(MobileLinkArgs), Sessions(ConnectArgs), - Create(CreateArgs), - Replay(SessionArgs), - Resize(ResizeArgs), Close(SessionArgs), Attach(SessionArgs), Shell(CreateArgs), - Exec(ExecArgs), } #[derive(Args)] -struct KeyOnlyArgs { +struct KeygenArgs { #[arg(long)] key: PathBuf, + #[arg(long)] + cp_url: Option<String>, + #[arg(long)] + label: Option<String>, } #[derive(Args)] -struct KeygenArgs { +struct MobileLinkArgs { #[arg(long)] - key: PathBuf, + url: String, #[arg(long)] - cp_url: Option<String>, + id: String, #[arg(long)] - label: Option<String>, + key: PathBuf, } #[derive(Args, Clone)] @@ -89,28 +88,6 @@ struct SessionArgs { id: String, } -#[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<String>, -} - #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -126,60 +103,86 @@ async fn main() -> anyhow::Result<()> { println!("{}", enrollment_url(cp_url, &pubkey, label)); } } - Command::Pubkey(args) => { - println!("{}", public_key_hex(&args.key).await?); - } - Command::Recipes(args) => { - let mut conn = connect(&connection_options(args)?).await?; - print_json(list_recipes(&mut conn).await?)?; + Command::MobileLink(args) => { + print_mobile_link(args).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).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?)?; } 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?; - } - 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?, - )?; + attach_session(conn, &id, Some(opts)).await?; } } Ok(()) } +async fn print_mobile_link(args: MobileLinkArgs) -> anyhow::Result<()> { + let key_hex = load_key_hex(&args.key).await?; + let link = mobile_session_url(&args.url, &args.id, &key_hex); + println!("{link}"); + + println!(); + println!("pubkey: {}", public_key_hex(&args.key).await?); + + 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: &str) -> String { + format!( + "devopsdefender://session?agent={}&id={}&skip_quote_verify=1&key={}", + percent_encode(agent_url), + percent_encode(session_id), + percent_encode(key_hex) + ) +} + +async fn load_key_hex(path: &Path) -> anyhow::Result<String> { + 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_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/Cargo.toml b/crates/dd-client-core/Cargo.toml index d2a7a8d..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" @@ -18,8 +17,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", "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 b23069d..0e898fa 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 _; @@ -11,6 +12,7 @@ use serde_json::Value; use snow::{Builder, TransportState}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::sync::{mpsc, watch}; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use x25519_dalek::{PublicKey, StaticSecret}; @@ -18,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; @@ -53,12 +56,6 @@ pub struct CreateSessionRequest { pub command: Option<String>, } -#[derive(Debug, Clone)] -pub struct ExecRequest { - pub cmd: Vec<String>, - pub timeout_secs: u64, -} - pub struct NoiseConnection { transport: TransportState, sink: WsSink, @@ -66,7 +63,7 @@ pub struct NoiseConnection { } impl NoiseConnection { - pub async fn call(&mut self, request: Value) -> anyhow::Result<Value> { + async fn call(&mut self, request: Value) -> anyhow::Result<Value> { let plain = serde_json::to_vec(&request)?; send_encrypted(&mut self.transport, &mut self.sink, &plain).await?; let cipher = next_binary(&mut self.stream) @@ -113,11 +110,6 @@ pub async fn connect(opts: &ConnectionOptions) -> anyhow::Result<NoiseConnection }) } -pub async fn list_recipes(conn: &mut NoiseConnection) -> anyhow::Result<Value> { - conn.call(serde_json::json!({"method": "shell.list_recipes"})) - .await -} - pub async fn list_sessions(conn: &mut NoiseConnection) -> anyhow::Result<Value> { conn.call(serde_json::json!({"method": "shell.list_sessions"})) .await @@ -143,15 +135,22 @@ pub async fn create_session( conn.call(Value::Object(body)).await } -pub async fn replay_session(conn: &mut NoiseConnection, id: &str) -> anyhow::Result<Value> { - conn.call(serde_json::json!({ +pub async fn replay_session( + conn: &mut NoiseConnection, + id: &str, + max_bytes: Option<usize>, +) -> anyhow::Result<Value> { + 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( +async fn resize_session( conn: &mut NoiseConnection, id: &str, cols: u64, @@ -174,16 +173,18 @@ pub async fn close_session(conn: &mut NoiseConnection, id: &str) -> anyhow::Resu .await } -pub async fn exec(conn: &mut NoiseConnection, request: &ExecRequest) -> anyhow::Result<Value> { - 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, + resize_opts: Option<ConnectionOptions>, +) -> 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}"); + } + } -pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Result<()> { let ack = conn .call(serde_json::json!({ "method": "shell.attach_session", @@ -198,12 +199,23 @@ 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]; + 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 { @@ -231,9 +243,84 @@ pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Resu } } } + if let Some(task) = resize_task { + task.abort(); + } + Ok(()) +} + +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<()> +where + F: FnMut(&[u8]) -> anyhow::Result<()> + Send, +{ + 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)?); + } + 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; + } + } + 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; + }; + let mut plain = vec![0u8; cipher.len()]; + let n = conn.transport.read_message(&cipher, &mut plain)?; + on_bytes(&plain[..n])?; + } + } + } + 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, @@ -241,6 +328,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<TerminalSize>, +) { + 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<TerminalSize>, +) { +} + +fn current_terminal_size() -> Option<TerminalSize> { + #[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<TerminalSize> { + if unsafe { libc::isatty(fd) } != 1 { + return None; + } + + let mut winsize = std::mem::MaybeUninit::<libc::winsize>::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, @@ -261,7 +427,7 @@ pub fn session_id(value: &Value) -> anyhow::Result<String> { .ok_or_else(|| anyhow!("create response did not include a session id: {value}")) } -pub async fn load_or_create_key(path: &Path) -> anyhow::Result<StaticSecret> { +async fn load_or_create_key(path: &Path) -> anyhow::Result<StaticSecret> { match tokio::fs::read(path).await { Ok(bytes) if bytes.len() == 32 => { let mut key = [0u8; 32]; @@ -283,7 +449,7 @@ pub async fn public_key_hex(path: &Path) -> anyhow::Result<String> { Ok(public_hex(&secret)) } -pub fn public_hex(secret: &StaticSecret) -> String { +fn public_hex(secret: &StaticSecret) -> String { hex::encode(PublicKey::from(secret).as_bytes()) } @@ -296,11 +462,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/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..b34b546 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -1,15 +1,103 @@ use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use std::path::Path; +use std::future::Future; +use std::os::raw::{c_char, c_void}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::thread; + +use base64::Engine as _; +use dd_client_core::{ + attach_session_stream, connect, list_sessions, public_key_hex, replay_session, + ConnectionOptions, IntelTrustAuthority, QuoteVerification, +}; +use std::collections::HashMap; +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"; +const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; +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<bool>, + input: mpsc::UnboundedSender<Vec<u8>>, +} + +static NEXT_STREAM_ID: AtomicU64 = AtomicU64::new(1); +static STREAMS: OnceLock<Mutex<HashMap<u64, StreamControl>>> = OnceLock::new(); #[no_mangle] -pub extern "C" fn dd_client_keygen( +pub extern "C" fn dd_client_import_key( key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, + key_content: *const c_char, ) -> *mut c_char { - let result = keygen_response(key_path, cp_url, label); - into_c_string(result) + 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] +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, + callback: Option<StreamCallback>, + 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 +/// +/// `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] @@ -24,12 +112,120 @@ pub unsafe extern "C" fn dd_client_string_free(value: *mut c_char) { let _ = unsafe { CString::from_raw(value) }; } -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) { +fn attach_stream_start( + request_json: *const c_char, + callback: Option<StreamCallback>, + context: *mut c_void, +) -> Result<u64, String> { + 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 (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, + input: input_tx, + }, + ); + + 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, + shutdown_rx, + Some(input_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<HashMap<u64, StreamControl>> { + STREAMS.get_or_init(|| Mutex::new(HashMap::new())) +} + +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, @@ -38,36 +234,118 @@ fn keygen_response( } } -fn keygen( +fn import_key( key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, + key_content: *const c_char, ) -> Result<serde_json::Value, String> { 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 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 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, - }; + 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!({ + "ok": true, + "operation": "import_key", + "key_path": key_path, + })) +} + +fn replay_session_response(request_json: *const c_char) -> serde_json::Value { + match replay_session_request(request_json) { + Ok(value) => value, + Err(error) => serde_json::json!({ + "ok": false, + "error": error, + }), + } +} +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, - "pubkey_hex": pubkey_hex, - "enrollment_url": enrollment_url, + "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 = + 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<ConnectionOptions, String> { + 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<String, String> { optional_c_string(ptr)?.ok_or_else(|| format!("{name} is required")) } @@ -83,6 +361,150 @@ fn optional_c_string(ptr: *const c_char) -> Result<Option<String>, String> { Ok(Some(s)) } +fn required_json_string(request: &serde_json::Value, name: &str) -> Result<String, String> { + optional_json_string(request, name)?.ok_or_else(|| format!("{name} is required")) +} + +fn optional_json_string(request: &serde_json::Value, name: &str) -> Result<Option<String>, 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<Option<bool>, 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<Option<u64>, 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<Option<usize>, String> { + Ok(u64_json_field(request, name)?.map(|value| value as usize)) +} + +fn block_on_ffi<M, F, T>(make_future: M) -> Result<T, String> +where + M: FnOnce() -> F + Send + 'static, + F: Future<Output = T> + 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<M, F, T, E>(make_future: M) -> Result<T, String> +where + M: FnOnce() -> F + Send + 'static, + F: Future<Output = Result<T, E>> + 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<Result<tokio::runtime::Runtime, String>> = 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 { + 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<String, serde_json::Value> { + 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}"}}"#)); @@ -98,30 +520,28 @@ mod tests { use super::*; #[test] - fn keygen_returns_enrollment_url() { + fn import_key_accepts_hex_content() { 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 key_path = dir.path().join("noise.key"); + 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 = keygen_response(key_path.as_ptr(), cp_url.as_ptr(), label.as_ptr()); + let value = import_key_response(key_path_c.as_ptr(), key_content_c.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" - ); + assert_eq!(std::fs::read(key_path).unwrap(), vec![7u8; 32]); } #[test] - fn keygen_rejects_missing_key_path() { - let value = keygen_response(std::ptr::null(), std::ptr::null(), std::ptr::null()); + fn connection_options_requires_ita_key_when_verification_enabled() { + let request: serde_json::Value = serde_json::json!({ + "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_eq!(value["ok"], false); - assert!(value["error"].as_str().unwrap().contains("key_path")); + assert!(error.contains("ita_api_key")); } }