Files
scarf/scarf/Scarf iOS/App/ScarfGoTabRoot.swift
T
Alan Wizemann 799332fbcd feat(hermes-v12): iOS catch-up — Webhooks/Plugins/Profiles read-only + version banner (Phase H)
Closes the iOS read-only inspection gap on three CLI-driven Hermes
surfaces and adds a Hermes-version banner so mobile users on remote
v0.11 hosts see the upgrade nudge inline.

Components:

- Scarf iOS/Components/HermesVersionBanner.swift — yellow banner shown
  on the Dashboard when the active server's HermesCapabilities returns
  detected==true && hasCurator==false. One-tap session dismiss; comes
  back on next app open. Lists the v0.12 capabilities the user is
  missing out on (curator, multimodal, new providers).

- Scarf iOS/Webhooks/WebhooksView.swift — read-only list rendered from
  `hermes webhook list`. Tolerant block parser mirrors the Mac
  WebhooksViewModel shape so future drift fixes in one canonical place
  if/when promoted into ScarfCore. Detects the "platform not enabled"
  state and shows a setup-required pane instead of synthesizing rows
  from instructional text.

- Scarf iOS/Plugins/PluginsView.swift — filesystem-first scan over
  `~/.hermes/plugins/<name>/` with plugin.json / plugin.yaml manifest
  reads (mirrors the Mac VM). Enabled/disabled badge, version, source.
  Uses HermesYAML.parseNestedYAML / stripYAMLQuotes from ScarfCore
  (already public).

- Scarf iOS/Profiles/ProfilesView.swift — `hermes profile list` text
  parser with active-profile highlighting from
  `~/.hermes/active_profile`. Defensively handles both Rich box-drawn
  table output and plain-text fallback.

ScarfGoTabRoot's System tab gains an "Inspect" section with the three
new NavigationLinks. None are capability-gated — the underlying
list verbs exist on both v0.11 and v0.12, so the read views work
against either Hermes version without surprises.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:58:28 +02:00

402 lines
18 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfIOS
import ScarfDesign
/// ScarfGo's primary navigation surface. v2.5 expands the original
/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs
/// with Chat in the mathematical center:
///
/// Dashboard | Projects | Chat | Skills | System
///
/// "Chat in the middle" is the v2.5 product ask chat is the action
/// users come back for, so it's the most thumb-reachable slot on a
/// phone-sized device. We stay on Apple's native `TabView` instead of
/// drawing a custom raised center button: 5 tabs is exactly the iPhone
/// system maximum (no auto-collapse to "More"), and `.sidebarAdaptable`
/// continues to give us a real sidebar on iPad / macCatalyst for free.
/// Memory drops out of primary slots and lives inside the renamed
/// "System" tab (was "More"). Skills graduates from a System sub-row
/// into its own primary tab to match v2.5's full Mac parity for skills
/// (Installed / Browse Hub / Updates).
///
/// Each tab wraps its feature view in its own `NavigationStack` so push
/// navigation (Cron editor, Memory detail, Project detail, etc.) stays
/// scoped to the tab instead of bleeding across.
struct ScarfGoTabRoot: View {
let serverID: ServerID
let config: IOSServerConfig
let key: SSHKeyBundle
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
/// Stable per-tab context UUID used for the System tab's Curator
/// row so its CuratorViewModel reuses the cached SSH connection
/// keyed by this id rather than building a fresh one. Same pattern
/// as `sharedContextID` on ChatView.
static let systemTabContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
/// One coordinator per server-connected session. Cross-tab
/// signalling (Dashboard row Chat tab resume, Project Detail
/// in-project chat handoff, notification deep-link Chat) flows
/// through here.
@State private var coordinator = ScarfGoCoordinator()
/// Hermes version + capability flags for this remote. Drives the
/// iOS version banner (v0.11 hosts get a yellow "update for new
/// features" banner) and capability-gated affordances like ACP
/// image attachments. Constructed once per server connection so
/// the detection runs over the active SSH transport.
@State private var capabilities: HermesCapabilitiesStore
init(
serverID: ServerID,
config: IOSServerConfig,
key: SSHKeyBundle,
onSoftDisconnect: @escaping @MainActor () async -> Void,
onForget: @escaping @MainActor () async -> Void
) {
self.serverID = serverID
self.config = config
self.key = key
self.onSoftDisconnect = onSoftDisconnect
self.onForget = onForget
let ctx = config.toServerContext(id: serverID)
_capabilities = State(initialValue: HermesCapabilitiesStore(context: ctx))
}
/// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active
/// tab doesn't fire while the tab is unmounted the coordinator
/// is the single source of truth for scene-phase transitions
/// across all tabs.
@Environment(\.scenePhase) private var scenePhase
var body: some View {
// The transport factory is keyed by ServerID, so the correct
// Keychain slot + config is picked automatically. Reuses the
// server's own id as the context id so the CitadelServerTransport
// pool caches per-server (instead of the singleton we had
// pre-M9). Two active servers two connection holders, no
// SSH channel contention.
let ctx = config.toServerContext(id: serverID)
TabView(selection: $coordinator.selectedTab) {
// 1 Dashboard: stats + recent sessions.
NavigationStack {
DashboardView(config: config, key: key, onSoftDisconnect: onSoftDisconnect)
}
.tabItem {
Label("Dashboard", systemImage: "gauge.with.needle")
}
.tag(ScarfGoCoordinator.Tab.dashboard)
.accessibilityLabel("Dashboard tab")
// 2 Projects: registered projects per-project dashboard,
// site, and sessions. Read-only registry on iOS add /
// rename / archive happens in the Mac app.
NavigationStack {
ProjectsListView(config: config)
}
.tabItem {
Label("Projects", systemImage: "square.grid.2x2")
}
.tag(ScarfGoCoordinator.Tab.projects)
.accessibilityLabel("Projects tab")
// 3 Chat: the reason the app is on your phone. Centered
// among the 5 tabs for thumb reach + visual prominence.
NavigationStack {
ChatView(config: config, key: key)
}
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
.tag(ScarfGoCoordinator.Tab.chat)
.accessibilityLabel("Chat tab")
// 4 Skills: Installed | Browse Hub | Updates, mirroring
// the Mac app's 3-tab skills surface.
NavigationStack {
SkillsView(config: config)
}
.tabItem {
Label("Skills", systemImage: "lightbulb")
}
.tag(ScarfGoCoordinator.Tab.skills)
.accessibilityLabel("Skills tab")
// 5 System: server identity, Memory, Cron, Settings, plus
// the destructive disconnect / forget actions. Renamed from
// "More" to match the user-facing v2.5 vocabulary; the
// .sidebarAdaptable system fallback label happens not to
// matter here because we never overflow.
NavigationStack {
SystemTab(
config: config,
onSoftDisconnect: onSoftDisconnect,
onForget: onForget
)
}
.tabItem {
Label("System", systemImage: "gearshape.fill")
}
.tag(ScarfGoCoordinator.Tab.system)
.accessibilityLabel("System tab")
}
// Pulls the sidebar-on-iPad affordance into the same code path
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
.tabViewStyle(.sidebarAdaptable)
.environment(\.serverContext, ctx)
.environment(\.scarfGoCoordinator, coordinator)
.environment(capabilities)
.hermesCapabilities(capabilities)
.onAppear {
// Give the notification router a handle to this session's
// coordinator so notification-taps can route across tabs.
// Weak ref coordinator owns its own lifetime, router
// just observes.
NotificationRouter.shared.coordinator = coordinator
}
// Funnel scene-phase transitions through the coordinator so
// tab view-models (notably ChatController) can react even
// when their tab isn't currently on-screen.
.onChange(of: scenePhase) { _, newPhase in
coordinator.setScenePhase(newPhase)
}
}
}
/// Server identity + Memory + Cron + Settings + destructive actions.
/// "System" reads as configuration / server-meta; the reorganization
/// in v2.5 promotes Skills out of here into its own primary tab and
/// pulls Memory in from a primary tab into a NavigationLink row.
///
/// Kept private to this file because we don't expect it to be reused
/// elsewhere if a feature graduates to a primary tab, that's a
/// deliberate design decision.
private struct SystemTab: View {
let config: IOSServerConfig
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var showForgetConfirmation = false
@State private var isForgetting = false
@State private var isDisconnecting = false
/// Mirror of `SSHKeyICloudPreference.isEnabled` drives the iCloud
/// Keychain sync toggle (issue #52). Initial value is read on view
/// init so the toggle reflects today's preference before the user
/// taps anything; flipping triggers `migrateAllItems(toICloudSync:)`.
@State private var iCloudSyncEnabled: Bool = SSHKeyICloudPreference.isEnabled
@State private var iCloudMigrationInFlight = false
@State private var iCloudMigrationError: String?
var body: some View {
List {
Section("Server") {
LabeledContent("Host", value: config.host)
.listRowBackground(ScarfColor.backgroundSecondary)
if let user = config.user {
LabeledContent("User", value: user)
.listRowBackground(ScarfColor.backgroundSecondary)
}
if let port = config.port {
LabeledContent("Port", value: String(port))
.listRowBackground(ScarfColor.backgroundSecondary)
}
}
Section("Features") {
NavigationLink {
MemoryListView(config: config)
} label: {
Label("Memory", systemImage: "brain.head.profile")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
if capabilitiesStore?.capabilities.hasCurator ?? false {
NavigationLink {
CuratorView(context: config.toServerContext(id: ScarfGoTabRoot.systemTabContextID))
} label: {
Label("Curator", systemImage: "sparkles")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
NavigationLink {
CronListView(config: config)
} label: {
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
SettingsView(config: config)
} label: {
Label("Settings", systemImage: "gearshape.fill")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
// v2.6: read-only mobile views over CLI-driven Hermes
// surfaces. Mac owns the create/edit paths; phones get a
// monitoring window into what the remote agent is honoring.
// None of these are capability-gated the underlying
// `hermes plugins/profile/webhook list` verbs exist on
// both v0.11 and v0.12, so the read views work on either.
Section("Inspect") {
NavigationLink {
WebhooksView(config: config)
} label: {
Label("Webhooks", systemImage: "arrow.up.right.square")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
PluginsView(config: config)
} label: {
Label("Plugins", systemImage: "app.badge.checkmark")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
ProfilesView(config: config)
} label: {
Label("Profiles", systemImage: "person.2.crop.square.stack")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
Section {
Toggle(isOn: $iCloudSyncEnabled) {
HStack(spacing: 10) {
Image(systemName: "key.icloud.fill")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text("Sync SSH key with iCloud Keychain")
Text(iCloudSyncEnabled
? "Synced — your other Apple devices with iCloud Keychain will see this key."
: "This device only — generate a separate key on each device.")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
}
.tint(ScarfColor.accent)
.disabled(iCloudMigrationInFlight)
.onChange(of: iCloudSyncEnabled) { _, newValue in
Task {
iCloudMigrationInFlight = true
iCloudMigrationError = nil
defer { iCloudMigrationInFlight = false }
do {
try await KeychainSSHKeyStore().migrateAllItems(toICloudSync: newValue)
} catch {
// Revert the toggle on failure so the UI
// reflects what's actually in the Keychain;
// surface the error inline so the user can
// retry / report. Keychain failures here are
// rare (typically `errSecDuplicateItem` if a
// prior migration was interrupted the
// delete-with-Any in writeBundle prevents
// that, but we still belt-and-brace).
iCloudMigrationError = error.localizedDescription
iCloudSyncEnabled = !newValue
SSHKeyICloudPreference.isEnabled = !newValue
}
}
}
if iCloudMigrationInFlight {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Updating Keychain…")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
if let err = iCloudMigrationError {
Label(err, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(ScarfColor.warning)
}
} header: {
Text("Security")
} footer: {
Text("End-to-end encrypted via iCloud Keychain. With Advanced Data Protection on, the encryption keys never leave your devices. Toggle off to keep the key device-only — each new device must onboard separately.")
.font(.caption)
}
.listRowBackground(ScarfColor.backgroundSecondary)
Section {
Button {
Task {
isDisconnecting = true
await onSoftDisconnect()
}
} label: {
HStack {
Spacer()
if isDisconnecting {
ProgressView()
} else {
Text("Disconnect")
}
Spacer()
}
}
.disabled(isDisconnecting || isForgetting)
.listRowBackground(ScarfColor.backgroundSecondary)
} footer: {
Text("Closes the live connection. Your key and host details stay on this device; tapping the server from the list reconnects with no re-onboarding.")
.font(.caption)
}
Section {
Button(role: .destructive) {
showForgetConfirmation = true
} label: {
HStack {
Spacer()
if isForgetting {
ProgressView()
} else {
Text("Forget this server")
}
Spacer()
}
}
.disabled(isForgetting || isDisconnecting)
.listRowBackground(ScarfColor.backgroundSecondary)
} footer: {
Text("Removes this server's SSH key and host info from the device. You'll need to add the public key back to `~/.ssh/authorized_keys` to reconnect.")
.font(.caption)
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
.navigationTitle("System")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Forget this server?",
isPresented: $showForgetConfirmation,
titleVisibility: .visible
) {
Button("Forget \(config.displayName)", role: .destructive) {
Task {
isForgetting = true
await onForget()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Your SSH key and host settings for \(config.displayName) will be removed. Other servers stay configured. This cannot be undone.")
}
}
}