Simplify iOS as linked session transcript viewer#1
Conversation
|
Live smoke test results after latest push:
|
Reverses the passive-viewer invariant from this PR's initial scope so mobile can drive a live Claude Code session, per issue #2. Bytes flow both directions over the same Noise channel, and the UI adapts to what the agent is doing. Rust + FFI - attach_session_stream now takes an Option<mpsc::UnboundedReceiver<Vec<u8>>> for input; the select loop writes incoming bytes via send_encrypted on the existing transport. - dd_client_attach_stream_send(handle, bytes, len) FFI exposes the input channel; stream registry holds the sender alongside the shutdown watch. - Header updates: <stdbool.h>, <stddef.h>, the new send signature. iOS terminal pipeline - TerminalScreenRenderer stores StyledCell instead of UnicodeScalar. Full SGR parser: bold/dim/italic/underline/inverse, 30-37/90-97 fg, 40-47/100-107 bg, 38;5;n / 48;5;n indexed-256, 38;2;r;g;b / 48;2;r;g;b truecolor, 39/49 default. Resolved against a SwiftUI Color palette. - Dual buffer: main scrollback + alternate screen (CSI ?1049h/l, ?47, ?1047). The alt buffer is discarded on exit; rendered output never exposes it, so TUI redraws no longer pollute the iOS transcript. - ESC 7 / ESC 8 / CSI s / CSI u cursor save+restore. CSI L / CSI M insert/delete lines. CSI ?1048h/l mapped to save/restore. - renderedAttributedString runs through main only and accepts a lastRows cap so per-frame work is bounded. Detection + keyboard surface - OSCSniffer extracts OSC and bare-BEL payloads from the raw byte stream before the renderer strips them. Handles 7-bit and 8-bit variants plus split-across-chunk parsing. - TitleClassifier turns OSC 0 titles into typed events (.generating, .working, .ready, .unknown), strips decorative leading glyphs from the dingbat / geometric / misc-symbols blocks, and processes events incrementally via byteOffset so replay + idle ticks don't double-count. - AppDetector picks Claude Code / Codex / OpenClaw / raw shell from transcript markers; EffectiveAppResolver fuses that with OSC title evidence so a quiet transcript can still classify as Claude Code. - KeyboardModeResolver collapses everything to one mode: .disconnected | .generating(label) | .choose(options) | .confirm | .idle(latest) | .rawShell. Title freshness beats transcript heuristics for distinguishing generating vs idle; explicit awaitingChoice / awaitingYesNo menus still win. Keyboard UI - Replaces the single action-row with mode-driven panels: .generating shows a single full-width Interrupt button; .choose mirrors Claude's numbered list as full-width tappable rows; .confirm shows Claude's actual 3-row menu (Yes / Yes-don't-ask / No-tell-me-different); .idle shows an integrated composer. - Menu rail under the contextual panel: Commands (searchable slash catalog), History (sent-input recall), Mode (Normal/Plan/Auto with Shift+Tab cycler), Keys (Tab, Shift+Tab, Esc Esc, Ctrl-R, Ctrl-L, Ctrl-C, Ctrl-D, arrows, Backspace, Newline). Persistent nav strip (↑ ↓ ⏎ Esc · ⌃C) below. - SpecialKey covers Claude Code chords: newline (0x0A), shiftTab (ESC [ Z), escEsc, ctrlR, ctrlL. - Transcript pane runs on a terminal-dark background so the ANSI palette has the contrast it was designed for; the rest of the chrome stays cream. Reliability - apply() feeds bytes into renderer + OSC sniffer immediately but schedules UI state updates at most every 80ms; every @published assignment is gated by an equality check so identical menu options don't force SwiftUI to rebuild every row. - 1s idle tick keeps refreshKeyboardMode() running while the PTY is silent so the UI can transition out of .generating without new bytes arriving. - Auto-reconnect with 1/2/4/8/16/30s exponential backoff on stream close/error; manual "Reconnect" button surfaced when disconnected. - Events debug sheet exposes captured OSC 0 titles + raw OSC/BEL payloads so the schema can be iterated against live agents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second way into the iOS app: sign in with GitHub on the device,
browse the control plane's agents, list sessions per agent, attach.
The mobile-link path is preserved — a launch chooser routes between
the two on cold start.
FFI
- dd_client_ensure_key(path): generate the iOS device's persistent
Noise key on demand, return the pubkey hex. Lets Swift avoid spelling
X25519 in CryptoKit. Idempotent.
- dd_client_list_sessions(request_json): wraps the existing
shell.list_sessions Noise RPC behind a one-shot FFI call so the
SessionListView doesn't reimplement the Noise stack in Swift.
- DDClientFFI.h updated with both signatures.
iOS services
- KeychainStore: bearer token + CP URL override under a single service
identifier; reset() wipes everything on sign-out.
- AppKeyStore: owns Application Support/devopsdefender/ios.key, separate
from the mobile-link key (noise.key) so the two flows don't overwrite
each other.
- OAuthService: ASWebAuthenticationSession wrapper that opens
{cp}/oauth/ios/start?pubkey=&label= and parses the bearer token from
the devopsdefender://oauth/callback?token=... redirect. Reuses the
existing custom URL scheme.
- FleetAPIClient: URLSession client for GET {cp}/api/v1/agents with
Bearer auth. Stubbed under DEBUG_FAKE_FLEET so the UI is exercisable
before the CP endpoint exists. Throws typed FleetError; 401 triggers
signOutOfFleet() in the caller.
iOS models / views
- AgentSummary: decodable matching {id, label, agent_url, last_seen_at}.
- SessionSummary: best-effort parser for the agent's list_sessions
response (accepts {sessions: [...]} or a bare array, multiple field
name variants for id/name/recipe/started_at).
- LaunchView: cold-start chooser with "Sign in with GitHub" and a
description of the mobile-link fallback. Drives the OAuth flow.
- AgentListView: lists agents from FleetAPIClient.agents(). Sign-out
button in the nav bar. Tap → SessionListView.
- SessionListView: calls dd_client_list_sessions for the picked agent
off the main actor; tap → viewModel.attachToFleetSession() which
reuses the iOS device key (not the mobile-link key).
Routing
- ClientViewModel.appMode: .chooser | .fleet | .session. ContentView
switches on it. openMobileLink advances to .session; enterFleet to
.fleet; returnToChooser / signOutOfFleet back to .chooser.
- Back-to-chooser chevron on the session status bar.
Plan + CP dependencies
- Plan file: .claude/plans/pure-watching-lamport.md.
- Out of scope for this commit (depends on devopsdefender/dd):
/oauth/ios/start endpoint (GitHub OAuth start),
/oauth/ios/callback (302 to devopsdefender://oauth/callback?token=),
/api/v1/agents (list authorized agents),
agent-side polling of authorized pubkeys from the CP,
rebuilding /admin/enroll as the dashboard.
- Until those exist, set DEBUG_FAKE_FLEET to exercise the UI with
stubbed agents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuation prompt for AI assistantsThe iOS Claude Code client work on this branch is split into two arcs. ARC 1 — Interactive mobile client (LANDED, commit ARC 2 — GitHub-auth fleet discovery (LANDED, commit
KEY POINTS for whoever picks this up:
To continue on another machine: git checkout ios-rust-direct-noise && git pull
cargo fmt -p dd-client-core -p dd-client-ffi -p dd-client
cargo test -p dd-client-core -p dd-client-ffi
cd apps/ios && xcodegen generate && \
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender \
-configuration Debug \
-destination 'platform=iOS Simulator,id=2F7A1C9F-59F8-4AB0-90A9-709FD8726A27' \
DD_PRODUCT_BUNDLE_IDENTIFIER=com.posix4e.devopsdefender.client \
CODE_SIGNING_ALLOWED=NO build
Known follow-ups (not blocking):
🤖 Generated with Claude Code |
…eIsolated The presentationAnchor delegate from ASWebAuthenticationSession is invoked on the main thread. The previous implementation called DispatchQueue.main.sync inside it, which is a re-entrant wait on the same queue and traps with EXC_BREAKPOINT (__DISPATCH_WAIT_FOR_QUEUE__) the moment the user taps "Sign in with GitHub". Use MainActor.assumeIsolated instead — it asserts we're on the main actor and provides synchronous access to main-actor-isolated UIKit state without dispatching. Same effect as accessing UIApplication.shared directly, without the compile error from a nonisolated context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- FleetAPIClient: remove the DEBUG_FAKE_FLEET compile-flagged fake-agents branch. Real CP only; when the endpoint isn't there (currently the case), iOS surfaces the actual error from the API call. - apps/ios/README.md: replace the stale "intentionally does not create sessions, list recipes, browse agents, send input" paragraph (which described the pre-PR-#1 surface) with the current state — two paths (mobile-link and fleet sign-in), interactive client with keystroke forwarding, mode-driven keyboard. Document the planned Xcode Cloud migration path so the GH Actions ios.yml + testflight.yml workflows can be retired once the Xcode Cloud workflows are up. - .gitignore: ignore the .claude/ runtime scratch directory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
devopsdefender://session?...link, import the embedded Noise key, load bounded transcript history, then follow live transcript output.dd-client mobile-linka single self-contained handoff path:--url,--id, and--key; the generated link always includes the Noise key and must be treated as secret.keygen,shell,attach,sessions,close, andmobile-link.attach/shellsend the local terminal size before attach and watchSIGWINCHto resize the remote PTY through separate control RPC connections.chronodependency and narrows private core helpers that are not part of the CLI/FFI surface.Verification
cargo fmt -p dd-client-core -p dd-client-ffi -p dd-clientgit diff --checkcargo test -p dd-client-corecargo test -p dd-client-fficargo test -p dd-clientcargo run -p dd-client -- mobile-link --helpcd apps/ios && xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender -configuration Debug -destination 'platform=iOS Simulator,id=2F7A1C9F-59F8-4AB0-90A9-709FD8726A27' DD_PRODUCT_BUNDLE_IDENTIFIER=com.posix4e.devopsdefender.client CODE_SIGNING_ALLOWED=NO buildbash -n apps/ios/Scripts/archive-testflight.shruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "ok #{f}" }' .github/workflows/ios.yml .github/workflows/testflight.ymlcd apps/ios && xcodegen generatecd apps/ios && xcodebuild -project DevOpsDefender.xcodeproj -scheme DevOpsDefender -configuration Debug -destination 'generic/platform=iOS Simulator' ARCHS=arm64 DD_PRODUCT_BUNDLE_IDENTIFIER=com.devopsdefender.client.ci CODE_SIGNING_ALLOWED=NO buildcd apps/ios && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer DD_DEVELOPMENT_TEAM=2ARJ2X9A88 DD_BUNDLE_ID=com.posix4e.devopsdefender.client ./run-designed-for-ipad-on-mac.shdevopsdefender://session?...link and confirmed the session connected and rendered transcript output.Notes
shell.attach_session; it does not callshell.resize_session.shell.attach_sessionsucceeds.x86_64-apple-iosRust target installed; the GitHub workflow installs both simulator targets.testflightGitHub environment secrets:APPLE_TEAM_ID,APP_STORE_CONNECT_API_KEY_ID,APP_STORE_CONNECT_API_ISSUER_ID, andAPP_STORE_CONNECT_API_PRIVATE_KEY.