mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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>
This commit is contained in:
@@ -241,6 +241,36 @@ private struct SystemTab: View {
|
|||||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
.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 {
|
Section {
|
||||||
Toggle(isOn: $iCloudSyncEnabled) {
|
Toggle(isOn: $iCloudSyncEnabled) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Yellow banner that nudges users to upgrade Hermes when the remote
|
||||||
|
/// is running pre-v0.12. Shown on the Dashboard tab; auto-dismissed
|
||||||
|
/// for the rest of the session when the user taps the X. Persistent
|
||||||
|
/// re-show on each app open keeps the prompt visible without nagging
|
||||||
|
/// inside a single session.
|
||||||
|
///
|
||||||
|
/// Hidden entirely on v0.12+ (the new features are reachable) and
|
||||||
|
/// while capability detection is still in flight.
|
||||||
|
struct HermesVersionBanner: View {
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
@State private var dismissedThisSession = false
|
||||||
|
|
||||||
|
/// Capability gate — only render when:
|
||||||
|
/// - the store finished its initial detection AND
|
||||||
|
/// - the host returned an actual version string AND
|
||||||
|
/// - that version is below v0.12 AND
|
||||||
|
/// - the user hasn't dismissed this banner during this session.
|
||||||
|
private var shouldShow: Bool {
|
||||||
|
guard let store = capabilitiesStore else { return false }
|
||||||
|
let caps = store.capabilities
|
||||||
|
guard caps.detected else { return false } // skip while loading / on detection failure
|
||||||
|
guard !caps.hasCurator else { return false } // already on v0.12+
|
||||||
|
return !dismissedThisSession
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if shouldShow {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Hermes update available")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
Text("This server runs \(versionLabel). Update to v0.12 to unlock the autonomous curator, multimodal image input, GMI Cloud / Azure / LM Studio / MiniMax / Tencent providers, and more.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Button {
|
||||||
|
dismissedThisSession = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Dismiss this version notice for the rest of the session")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(ScarfColor.warning.opacity(0.12))
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(ScarfColor.warning.opacity(0.4))
|
||||||
|
.frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pretty-print the detected version. Falls back to the raw line
|
||||||
|
/// if parsing didn't extract semver — keeps the banner honest
|
||||||
|
/// when Hermes ships an unexpected version string.
|
||||||
|
private var versionLabel: String {
|
||||||
|
let caps = capabilitiesStore?.capabilities
|
||||||
|
if let semver = caps?.semver {
|
||||||
|
return "Hermes v\(semver.description)"
|
||||||
|
}
|
||||||
|
return caps?.versionLine ?? "an older Hermes"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,13 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// v2.6 Hermes-version banner. Renders only when the remote
|
||||||
|
// is pre-v0.12 and the user hasn't dismissed for this
|
||||||
|
// session. v0.12+ hosts get a tab with no banner above
|
||||||
|
// the picker; older hosts see the upgrade nudge inline so
|
||||||
|
// it's visible without burying it inside Settings.
|
||||||
|
HermesVersionBanner()
|
||||||
|
|
||||||
Picker("View", selection: $selectedSection) {
|
Picker("View", selection: $selectedSection) {
|
||||||
Text("Overview").tag(Section.overview)
|
Text("Overview").tag(Section.overview)
|
||||||
Text("Sessions").tag(Section.sessions)
|
Text("Sessions").tag(Section.sessions)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// iOS read-only Plugins view (v2.6).
|
||||||
|
///
|
||||||
|
/// Walks `~/.hermes/plugins/` (each subdirectory is one plugin) and
|
||||||
|
/// reads the optional `plugin.json` / `plugin.yaml` manifest for each.
|
||||||
|
/// Mirrors the Mac PluginsViewModel's filesystem-first source-of-truth
|
||||||
|
/// approach — `hermes plugins list`'s box-drawn output is fragile to
|
||||||
|
/// parse from a phone form-factor.
|
||||||
|
///
|
||||||
|
/// Install / update / remove / enable / disable verbs stay on Mac for
|
||||||
|
/// v2.6 — installing a plugin from a phone is an unusual flow.
|
||||||
|
struct PluginsView: View {
|
||||||
|
let config: IOSServerConfig
|
||||||
|
|
||||||
|
@State private var plugins: [PluginRow] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var lastError: String?
|
||||||
|
@Environment(\.serverContext) private var contextFromEnv
|
||||||
|
|
||||||
|
private var context: ServerContext {
|
||||||
|
config.toServerContext(id: contextFromEnv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let err = lastError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if plugins.isEmpty && !isLoading {
|
||||||
|
Section {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No plugins installed",
|
||||||
|
systemImage: "app.badge.checkmark",
|
||||||
|
description: Text("Hermes plugins live under `~/.hermes/plugins/<name>/`. Install one with `hermes plugins install <repo>` from the Mac app.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(plugins) { plugin in
|
||||||
|
Section(plugin.name) {
|
||||||
|
HStack {
|
||||||
|
statusBadge(plugin.enabled)
|
||||||
|
if !plugin.version.isEmpty {
|
||||||
|
Text("v\(plugin.version)")
|
||||||
|
.font(ScarfFont.monoSmall)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
if !plugin.source.isEmpty {
|
||||||
|
LabeledContent("Source", value: plugin.source)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
}
|
||||||
|
Text(plugin.path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Plugins")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(_ enabled: Bool) -> some View {
|
||||||
|
ScarfBadge(enabled ? "Enabled" : "Disabled", kind: enabled ? .success : .neutral)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let ctx = context
|
||||||
|
let entries = await Task.detached {
|
||||||
|
Self.scan(context: ctx)
|
||||||
|
}.value
|
||||||
|
self.plugins = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func scan(context: ServerContext) -> [PluginRow] {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let dir = context.paths.pluginsDir
|
||||||
|
guard let entries = try? transport.listDirectory(dir) else { return [] }
|
||||||
|
var results: [PluginRow] = []
|
||||||
|
for entry in entries.sorted() where !entry.hasPrefix(".") {
|
||||||
|
let path = dir + "/" + entry
|
||||||
|
guard transport.stat(path)?.isDirectory == true else { continue }
|
||||||
|
let manifest = readManifest(path: path, context: context)
|
||||||
|
let disabled = transport.fileExists(path + "/.disabled")
|
||||||
|
results.append(PluginRow(
|
||||||
|
name: entry,
|
||||||
|
version: manifest.version,
|
||||||
|
source: manifest.source,
|
||||||
|
path: path,
|
||||||
|
enabled: !disabled
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `plugin.json` first; fall back to `plugin.yaml` for plugins
|
||||||
|
/// that author manifest in YAML. Same shape as the Mac VM so
|
||||||
|
/// parsing stays consistent across targets.
|
||||||
|
nonisolated private static func readManifest(path: String, context: ServerContext) -> (source: String, version: String) {
|
||||||
|
if let data = context.readData(path + "/plugin.json"),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
|
||||||
|
let version = (obj["version"] as? String) ?? ""
|
||||||
|
return (source, version)
|
||||||
|
}
|
||||||
|
if let yaml = context.readText(path + "/plugin.yaml") {
|
||||||
|
let parsed = HermesYAML.parseNestedYAML(yaml)
|
||||||
|
let source = HermesYAML.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
|
||||||
|
let version = HermesYAML.stripYAMLQuotes(parsed.values["version"] ?? "")
|
||||||
|
return (source, version)
|
||||||
|
}
|
||||||
|
return ("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PluginRow: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let version: String
|
||||||
|
let source: String
|
||||||
|
let path: String
|
||||||
|
let enabled: Bool
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// iOS read-only Profiles view (v2.6).
|
||||||
|
///
|
||||||
|
/// Lists `hermes profile list` output and highlights the active profile.
|
||||||
|
/// Profile switching, creation, deletion, and import/export remain on
|
||||||
|
/// the Mac app — those involve writing data we don't want to risk
|
||||||
|
/// fat-fingering on a phone (e.g., wiping the active profile by accident).
|
||||||
|
struct ProfilesView: View {
|
||||||
|
let config: IOSServerConfig
|
||||||
|
|
||||||
|
@State private var profiles: [ProfileRow] = []
|
||||||
|
@State private var activeProfile: String?
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var lastError: String?
|
||||||
|
@Environment(\.serverContext) private var contextFromEnv
|
||||||
|
|
||||||
|
private var context: ServerContext {
|
||||||
|
config.toServerContext(id: contextFromEnv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let err = lastError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if profiles.isEmpty && !isLoading {
|
||||||
|
Section {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No profiles",
|
||||||
|
systemImage: "person.2.crop.square.stack",
|
||||||
|
description: Text("Hermes profiles let you keep multiple HERMES_HOME directories side-by-side. Create one with `hermes profile create <name>` from the Mac app.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
ForEach(profiles) { p in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(p.name)
|
||||||
|
.font(.body)
|
||||||
|
if let aliases = p.aliasesLabel {
|
||||||
|
Text(aliases)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if p.name == activeProfile {
|
||||||
|
ScarfBadge("Active", kind: .success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if let active = activeProfile {
|
||||||
|
Text("Active profile: \(active)")
|
||||||
|
} else {
|
||||||
|
Text("All profiles")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Switching profiles, creating new ones, and import/export live in the Mac app — they touch enough state that we keep them off the phone.")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Profiles")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let ctx = context
|
||||||
|
let result = await Task.detached { () -> (output: String, active: String?) in
|
||||||
|
let listOut = Self.runHermes(context: ctx, args: ["profile", "list"])
|
||||||
|
// Active profile lives at ~/.hermes/active_profile (text file
|
||||||
|
// with one line). Reading directly is faster than another
|
||||||
|
// CLI round-trip.
|
||||||
|
let activeRaw = ctx.readText(ctx.paths.home + "/active_profile")
|
||||||
|
let active = activeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (listOut, active)
|
||||||
|
}.value
|
||||||
|
self.profiles = Self.parse(result.output)
|
||||||
|
self.activeProfile = result.active.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func runHermes(context: ServerContext, args: [String]) -> String {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let r = try transport.runProcess(
|
||||||
|
executable: context.paths.hermesBinary,
|
||||||
|
args: args,
|
||||||
|
stdin: nil,
|
||||||
|
timeout: 30
|
||||||
|
)
|
||||||
|
return r.stdoutString + r.stderrString
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerant parser for `hermes profile list`. The CLI prints a
|
||||||
|
/// table-like format with the profile name on the leading column
|
||||||
|
/// and optional alias / path columns afterwards. We surface the
|
||||||
|
/// name (always present); aliases collapse into a comma-separated
|
||||||
|
/// label in the row when present.
|
||||||
|
nonisolated private static func parse(_ output: String) -> [ProfileRow] {
|
||||||
|
var results: [ProfileRow] = []
|
||||||
|
for raw in output.components(separatedBy: "\n") {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
// Skip table-rule and header lines.
|
||||||
|
if trimmed.hasPrefix("┃") || trimmed.hasPrefix("┏") || trimmed.hasPrefix("┡")
|
||||||
|
|| trimmed.hasPrefix("┗") || trimmed.hasPrefix("━") || trimmed.hasPrefix("│") {
|
||||||
|
// Strip box-drawing chars and try to extract the leading column.
|
||||||
|
let body = trimmed
|
||||||
|
.replacingOccurrences(of: "│", with: "|")
|
||||||
|
.replacingOccurrences(of: "┃", with: "|")
|
||||||
|
if !body.contains("|") { continue }
|
||||||
|
let cols = body.split(separator: "|", omittingEmptySubsequences: true)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard let name = cols.first, !name.isEmpty,
|
||||||
|
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil
|
||||||
|
else { continue }
|
||||||
|
let aliases = cols.dropFirst().filter { !$0.isEmpty }.joined(separator: ", ")
|
||||||
|
results.append(ProfileRow(name: name, aliasesLabel: aliases.isEmpty ? nil : aliases))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Plain-text fallback: first whitespace-delimited token is the name.
|
||||||
|
if let name = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).first,
|
||||||
|
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil {
|
||||||
|
results.append(ProfileRow(name: String(name), aliasesLabel: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedupe (the table-row + plain-text passes can overlap).
|
||||||
|
var seen = Set<String>()
|
||||||
|
return results.filter { seen.insert($0.name).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProfileRow: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let aliasesLabel: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// iOS read-only Webhooks view (v2.6).
|
||||||
|
///
|
||||||
|
/// Lists `hermes webhook list` output so mobile users can see what
|
||||||
|
/// dynamic webhook subscriptions the remote agent is honoring. Create /
|
||||||
|
/// remove / test actions stay on Mac for v2.6 — most webhook setup
|
||||||
|
/// involves pasting URLs / secrets that are inconvenient on a phone.
|
||||||
|
///
|
||||||
|
/// Reuses the same tolerant text parser the Mac WebhooksViewModel uses.
|
||||||
|
struct WebhooksView: View {
|
||||||
|
let config: IOSServerConfig
|
||||||
|
|
||||||
|
@State private var webhooks: [WebhookRow] = []
|
||||||
|
@State private var notEnabled = false
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var lastError: String?
|
||||||
|
@Environment(\.serverContext) private var contextFromEnv
|
||||||
|
|
||||||
|
private var context: ServerContext {
|
||||||
|
// The view receives `IOSServerConfig` directly (matches the
|
||||||
|
// sibling Skills/Settings tabs); use that to construct a
|
||||||
|
// context bound to the active server. Falls back to env when
|
||||||
|
// the navigation host hasn't injected a config-derived ctx.
|
||||||
|
config.toServerContext(id: contextFromEnv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let err = lastError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notEnabled {
|
||||||
|
Section("Setup required") {
|
||||||
|
Text("The webhook gateway platform isn't enabled on this server. Run `hermes setup` from the Mac app or a shell to enable it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if webhooks.isEmpty && !isLoading {
|
||||||
|
Section {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No webhooks subscribed",
|
||||||
|
systemImage: "arrow.up.right.square",
|
||||||
|
description: Text("Run `hermes webhook subscribe …` from the Mac app to register one.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(webhooks) { hook in
|
||||||
|
Section(hook.name) {
|
||||||
|
if !hook.description.isEmpty {
|
||||||
|
LabeledContent("Description", value: hook.description)
|
||||||
|
}
|
||||||
|
if !hook.deliver.isEmpty {
|
||||||
|
LabeledContent("Deliver", value: hook.deliver)
|
||||||
|
}
|
||||||
|
if !hook.events.isEmpty {
|
||||||
|
LabeledContent("Events", value: hook.events.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
LabeledContent("Route", value: hook.routeSuffix)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Webhooks")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let ctx = context
|
||||||
|
let result = await Task.detached {
|
||||||
|
return Self.runHermesList(context: ctx)
|
||||||
|
}.value
|
||||||
|
if Self.detectNotEnabled(result) {
|
||||||
|
self.notEnabled = true
|
||||||
|
self.webhooks = []
|
||||||
|
self.lastError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.notEnabled = false
|
||||||
|
let parsed = Self.parse(result)
|
||||||
|
self.webhooks = parsed
|
||||||
|
self.lastError = parsed.isEmpty && !result.isEmpty
|
||||||
|
? nil
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func runHermesList(context: ServerContext) -> String {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let r = try transport.runProcess(
|
||||||
|
executable: context.paths.hermesBinary,
|
||||||
|
args: ["webhook", "list"],
|
||||||
|
stdin: nil,
|
||||||
|
timeout: 30
|
||||||
|
)
|
||||||
|
return r.stdoutString + r.stderrString
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func detectNotEnabled(_ output: String) -> Bool {
|
||||||
|
let lower = output.lowercased()
|
||||||
|
return lower.contains("webhook platform is not enabled")
|
||||||
|
|| lower.contains("run the gateway setup wizard")
|
||||||
|
|| lower.contains("webhook_enabled=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerant block-parser. Each subscription begins on a non-indented
|
||||||
|
/// line; description / deliver / events / url details follow as
|
||||||
|
/// indented `key: value` lines. Mirrors the Mac parser shape so
|
||||||
|
/// future drift only has to be fixed in one canonical place if/when
|
||||||
|
/// we promote this VM into ScarfCore.
|
||||||
|
nonisolated private static func parse(_ output: String) -> [WebhookRow] {
|
||||||
|
var results: [WebhookRow] = []
|
||||||
|
var name = ""
|
||||||
|
var desc = ""
|
||||||
|
var deliver = ""
|
||||||
|
var events: [String] = []
|
||||||
|
var route = ""
|
||||||
|
|
||||||
|
func flush() {
|
||||||
|
if !name.isEmpty {
|
||||||
|
results.append(WebhookRow(
|
||||||
|
name: name,
|
||||||
|
description: desc,
|
||||||
|
deliver: deliver,
|
||||||
|
events: events,
|
||||||
|
routeSuffix: route.isEmpty ? "/webhooks/\(name)" : route
|
||||||
|
))
|
||||||
|
}
|
||||||
|
name = ""; desc = ""; deliver = ""; events = []; route = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw in output.components(separatedBy: "\n") {
|
||||||
|
let line = raw
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { continue }
|
||||||
|
if !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||||
|
flush()
|
||||||
|
let candidate = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ":"))
|
||||||
|
if candidate.range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil {
|
||||||
|
name = candidate
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed.lowercased().hasPrefix("description:") {
|
||||||
|
desc = String(trimmed.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
} else if trimmed.lowercased().hasPrefix("deliver:") {
|
||||||
|
deliver = String(trimmed.dropFirst("deliver:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
} else if trimmed.lowercased().hasPrefix("events:") {
|
||||||
|
let list = String(trimmed.dropFirst("events:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
events = list.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
} else if trimmed.lowercased().hasPrefix("url:") || trimmed.lowercased().hasPrefix("route:") {
|
||||||
|
route = trimmed.components(separatedBy: ":").dropFirst().joined(separator: ":").trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WebhookRow: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let deliver: String
|
||||||
|
let events: [String]
|
||||||
|
let routeSuffix: String
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user