Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
833 changes: 825 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/dd-client-core",
"crates/dd-client-session",
"crates/dd-client-cli",
"crates/dd-client-ffi",
]
Expand Down
187 changes: 178 additions & 9 deletions apps/ios/DevOpsDefender/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,193 @@
import SwiftUI

struct ContentView: View {
@StateObject private var model = SessionModel()

var body: some View {
NavigationStack {
List {
Section("Pairing") {
LabeledContent("Device key", value: "Not generated")
Button("Generate enrollment URL") {}
if model.connected {
SessionView(model: model)
.navigationTitle("Session")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Disconnect") { model.disconnect() }
}
}
} else {
ConnectForm(model: model)
.navigationTitle("DevOps Defender")
}
}
}
}

/// Connect form. `keyPath` defaults into the app's container; the device key is
/// created on first use and enrolled out-of-band via the CP `/admin/enroll` URL
/// (`keygen` returns it).
struct ConnectForm: View {
@ObservedObject var model: SessionModel
@State private var agentUrl = "https://"
@State private var sessionId = ""
@State private var insecure = false

private var keyPath: String {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return dir.appendingPathComponent("noise.key").path
}

var body: some View {
Form {
Section("Agent") {
TextField("Agent URL", text: $agentUrl)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Session ID", text: $sessionId)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Toggle("Skip quote verification", isOn: $insecure)
}
Section {
Button(model.connecting ? "Connecting…" : "Connect") {
model.connect(
agentUrl: agentUrl, keyPath: keyPath,
sessionId: sessionId, insecure: insecure)
}
.disabled(model.connecting || agentUrl.isEmpty || sessionId.isEmpty)
}
Section { Text(model.status).foregroundStyle(.secondary) }
}
}
}

/// The session: a mode picker, the streamed block document, and (in Interact)
/// an input bar.
struct SessionView: View {
@ObservedObject var model: SessionModel
@State private var draft = ""

Section("Agents") {
ContentUnavailableView("No agents", systemImage: "server.rack")
var body: some View {
VStack(spacing: 0) {
Picker("Mode", selection: Binding(get: { model.mode }, set: model.setMode)) {
Text("Watch").tag(FfiMode.watch)
Text("Interact").tag(FfiMode.interact)
Text("Raw").tag(FfiMode.raw)
}
.pickerStyle(.segmented)
.padding(8)

ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(Array(model.blocks.enumerated()), id: \.offset) { idx, block in
BlockView(block: block, interactive: model.mode == .interact) { opt, i in
model.pick(option: opt, index: i)
}
.id(idx)
}
}
.padding(.horizontal)
}
.onChange(of: model.blocks.count) { _, count in
if count > 0 { proxy.scrollTo(count - 1, anchor: .bottom) }
}
}
.navigationTitle("DevOps Defender")

if model.mode == .interact {
HStack {
TextField("Send to session…", text: $draft)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Send") {
model.send(draft + "\n")
draft = ""
}
.disabled(draft.isEmpty)
}
.padding(8)
}
}
}
}

#Preview {
ContentView()
/// Renders one block. The whole point of the design: markdown reads as markdown,
/// menus are tappable, code/diffs are monospaced — not a terminal.
struct BlockView: View {
let block: FfiBlock
let interactive: Bool
let onPick: (FfiMenuOption, Int) -> Void

var body: some View {
switch block {
case let .markdown(text, _):
Text((try? AttributedString(markdown: text)) ?? AttributedString(text))
.frame(maxWidth: .infinity, alignment: .leading)

case let .code(lang, text, _):
VStack(alignment: .leading, spacing: 2) {
if let lang { Text(lang).font(.caption2).foregroundStyle(.secondary) }
Text(text)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(8)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))

case let .diff(unified, _):
DiffView(unified: unified)

case let .menu(title, options, selected, resolved):
VStack(alignment: .leading, spacing: 6) {
if let title { Text(title).font(.headline) }
ForEach(Array(options.enumerated()), id: \.offset) { i, opt in
Button { onPick(opt, i) } label: {
HStack {
Image(systemName: selected == UInt32(i)
? "largecircle.fill.circle" : "circle")
Text(opt.label)
Spacer()
}
}
.buttonStyle(.bordered)
.disabled(!interactive || resolved)
}
}
.padding(8)
.background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))

case let .input(prompt):
Text(prompt).italic().foregroundStyle(.secondary)

case let .rawTerminal(screen):
Text(screen)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}

struct DiffView: View {
let unified: String
var body: some View {
VStack(alignment: .leading, spacing: 0) {
let lines = unified.split(separator: "\n", omittingEmptySubsequences: false)
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(String(line))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(color(for: line.first))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(6)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
}

private func color(for first: Character?) -> Color {
switch first {
case "+": return .green
case "-": return .red
case "@": return .purple
default: return .primary
}
}
}
106 changes: 106 additions & 0 deletions apps/ios/DevOpsDefender/SessionModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation
import SwiftUI

// Types referenced here — `SessionHandle`, `BlockObserver`, `FfiBlock`,
// `FfiMode`, `FfiMenuOption`, `keygen`, `KeygenResult` — come from the
// UniFFI-generated `dd-client-ffi` bindings. Generate them with:
//
// cargo build -p dd-client-ffi --release # produces the staticlib
// cargo run -p uniffi-bindgen -- generate \
// --library target/release/libdd_client_ffi.a --language swift --out-dir apps/ios/Generated
//
// then add Generated/*.swift + the static lib (as an xcframework) to the target.

/// Observable wrapper around a live `SessionHandle`. All interpretation lives in
/// Rust; this just mirrors the block snapshot into SwiftUI on change.
@MainActor
final class SessionModel: ObservableObject {
@Published var blocks: [FfiBlock] = []
@Published var mode: FfiMode = .watch
@Published var status: String = "Not connected"
@Published var connected = false
@Published var connecting = false

private var handle: SessionHandle?
private var observer: ChangeObserver?

func connect(agentUrl: String, keyPath: String, sessionId: String, insecure: Bool) {
connecting = true
status = "Connecting…"
Task.detached { [weak self] in
do {
// attach() blocks on the Noise handshake — off the main actor.
let handle = try SessionHandle.attach(
agentUrl: agentUrl,
keyPath: keyPath,
sessionId: sessionId,
insecureSkipQuoteVerify: insecure,
jwksUrl: "https://portal.trustauthority.intel.com/certs",
issuer: "https://portal.trustauthority.intel.com"
)
await self?.onAttached(handle)
} catch {
await self?.onFailed(error)
}
}
}

private func onAttached(_ handle: SessionHandle) {
self.handle = handle
let observer = ChangeObserver(model: self)
self.observer = observer
handle.subscribe(observer: observer)
self.mode = handle.mode()
self.connecting = false
self.connected = true
self.status = "Connected"
reload()
}

private func onFailed(_ error: Error) {
self.connecting = false
self.status = "Failed: \(error)"
}

func reload() {
blocks = handle?.blocks() ?? []
}

func setMode(_ newMode: FfiMode) {
handle?.setMode(mode: newMode)
mode = newMode
}

/// Send text to the session (ignored by the engine in Watch — read-only).
func send(_ text: String) {
handle?.sendText(text: text)
}

/// Pick a menu option: send its hotkey when known, else its 1-based number.
func pick(option: FfiMenuOption, index: Int) {
if let hotkey = option.hotkey {
send(hotkey)
} else {
send(String(index + 1))
}
}

func disconnect() {
handle?.close()
handle = nil
observer = nil
connected = false
status = "Disconnected"
blocks = []
}
}

/// Bridges the Rust `BlockObserver` callback onto the main actor.
final class ChangeObserver: BlockObserver {
weak var model: SessionModel?
init(model: SessionModel) { self.model = model }

func onChanged() {
Task { @MainActor [weak model] in model?.reload() }
}
}
53 changes: 40 additions & 13 deletions apps/ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,52 @@

The iOS client should be a native SwiftUI app backed by the Rust client core.

Initial split:
Split:

- 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.
- `dd-client-session` owns interpretation: blocks, the floor/agent derivers,
view modes, and history decryption — shared verbatim with the CLI.
- `dd-client-core` owns protocol behavior: pairing keys, quote verification
(verify-only, no Intel account), Noise transport, session RPCs, PTY bytes.
- `dd-client-ffi` exposes all of the above over **UniFFI** (Swift + Kotlin
generated from one Rust surface) — no hand-written C.

First screen to build:
The app is a renderer for the structured chat document the engine produces:
`SessionHandle.blocks()` returns typed `FfiBlock`s, a `BlockObserver` fires on
change, and `setMode`/`sendText` drive Watch ⇄ Interact ⇄ Raw. No protocol,
crypto, or terminal-interpretation logic lives in Swift. See `SessionModel.swift`
and `ContentView.swift`.

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.
## Generating the UniFFI bindings

The iOS app should not embed a browser shell or PWA. It should be a native
client using the same core as the CLI.
The Swift in this folder references types (`SessionHandle`, `FfiBlock`,
`FfiMode`, `keygen`, …) emitted by UniFFI. Generate them before building:

```bash
# from the repo root
cargo build -p dd-client-ffi --release
cargo run -p dd-client-ffi --bin uniffi-bindgen -- generate \
--library target/release/libdd_client_ffi.dylib \
--language swift --out-dir apps/ios/Generated
```

Then add `apps/ios/Generated/*.swift` to the target and link the Rust static
library as an `xcframework` (build `aarch64-apple-ios` + the simulator/macABI
triples and `xcodebuild -create-xcframework`). The generated `*.modulemap`
header path is wired via the xcframework.

> The Rust FFI crate is compile/clippy/test-verified on Linux. The binding
> generation and the iOS build require the Apple toolchain (Xcode), which isn't
> available in CI here — run the steps above on macOS.

First-run flow:

1. Generate or load the device key (`keygen`), Keychain-backed.
2. Show the pubkey + CP enrollment URL; enroll in an authenticated browser.
3. After enrollment, attach to a session and render its block document.

The iOS app does not embed a browser shell or PWA.

macOS testing target:

Expand Down
5 changes: 4 additions & 1 deletion crates/dd-client-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ path = "src/main.rs"
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
dd-client-core = { path = "../dd-client-core" }
dd-client-session = { path = "../dd-client-session" }
libc = "0.2"
ratatui = "0.29"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] }

Loading
Loading