mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
aba94bbe0b
* fix(skills): keep ClawHub publisher identity from search through install ClawHub search returns one entry per publisher, so several results can share a slug. Every client collapsed the selection to that bare slug before calling skills.detail and skills.install, and ClawHub answered 409 AMBIGUOUS_SKILL_SLUG with no in-product way forward. searchClawHubSkills now records the publisher-qualified reference once, on the result that carries it, and the Gateway protocol documents it. skills.detail parses the same reference grammar skills.install already accepted, so review and install cannot resolve to different publishers. Control UI carries that one reference through row actions, detail, busy state, and acknowledgement retries, and shows it so otherwise identical rows are distinguishable. Fixes #117633 * fix(apps): send the ClawHub publisher reference from native skill browsers macOS, iOS, and Android read the qualified reference from search results and use it for skills.detail, install, busy state, installed matching, and list identity, so two publishers sharing a slug stay distinct instead of collapsing into one ambiguous request. * fix(skills): refuse external-source skill detail instead of reading a same-slug skill ClawHub has no source-qualified read endpoint, so a skills-sh reference parsed down to its bare slug would have returned a registry skill's card while install resolved the external artifact. Review and install could name different skills. skills.detail now fails closed on any reference that carries a source, and the macOS and AgentPro rows show the publisher reference next to the summary instead of only when a summary is missing, so same-slug rows stay distinguishable. * chore(apps): refresh native i18n source baseline for the skill row references * refactor(skills): drop the unread search-result ownerHandle field installRef is the one reference clients send back, and no client reads the publisher handle separately, so the protocol and Control UI carry one field instead of two. * fix(skills): name the next step when external skill detail is refused Clients that gate install behind a successful review would otherwise see only a refusal, so the error names the direct install path and the CLI equivalent. * fix(macos): use a doc comment on the ClawHub row subtitle swift-format's docComments rule requires doc comments on declarations; the subtitle property carried a regular comment and failed macos-swift. * fix(skills): carry ClawHub trust state to clients that can install Forwarding installRef let clients install the exact publisher the operator picked, including external skills-sh sources. It did not forward the trust state that says ClawHub never scanned that source, so iOS AgentPro — the one surface that installs in a single tap with no review step — could install an unscanned artifact with nothing on screen saying so. The CLI already labels these (docs/clawhub/cli.md, docs/cli/skills.md); native clients could not, because trustState was never on the wire. trustState becomes an optional field on SkillsSearchResultSchema. It is purely additive: older clients ignore an unknown key and the field is absent for registry results, so downgraded readers are unaffected and no protocol version moves. Every client that renders a search row now shows "Not scanned by ClawHub", matching the CLI wording exactly: iOS AgentPro in the row above the install button, macOS and Android beside the review action, and Control UI on the row that explains why review is refused for these sources. Covered by a wire assertion that the state reaches clients for an external source and stays absent for registry rows, plus decode-and-label tests on the shared Swift kit and the Android parser, and a Control UI render assertion. * fix(ui): size the ClawHub detail dialog to a refusal message Refusing detail for an external source made an error-only dialog reachable. The shared preview panel reserves a tall reader height for skill documents, so a two-line refusal rendered in a mostly empty dialog and read as broken rather than deliberate. Found by inspecting the review captures. * revert(ui,apps): drop the ClawHub trust label layer Maintainer product decision: skills.sh runs its own scanners, so OpenClaw does not add a second alert layer in the apps. Removes the label from Control UI, iOS, macOS and Android, and drops the trustState wire field that nothing would render. The CLI keeps its existing label; changing that is a separate call. Publisher identity, the fail-closed detail refusal, and the message-only dialog are unchanged. Splits the oversized skills view test file to satisfy max-lines without a suppression. * test(ui): fix ClawHub skill fixture checks * chore(plugin-sdk): refresh API baseline --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
406 lines
15 KiB
Swift
406 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.reference,
|
|
version: $0)
|
|
} ?? SkillManagementContract.installed(
|
|
self.installedSkills,
|
|
slug: skill.reference),
|
|
isBusy: self.model.reviewingSlug == skill.reference || self.model.installingSlug.map {
|
|
SkillManagementContract.sameClawHubSkill($0, skill.reference)
|
|
} == 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
|
|
|
|
/// Same-slug rows share a display name and often a summary, so the reference always shows:
|
|
/// it is the only thing that tells them apart and what review and install send back.
|
|
private var subtitle: String {
|
|
guard let summary = self.skill.summary else { return self.skill.reference }
|
|
return "\(summary) · \(self.skill.reference)"
|
|
}
|
|
|
|
var body: some View {
|
|
SettingsCardRow(
|
|
title: .verbatim(self.skill.displayName),
|
|
subtitle: .verbatim(self.subtitle),
|
|
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.reference
|
|
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.reference, 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."
|
|
}
|
|
}
|
|
}
|