mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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 {
|
||||
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) {
|
||||
Text("Overview").tag(Section.overview)
|
||||
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