Skip to content

Simplify iOS as linked session transcript viewer#1

Open
posix4e wants to merge 18 commits into
mainfrom
ios-rust-direct-noise
Open

Simplify iOS as linked session transcript viewer#1
posix4e wants to merge 18 commits into
mainfrom
ios-rust-direct-noise

Conversation

@posix4e
Copy link
Copy Markdown
Member

@posix4e posix4e commented May 10, 2026

Summary

  • Simplifies the iOS app into a passive desktop-linked session viewer: open a devopsdefender://session?... link, import the embedded Noise key, load bounded transcript history, then follow live transcript output.
  • Makes dd-client mobile-link a single self-contained handoff path: --url, --id, and --key; the generated link always includes the Noise key and must be treated as secret.
  • Adds explicit Rust FFI calls for mobile key import and session replay, plus an attach-stream API for continuous encrypted transcript bytes without polling or terminal control.
  • Removes legacy mobile preview/control-plane functionality from the FFI and app: no keygen/enrollment export, no recipes, no session listing/creation, no attach snapshot/write fallback, and no manual key fallback.
  • Trims the CLI to the dogfood flow: keygen, shell, attach, sessions, close, and mobile-link.
  • Keeps mobile from creating sessions, listing recipes, sending input, resizing the remote PTY, or changing stream mode; mobile always tails linked sessions.
  • Updates the transcript UI to preserve terminal width with horizontal scrolling instead of wrapping/squeezing lines.
  • Adds normal CLI terminal resizing: attach/shell send the local terminal size before attach and watch SIGWINCH to resize the remote PTY through separate control RPC connections.
  • Sends WebSocket protocol pings during idle attached sessions so CLI and mobile transcript streams do not go completely quiet and get closed by intermediaries.
  • Removes the unused direct chrono dependency and narrows private core helpers that are not part of the CLI/FFI surface.
  • Adds a PR/push iOS simulator build workflow on macOS.
  • Adds a manual TestFlight upload workflow using App Store Connect API-key authentication, generated build numbers, and internal-only distribution by default.
  • Adds App Store-ready app icon assets and wires marketing/build version settings through XcodeGen.
  • Refreshes README and iOS docs around the desktop CLI handoff flow.

Verification

  • cargo fmt -p dd-client-core -p dd-client-ffi -p dd-client
  • git diff --check
  • cargo test -p dd-client-core
  • cargo test -p dd-client-ffi
  • cargo test -p dd-client
  • cargo run -p dd-client -- mobile-link --help
  • cd 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 build
  • bash -n apps/ios/Scripts/archive-testflight.sh
  • ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "ok #{f}" }' .github/workflows/ios.yml .github/workflows/testflight.yml
  • cd apps/ios && xcodegen generate
  • cd 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 build
  • Earlier in this PR: cd 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.sh
  • Earlier in this PR: installed/opened the simulator app with a production devopsdefender://session?... link and confirmed the session connected and rendered transcript output.

Notes

  • The mobile attach path only reads from shell.attach_session; it does not call shell.resize_session.
  • CLI resize events intentionally use separate control connections because the attached websocket switches to raw encrypted PTY bytes after shell.attach_session succeeds.
  • The Designed-for-iPad-on-Mac path builds and signs locally, but Xcode still needs to launch the Mac compatibility destination manually.
  • The full generic simulator build tried both arm64 and x86_64 locally and failed only because this Mac does not have the x86_64-apple-ios Rust target installed; the GitHub workflow installs both simulator targets.
  • TestFlight upload still requires the testflight GitHub environment secrets: APPLE_TEAM_ID, APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_ISSUER_ID, and APP_STORE_CONNECT_API_PRIVATE_KEY.

@posix4e
Copy link
Copy Markdown
Member Author

posix4e commented May 10, 2026

Live smoke test results after latest push:

  • CLI recipes against PR #261 succeeded and returned shell, codex-podman, podman-ubuntu, podman-alpine.
  • CLI sessions succeeded.
  • CLI create --recipe shell --name ios-smoke-test created 8c9df7f5-3eef-483d-b819-a2f59fc846d5.
  • CLI attach accepted echo IOS_SMOKE_FROM_ATTACH, then Ctrl-] detached without closing the session.
  • CLI replay returned transcript bytes containing IOS_SMOKE_FROM_ATTACH.
  • Follow-up sessions showed the smoke session still running.
  • iOS Simulator build succeeded and installed/launched on iPad (A16) simulator E643BC5D-02B6-401E-A3D1-8F4AD0C65329.
  • Simulator screenshot verified the UI renders and now defaults the key path to /Users/posix4e/.config/devopsdefender/noise.key.
  • Temporary FFI smoke binary called dd_client_agent_request for recipes, sessions, create_session, attach_exchange with echo IOS_FFI_SMOKE, and replay_session; all succeeded against the PR preview.
  • DD_DEVELOPMENT_TEAM=2ARJ2X9A88 ./run-designed-for-ipad-on-mac.sh rebuilt/signed successfully and printed the expected Xcode launch instructions for local Designed-for-iPad.

@posix4e posix4e changed the title Add Rust-backed iOS PR preview workflow Simplify iOS as linked session transcript viewer May 10, 2026
posix4e and others added 7 commits May 10, 2026 15:51
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>
@posix4e
Copy link
Copy Markdown
Member Author

posix4e commented May 11, 2026

Continuation prompt for AI assistants

The iOS Claude Code client work on this branch is split into two arcs.

ARC 1 — Interactive mobile client (LANDED, commit f341876).
Mobile can drive a live Claude Code session: bytes flow both directions over the existing Noise channel, the keyboard surface adapts to OSC 0 title state and the on-screen prompt (.generating / .choose / .confirm / .idle / .rawShell), ANSI colors render with a proper SGR parser + dual main/alt screen buffers, state updates are debounced to ~12 fps with equality-gated @Published writes, a 1 s idle tick keeps mode resolution running while the PTY is silent, and auto-reconnect uses 1/2/4/8/16/30 s exponential backoff with a manual Reconnect button. See the commit body for the full surface area.

ARC 2 — GitHub-auth fleet discovery (LANDED, commit 6bf998b).
Adds a launch chooser between the existing mobile-link path and a new fleet flow. The fleet flow:

  1. Generates a persistent iOS Noise key (new FFI: dd_client_ensure_key) at Application Support/devopsdefender/ios.key.
  2. Opens ASWebAuthenticationSession at https://app.devopsdefender.com/oauth/ios/start?pubkey=<hex>&label=<device>.
  3. Receives a bearer token via the existing devopsdefender:// custom scheme callback (devopsdefender://oauth/callback?token=<jwt>).
  4. Persists the token in Keychain (KeychainStore).
  5. Calls GET /api/v1/agents (FleetAPIClient) for the user's agents.
  6. Calls dd_client_list_sessions per chosen agent to enumerate sessions via the existing Noise shell.list_sessions RPC.
  7. Tap a session → routes into the ARC 1 keyboard surface via attachToFleetSession.

KEY POINTS for whoever picks this up:

  • The CP repo is devopsdefender/dd (separate). The endpoints listed above must exist on the CP side for end-to-end testing. The plan calls for the existing /admin/enroll page to be rebuilt as the post-auth dashboard; CLI's enrollment URL output is unchanged.
  • iOS holds its own Noise key at Application Support/devopsdefender/ios.key, distinct from the mobile-link imported key (noise.key). Mobile-link flow stays fully functional in parallel.
  • Agents are expected to poll the CP for authorized pubkeys. No JWT goes through the Noise handshake.
  • Until the CP endpoints exist, set DEBUG_FAKE_FLEET (Swift compiler flag) so FleetAPIClient.agents() returns stubbed agents and the UI is exercisable end-to-end.

To continue on another machine:

git checkout ios-rust-direct-noise && git pull
  • Read crates/dd-client-ffi/src/lib.rs to see the FFI patterns; the two new functions (dd_client_ensure_key, dd_client_list_sessions) mirror dd_client_replay_session in style.
  • Read apps/ios/DevOpsDefender/DDClientBridge.swift for ClientViewModel.appMode, attachToFleetSession, signOutOfFleet, returnToChooser.
  • Build:
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
  • Live ARC 1 testing requires a session ID. During development the user's session on https://dd-production-api-6fb52bb0-a70d-4fc7-9353-883f8035335f.devopsdefender.com was used; the user can hand you a fresh mobile-link URL for side-attach testing.
  • Live ARC 2 testing requires the CP endpoints to exist. Stub mode (DEBUG_FAKE_FLEET) is sufficient for UI iteration.

Known follow-ups (not blocking):

  • Claude's in-place progress redraws (e.g. "Checking for updates" cell-overlap) still occasionally pile up because we don't implement the full cursor + scroll-region model. Flagged as a known limitation, not a regression.
  • Migration of .github/workflows/testflight.yml + the matching iOS-side PR build to Xcode Cloud is on the table; rationale in the conversation above. Would replace the manually-managed App Store Connect API key secrets with Apple's native distribution.
  • Universal links for OAuth callback (https://app.devopsdefender.com/oauth/ios/callback) instead of the current custom scheme can be a security/UX upgrade once the CP serves an AASA file. The current devopsdefender://oauth/callback?token=... is acceptable for now per ASWebAuthenticationSession semantics.

🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant