Files
openclaw/apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift
2026-07-21 16:15:40 +08:00

399 lines
15 KiB
Swift

import Observation
import OpenClawKit
import SwiftUI
private enum ClawHubReviewSheet: Identifiable {
case install(ClawHubSkillInstallReview, route: GatewayConnection.Route)
case risk(ClawHubSkillInstallReview, route: GatewayConnection.Route, message: String, warning: String?)
var id: String {
switch self {
case let .install(review, _): "install:\(review.id)"
case let .risk(review, _, _, _): "risk:\(review.id)"
}
}
}
struct ClawHubSkillsBrowser: View {
@State private var model = ClawHubSkillsBrowserModel()
let installedSkills: [SkillStatus]
let onInstalled: ([SkillStatus]) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SettingsCardGroup("Browse ClawHub") {
SettingsCardRow(
title: "Discover skills",
subtitle: "The Gateway verifies the exact reviewed release before download.",
showsDivider: false)
{
TextField("Search ClawHub", text: self.$model.query)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
.onSubmit { Task { await self.model.search() } }
Button {
Task { await self.model.search() }
} label: {
Label("Search", systemImage: "magnifyingglass")
}
.buttonStyle(.borderedProminent)
.disabled(self.model.isSearching)
}
}
if let notice = self.model.notice {
ClawHubNoticeCard(notice: notice)
}
SettingsCardGroup("Results") {
if self.model.isSearching, self.model.results.isEmpty {
SettingsCardRow(title: "Searching ClawHub…", showsDivider: false) {
ProgressView().controlSize(.small)
}
} else if self.model.results.isEmpty {
SettingsCardRow(
title: "No skills found",
subtitle: "Try another search or refresh the catalog.",
showsDivider: false)
{
EmptyView()
}
} else {
LazyVStack(spacing: 0) {
ForEach(Array(self.model.results.enumerated()), id: \.element.id) { index, skill in
ClawHubSkillResultRow(
skill: skill,
installed: skill.version.map {
SkillManagementContract.installed(
self.installedSkills,
slug: skill.slug,
version: $0)
} ?? SkillManagementContract.installed(
self.installedSkills,
slug: skill.slug),
isBusy: self.model.reviewingSlug == skill.slug || self.model.installingSlug.map {
SkillManagementContract.sameClawHubSkill($0, skill.slug)
} == true,
showsDivider: index != self.model.results.count - 1)
{
Task { await self.model.review(skill) }
}
}
}
}
}
}
.task { await self.model.searchIfNeeded() }
.sheet(item: self.$model.sheet) { sheet in
switch sheet {
case let .install(review, route):
ClawHubInstallReviewSheet(
review: review,
isInstalling: self.model.installingSlug == review.slug,
onCancel: { self.model.sheet = nil },
onInstall: {
Task {
if let skills = await self.model.install(review, route: route, acknowledgeRisk: false) {
self.onInstalled(skills)
}
}
})
case let .risk(review, route, message, warning):
ClawHubRiskReviewSheet(
review: review,
message: message,
warning: warning,
isInstalling: self.model.installingSlug == review.slug,
onCancel: { self.model.sheet = nil },
onInstall: {
Task {
if let skills = await self.model.install(review, route: route, acknowledgeRisk: true) {
self.onInstalled(skills)
}
}
})
}
}
}
}
private struct ClawHubSkillResultRow: View {
let skill: ClawHubSkillSummary
let installed: Bool
let isBusy: Bool
let showsDivider: Bool
let onReview: () -> Void
var body: some View {
SettingsCardRow(
title: .verbatim(self.skill.displayName),
subtitle: .verbatim(self.skill.summary ?? self.skill.slug),
showsDivider: self.showsDivider)
{
if let version = self.skill.version {
Text(version)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
Button(self.installed ? "Installed" : "Review", action: self.onReview)
.buttonStyle(.bordered)
.disabled(self.isBusy || self.installed)
}
}
}
private struct ClawHubInstallReviewSheet: View {
let review: ClawHubSkillInstallReview
let isInstalling: Bool
let onCancel: () -> Void
let onInstall: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Review ClawHub skill").font(.title2.bold())
ClawHubReviewDetails(review: self.review)
Text("The Gateway will verify this exact release with ClawHub before download.")
.font(.footnote)
.foregroundStyle(.secondary)
HStack {
Spacer()
Button("Cancel", action: self.onCancel)
Button("Verify and install", action: self.onInstall)
.buttonStyle(.borderedProminent)
.disabled(self.isInstalling)
}
}
.padding(24)
.frame(width: 480)
}
}
private struct ClawHubRiskReviewSheet: View {
let review: ClawHubSkillInstallReview
let message: String
let warning: String?
let isInstalling: Bool
let onCancel: () -> Void
let onInstall: () -> Void
@State private var warningExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Label("Gateway warning", systemImage: "exclamationmark.triangle.fill")
.font(.title2.bold())
.foregroundStyle(.orange)
ClawHubReviewDetails(review: self.review)
Text(self.message).font(.body)
DisclosureGroup("Review warning details", isExpanded: self.$warningExpanded) {
Text(self.warning ?? "The Gateway requires explicit acknowledgement for this release.")
.font(.callout)
.textSelection(.enabled)
.padding(.top, 8)
}
Text("Expand and review the Gateway warning before acknowledging this exact version.")
.font(.footnote)
.foregroundStyle(.secondary)
HStack {
Spacer()
Button("Cancel", action: self.onCancel)
Button("Acknowledge and install", action: self.onInstall)
.buttonStyle(.borderedProminent)
.disabled(!self.warningExpanded || self.isInstalling)
}
}
.padding(24)
.frame(width: 520)
}
}
private struct ClawHubReviewDetails: View {
let review: ClawHubSkillInstallReview
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.review.displayName).font(.headline)
if let summary = self.review.summary {
Text(summary).foregroundStyle(.secondary)
}
LabeledContent("Version", value: self.review.version)
LabeledContent("Publisher", value: self.review.author)
}
}
}
private struct ClawHubNoticeCard: View {
let notice: ClawHubSkillsBrowserModel.Notice
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: self.notice.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.foregroundStyle(self.notice.isError ? .orange : .green)
VStack(alignment: .leading, spacing: 4) {
Text(self.notice.title).font(.headline)
Text(self.notice.message).font(.footnote).textSelection(.enabled)
if let warning = self.notice.warning {
Text(warning).font(.footnote).foregroundStyle(.secondary).textSelection(.enabled)
}
}
Spacer()
}
.padding(14)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@MainActor
@Observable
private final class ClawHubSkillsBrowserModel {
struct Notice {
let title: String
let message: String
let warning: String?
let isError: Bool
}
var query = ""
var results: [ClawHubSkillSummary] = []
var isSearching = false
var reviewingSlug: String?
var installingSlug: String?
var sheet: ClawHubReviewSheet?
var notice: Notice?
private var hasSearched = false
func searchIfNeeded() async {
guard !self.hasSearched else { return }
await self.search()
}
func search() async {
guard !self.isSearching else { return }
self.isSearching = true
self.notice = nil
defer { self.isSearching = false }
do {
guard let route = await GatewayConnection.shared.captureRoute() else {
throw ClawHubSkillsBrowserError.gatewayUnavailable
}
self.results = try await GatewayConnection.shared.skillsSearch(query: self.query, on: route)
self.hasSearched = true
} catch {
self.notice = Notice(
title: "ClawHub unavailable",
message: error.localizedDescription,
warning: nil,
isError: true)
}
}
func review(_ skill: ClawHubSkillSummary) async {
guard self.reviewingSlug == nil else { return }
self.reviewingSlug = skill.slug
self.notice = nil
defer { self.reviewingSlug = nil }
do {
guard let route = await GatewayConnection.shared.captureRoute() else {
throw ClawHubSkillsBrowserError.gatewayUnavailable
}
let detail = try await GatewayConnection.shared.skillsDetail(slug: skill.slug, on: route)
guard let review = ClawHubSkillInstallReview(detail: detail, fallback: skill) else {
throw ClawHubSkillsBrowserError.missingInstallVersion
}
self.sheet = .install(review, route: route)
} catch {
self.notice = Notice(
title: "Could not review skill",
message: error.localizedDescription,
warning: nil,
isError: true)
}
}
func install(
_ review: ClawHubSkillInstallReview,
route: GatewayConnection.Route,
acknowledgeRisk: Bool) async -> [SkillStatus]?
{
guard self.installingSlug == nil else { return nil }
self.installingSlug = review.slug
self.notice = nil
defer { self.installingSlug = nil }
do {
let result = try await GatewayConnection.shared.skillsInstallClawHub(
slug: review.slug,
version: review.version,
acknowledgeRisk: acknowledgeRisk,
on: route)
let report = try await GatewayConnection.shared.skillsStatus(on: route)
guard SkillManagementContract.installed(report.skills, slug: review.slug, version: review.version) else {
self.sheet = nil
self.notice = Notice(
title: "Install result unknown",
// swiftlint:disable line_length
message: "Reconnect, refresh Skills, then retry. The Gateway safely joins a matching install still running.",
// swiftlint:enable line_length
warning: result.warning,
isError: true)
return nil
}
self.sheet = nil
self.notice = Notice(
title: "Installed",
message: result.message,
warning: result.warning,
isError: false)
return report.skills
} catch let error as GatewayResponseError {
let rejection = SkillManagementContract.rejection(from: error, attemptedVersion: review.version)
if rejection.requiresAcknowledgement, !acknowledgeRisk {
self.sheet = .risk(
review,
route: route,
message: rejection.message,
warning: rejection.warning)
} else {
self.sheet = nil
self.notice = Notice(
title: "Gateway blocked install",
message: rejection.message,
warning: rejection.warning,
isError: true)
}
return nil
} catch {
if let report = try? await GatewayConnection.shared.skillsStatus(on: route),
SkillManagementContract.installed(report.skills, slug: review.slug, version: review.version)
{
self.sheet = nil
self.notice = Notice(
title: "Installed",
message: "The Gateway installed the reviewed version.",
warning: nil,
isError: false)
return report.skills
}
self.sheet = nil
self.notice = Notice(
title: "Install result unknown",
message: error.localizedDescription,
warning: nil,
isError: true)
return nil
}
}
}
private enum ClawHubSkillsBrowserError: LocalizedError {
case gatewayUnavailable
case missingInstallVersion
var errorDescription: String? {
switch self {
case .gatewayUnavailable:
"Connect to a Gateway and try again."
case .missingInstallVersion:
"ClawHub did not report an installable version for this skill."
}
}
}