mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
799332fbcd
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>
137 lines
5.3 KiB
Swift
137 lines
5.3 KiB
Swift
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
|
|
}
|
|
}
|