mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
399 lines
15 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|