mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 13:16:04 +00:00
* feat(apps): add native skills management parity * chore(i18n): refresh native skills inventory * style(apps): apply canonical Swift formatting * style(ios): wrap skills status message * chore(i18n): refresh native skill locales * refactor(apps): trim native skills review surface * chore(i18n): regenerate native app resources * chore: keep release changelog owner-only * fix(ios): remove dead gateway and watch wrappers
1056 lines
40 KiB
Swift
1056 lines
40 KiB
Swift
import OpenClawKit
|
|
import SwiftUI
|
|
|
|
private enum SkillsSettingsSection: String, CaseIterable, Identifiable {
|
|
case installed
|
|
case browse
|
|
|
|
var id: String {
|
|
rawValue
|
|
}
|
|
|
|
var title: String {
|
|
self == .installed ? String(localized: "Installed") : String(localized: "Browse")
|
|
}
|
|
}
|
|
|
|
private enum InstalledSkillFilter: String, CaseIterable, Identifiable {
|
|
case all
|
|
case ready
|
|
case setup
|
|
case off
|
|
|
|
var id: String {
|
|
rawValue
|
|
}
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .all: String(localized: "All")
|
|
case .ready: String(localized: "Ready")
|
|
case .setup: String(localized: "Needs Setup")
|
|
case .off: String(localized: "Off")
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum SkillsReviewSheet: Identifiable {
|
|
case install(ClawHubSkillInstallReview, route: GatewayNodeSessionRoute)
|
|
case risk(ClawHubSkillInstallReview, route: GatewayNodeSessionRoute, message: String, warning: String?)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case let .install(review, _): "install:\(review.id)"
|
|
case let .risk(review, _, _, _): "risk:\(review.id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SettingsSkillsDestination: View {
|
|
@Environment(NodeAppModel.self) private var appModel
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@State private var section: SkillsSettingsSection = .installed
|
|
@State private var installedFilter: InstalledSkillFilter = .all
|
|
@State private var installedQuery = ""
|
|
@State private var installedSkills: [SkillStatus] = []
|
|
@State private var installedLoadID: UUID?
|
|
@State private var mutationIDs: [String: UUID] = [:]
|
|
@State private var browseQuery = ""
|
|
@State private var searchResults: [ClawHubSkillSummary] = []
|
|
@State private var searchID: UUID?
|
|
@State private var reviewingSlug: String?
|
|
@State private var reviewID: UUID?
|
|
@State private var installingSlug: String?
|
|
@State private var installID: UUID?
|
|
@State private var reviewSheet: SkillsReviewSheet?
|
|
@State private var clawHubSupported: Bool?
|
|
@State private var notice: SkillsNotice?
|
|
@State private var loadedGatewayID: String?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
self.summaryCard
|
|
self.sectionPicker
|
|
if self.section == .installed {
|
|
self.installedContent
|
|
} else {
|
|
self.browseContent
|
|
}
|
|
}
|
|
.padding(.vertical, 12)
|
|
}
|
|
.font(OpenClawType.body)
|
|
.task(id: self.refreshID) { await self.loadInitialState() }
|
|
.refreshable { await self.refreshVisibleSection() }
|
|
.onChange(of: self.appModel.connectedGatewayID) { _, _ in
|
|
self.resetGatewayState()
|
|
}
|
|
.onChange(of: self.section) { _, section in
|
|
guard section == .browse, self.searchResults.isEmpty else { return }
|
|
Task { await self.searchClawHub() }
|
|
}
|
|
.sheet(item: self.$reviewSheet) { sheet in
|
|
switch sheet {
|
|
case let .install(review, route):
|
|
SkillsInstallReviewSheet(
|
|
review: review,
|
|
canInstall: self.canAdmin,
|
|
isInstalling: self.installingSlug == review.slug,
|
|
onCancel: { self.reviewSheet = nil },
|
|
onInstall: { Task { await self.install(review, route: route, acknowledgeRisk: false) } })
|
|
case let .risk(review, route, message, warning):
|
|
SkillsRiskReviewSheet(
|
|
review: review,
|
|
message: message,
|
|
warning: warning,
|
|
isInstalling: self.installingSlug == review.slug,
|
|
onCancel: { self.reviewSheet = nil },
|
|
onInstall: { Task { await self.install(review, route: route, acknowledgeRisk: true) } })
|
|
}
|
|
}
|
|
}
|
|
|
|
private var refreshID: String {
|
|
[
|
|
self.canRead ? "connected" : "offline",
|
|
self.scenePhase == .active ? "active" : "inactive",
|
|
self.appModel.connectedGatewayID ?? "no-gateway",
|
|
].joined(separator: ":")
|
|
}
|
|
|
|
private var canRead: Bool {
|
|
self.appModel.isOperatorGatewayConnected
|
|
}
|
|
|
|
private var canAdmin: Bool {
|
|
self.appModel.hasOperatorAdminScope
|
|
}
|
|
|
|
private var isLoadingInstalled: Bool {
|
|
self.installedLoadID != nil
|
|
}
|
|
|
|
private var isSearching: Bool {
|
|
self.searchID != nil
|
|
}
|
|
|
|
private var readyCount: Int {
|
|
self.installedSkills.count(where: Self.isReady)
|
|
}
|
|
|
|
private var setupCount: Int {
|
|
self.installedSkills.count(where: Self.needsSetup)
|
|
}
|
|
|
|
private var summaryCard: some View {
|
|
ProCard(radius: SettingsLayout.cardRadius) {
|
|
HStack(spacing: 12) {
|
|
ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Skills").font(OpenClawType.headline)
|
|
Text(self.summaryText)
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 8)
|
|
ProValuePill(value: self.summaryValue, color: self.summaryColor)
|
|
}
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
|
|
private var summaryText: String {
|
|
guard self.canRead else { return String(localized: "Connect to manage Gateway skills.") }
|
|
if self.isLoadingInstalled, self.installedSkills.isEmpty {
|
|
return String(localized: "Loading installed skills and readiness.")
|
|
}
|
|
return String(
|
|
format: String(localized: "%@ ready · %@ need setup"),
|
|
self.readyCount.formatted(),
|
|
self.setupCount.formatted())
|
|
}
|
|
|
|
private var summaryValue: String {
|
|
guard self.canRead else { return String(localized: "offline") }
|
|
if self.isLoadingInstalled, self.installedSkills.isEmpty {
|
|
return String(localized: "loading")
|
|
}
|
|
return self.installedSkills.count.formatted()
|
|
}
|
|
|
|
private var summaryColor: Color {
|
|
guard self.canRead else { return .secondary }
|
|
return self.setupCount > 0 ? OpenClawBrand.warn : OpenClawBrand.ok
|
|
}
|
|
|
|
private var sectionPicker: some View {
|
|
Picker(selection: self.$section) {
|
|
ForEach(SkillsSettingsSection.allCases) { section in
|
|
Text(verbatim: section.title).font(OpenClawType.captionSemiBold).tag(section)
|
|
}
|
|
} label: {
|
|
Text("Skills section").font(OpenClawType.captionSemiBold)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
|
|
private var installedContent: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
self.installedControls
|
|
if let notice {
|
|
SkillsNoticeCard(notice: notice)
|
|
}
|
|
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
|
VStack(spacing: 0) {
|
|
ProPanelHeader(
|
|
title: "Installed",
|
|
value: self.filteredInstalledSkills.count.formatted(),
|
|
actionIcon: self.isLoadingInstalled ? "hourglass" : "arrow.clockwise",
|
|
actionAccessibilityLabel: "Refresh Skills",
|
|
isActionDisabled: self.isLoadingInstalled,
|
|
action: { Task { await self.loadInstalled() } })
|
|
self.installedRows
|
|
}
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
}
|
|
|
|
private var installedControls: some View {
|
|
ProCard(radius: SettingsLayout.cardRadius) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
TextField(
|
|
text: self.$installedQuery,
|
|
prompt: Text("Search installed skills").font(OpenClawType.body))
|
|
{
|
|
Text("Search installed skills").font(OpenClawType.body)
|
|
}
|
|
.font(OpenClawType.body)
|
|
.textFieldStyle(.roundedBorder)
|
|
Picker(selection: self.$installedFilter) {
|
|
ForEach(InstalledSkillFilter.allCases) { filter in
|
|
Text(verbatim: filter.title).font(OpenClawType.captionSemiBold).tag(filter)
|
|
}
|
|
} label: {
|
|
Text("Installed skill filter").font(OpenClawType.captionSemiBold)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var installedRows: some View {
|
|
if !self.canRead {
|
|
ProStatusRow(
|
|
icon: "wifi.slash",
|
|
title: "Gateway offline",
|
|
detail: "Connect to load and manage installed skills.",
|
|
value: "offline",
|
|
color: .secondary)
|
|
} else if self.isLoadingInstalled, self.installedSkills.isEmpty {
|
|
ProStatusRow(
|
|
icon: "hourglass",
|
|
title: "Loading skills",
|
|
detail: "Reading the Gateway skill catalog.",
|
|
value: "loading",
|
|
color: OpenClawBrand.accent)
|
|
} else if self.filteredInstalledSkills.isEmpty {
|
|
ProStatusRow(
|
|
icon: "tray",
|
|
title: self.installedSkills.isEmpty ? "No skills installed" : "No matching skills",
|
|
detail: self.installedSkills.isEmpty
|
|
? "Browse ClawHub to discover and install skills."
|
|
: "Change the search or readiness filter.",
|
|
value: "empty",
|
|
color: .secondary)
|
|
} else {
|
|
ForEach(Array(self.filteredInstalledSkills.enumerated()), id: \.element.id) { index, skill in
|
|
if index > 0 {
|
|
Divider().padding(.leading, 58)
|
|
}
|
|
InstalledSkillRow(
|
|
skill: skill,
|
|
canAdmin: self.canAdmin,
|
|
isBusy: self.mutationIDs[skill.skillKey] != nil,
|
|
onToggle: { enabled in Task { await self.setEnabled(skill, enabled: enabled) } })
|
|
}
|
|
}
|
|
}
|
|
|
|
private var filteredInstalledSkills: [SkillStatus] {
|
|
let query = self.installedQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return self.installedSkills.filter { skill in
|
|
let matchesQuery = query.isEmpty
|
|
|| skill.name.localizedCaseInsensitiveContains(query)
|
|
|| skill.skillKey.localizedCaseInsensitiveContains(query)
|
|
|| skill.description.localizedCaseInsensitiveContains(query)
|
|
let matchesFilter = switch self.installedFilter {
|
|
case .all: true
|
|
case .ready: Self.isReady(skill)
|
|
case .setup: Self.needsSetup(skill)
|
|
case .off: skill.disabled
|
|
}
|
|
return matchesQuery && matchesFilter
|
|
}
|
|
}
|
|
|
|
private var browseContent: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
self.browseControls
|
|
if let notice {
|
|
SkillsNoticeCard(notice: notice)
|
|
}
|
|
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
|
VStack(spacing: 0) {
|
|
ProPanelHeader(
|
|
title: "ClawHub",
|
|
value: self.searchResults.count.formatted(),
|
|
actionIcon: self.isSearching ? "hourglass" : "arrow.clockwise",
|
|
actionAccessibilityLabel: "Search ClawHub",
|
|
isActionDisabled: self.isSearching || self.clawHubSupported == false,
|
|
action: { Task { await self.searchClawHub() } })
|
|
self.browseRows
|
|
}
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
}
|
|
|
|
private var browseControls: some View {
|
|
ProCard(radius: SettingsLayout.cardRadius) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
TextField(
|
|
text: self.$browseQuery,
|
|
prompt: Text("Search ClawHub").font(OpenClawType.body))
|
|
{
|
|
Text("Search ClawHub").font(OpenClawType.body)
|
|
}
|
|
.font(OpenClawType.body)
|
|
.textFieldStyle(.roundedBorder)
|
|
.submitLabel(.search)
|
|
.onSubmit { Task { await self.searchClawHub() } }
|
|
Button {
|
|
Task { await self.searchClawHub() }
|
|
} label: {
|
|
Label("Search", systemImage: "magnifyingglass").font(OpenClawType.subheadSemiBold)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!self.canRead || self.isSearching || self.clawHubSupported == false)
|
|
Text("The Gateway verifies the exact reviewed release before download.")
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var browseRows: some View {
|
|
if !self.canRead {
|
|
ProStatusRow(
|
|
icon: "wifi.slash",
|
|
title: "Gateway offline",
|
|
detail: "Connect to search and install ClawHub skills.",
|
|
value: "offline",
|
|
color: .secondary)
|
|
} else if self.clawHubSupported == false {
|
|
ProStatusRow(
|
|
icon: "arrow.up.circle",
|
|
title: "Gateway update required",
|
|
detail: "Update the Gateway to search and install ClawHub skills from iOS.",
|
|
value: "update",
|
|
color: OpenClawBrand.warn)
|
|
} else if self.isSearching, self.searchResults.isEmpty {
|
|
ProStatusRow(
|
|
icon: "hourglass",
|
|
title: "Searching ClawHub",
|
|
detail: "Loading verified skill releases.",
|
|
value: "loading",
|
|
color: OpenClawBrand.accent)
|
|
} else if self.searchResults.isEmpty {
|
|
ProStatusRow(
|
|
icon: "magnifyingglass",
|
|
title: "No skills found",
|
|
detail: "Try another search or refresh the catalog.",
|
|
value: "empty",
|
|
color: .secondary)
|
|
} else {
|
|
ForEach(Array(self.searchResults.enumerated()), id: \.element.id) { index, skill in
|
|
if index > 0 {
|
|
Divider().padding(.leading, 58)
|
|
}
|
|
ClawHubSkillRow(
|
|
skill: skill,
|
|
installed: SkillManagementContract.installed(self.installedSkills, slug: skill.slug),
|
|
isBusy: self.reviewingSlug == skill.slug,
|
|
onReview: { Task { await self.review(skill) } })
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadInitialState() async {
|
|
guard self.scenePhase == .active else { return }
|
|
guard self.canRead else {
|
|
self.resetGatewayState()
|
|
return
|
|
}
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
if self.loadedGatewayID != gatewayID {
|
|
self.resetGatewayState()
|
|
self.loadedGatewayID = gatewayID
|
|
}
|
|
await self.updateClawHubSupport()
|
|
await self.loadInstalled()
|
|
}
|
|
|
|
private func refreshVisibleSection() async {
|
|
await self.updateClawHubSupport()
|
|
if self.section == .installed {
|
|
await self.loadInstalled()
|
|
} else {
|
|
await self.loadInstalled()
|
|
await self.searchClawHub()
|
|
}
|
|
}
|
|
|
|
private func updateClawHubSupport() async {
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
guard let route = await appModel.operatorSession.currentRoute(ifGatewayID: gatewayID) else {
|
|
self.clawHubSupported = nil
|
|
return
|
|
}
|
|
var values: [Bool?] = []
|
|
for method in clawHubSkillGatewayMethods.sorted() {
|
|
let supported = await appModel.operatorSession.supportsServerMethod(
|
|
method,
|
|
ifCurrentRoute: route)
|
|
values.append(supported)
|
|
}
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
if values.contains(false) {
|
|
self.clawHubSupported = false
|
|
} else if values.allSatisfy({ $0 == true }) {
|
|
self.clawHubSupported = true
|
|
} else {
|
|
self.clawHubSupported = nil
|
|
}
|
|
}
|
|
|
|
private func loadInstalled() async {
|
|
guard self.canRead, self.installedLoadID == nil else { return }
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
let operationID = UUID()
|
|
self.installedLoadID = operationID
|
|
self.notice = nil
|
|
defer {
|
|
if self.installedLoadID == operationID {
|
|
self.installedLoadID = nil
|
|
}
|
|
}
|
|
do {
|
|
let route = try await gatewayRoute()
|
|
let skills = try await fetchInstalledSkills(route: route)
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.installedSkills = skills
|
|
} catch {
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Could not load skills"),
|
|
message: error.localizedDescription,
|
|
warning: nil,
|
|
isError: true)
|
|
}
|
|
}
|
|
|
|
private func searchClawHub() async {
|
|
guard self.canRead,
|
|
self.loadedGatewayID == self.appModel.connectedGatewayID,
|
|
self.clawHubSupported != false,
|
|
self.searchID == nil
|
|
else { return }
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
let operationID = UUID()
|
|
self.searchID = operationID
|
|
self.notice = nil
|
|
defer {
|
|
if self.searchID == operationID {
|
|
self.searchID = nil
|
|
}
|
|
}
|
|
do {
|
|
let route = try await gatewayRoute()
|
|
let request = ClawHubSearchRequest(
|
|
query: browseQuery.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
|
limit: 25)
|
|
let data = try await self.request(
|
|
method: "skills.search",
|
|
params: request,
|
|
timeoutSeconds: 20,
|
|
route: route)
|
|
let results = try JSONDecoder().decode(ClawHubSkillSearchResult.self, from: data).results
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.searchResults = results
|
|
} catch {
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "ClawHub unavailable"),
|
|
message: error.localizedDescription,
|
|
warning: nil,
|
|
isError: true)
|
|
}
|
|
}
|
|
|
|
private func review(_ skill: ClawHubSkillSummary) async {
|
|
guard self.canRead,
|
|
self.loadedGatewayID == self.appModel.connectedGatewayID,
|
|
self.reviewID == nil
|
|
else { return }
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
let operationID = UUID()
|
|
self.reviewID = operationID
|
|
self.reviewingSlug = skill.slug
|
|
self.notice = nil
|
|
defer {
|
|
if self.reviewID == operationID {
|
|
self.reviewID = nil
|
|
self.reviewingSlug = nil
|
|
}
|
|
}
|
|
do {
|
|
let route = try await gatewayRoute()
|
|
let data = try await request(
|
|
method: "skills.detail",
|
|
params: ClawHubDetailRequest(slug: skill.slug),
|
|
timeoutSeconds: 20,
|
|
route: route)
|
|
let detail = try JSONDecoder().decode(ClawHubSkillDetail.self, from: data)
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
guard let review = ClawHubSkillInstallReview(detail: detail, fallback: skill) else {
|
|
throw SkillsSettingsError.missingInstallVersion
|
|
}
|
|
self.reviewSheet = .install(review, route: route)
|
|
} catch {
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Could not review skill"),
|
|
message: error.localizedDescription,
|
|
warning: nil,
|
|
isError: true)
|
|
}
|
|
}
|
|
|
|
private func install(
|
|
_ review: ClawHubSkillInstallReview,
|
|
route: GatewayNodeSessionRoute,
|
|
acknowledgeRisk: Bool) async
|
|
{
|
|
guard self.canAdmin,
|
|
self.loadedGatewayID == self.appModel.connectedGatewayID,
|
|
self.installID == nil
|
|
else { return }
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
let operationID = UUID()
|
|
self.installID = operationID
|
|
self.installingSlug = review.slug
|
|
self.notice = nil
|
|
defer {
|
|
if self.installID == operationID {
|
|
self.installID = nil
|
|
self.installingSlug = nil
|
|
}
|
|
}
|
|
do {
|
|
let request = ClawHubInstallRequest(
|
|
source: "clawhub",
|
|
slug: review.slug,
|
|
version: review.version,
|
|
acknowledgeClawHubRisk: acknowledgeRisk ? true : nil,
|
|
timeoutMs: clawHubInstallTimeoutMilliseconds)
|
|
let data = try await self.request(
|
|
method: "skills.install",
|
|
params: request,
|
|
timeoutSeconds: 125,
|
|
route: route)
|
|
let result = try JSONDecoder().decode(SkillInstallResult.self, from: data)
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.installedSkills = try await self.fetchInstalledSkills(route: route)
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
guard SkillManagementContract.installed(
|
|
self.installedSkills,
|
|
slug: review.slug,
|
|
version: review.version)
|
|
else {
|
|
self.reviewSheet = nil
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Install result unknown"),
|
|
message: String(
|
|
localized:
|
|
"""
|
|
Reconnect, refresh Skills, then retry. \
|
|
The Gateway safely joins a matching install still running.
|
|
"""),
|
|
warning: result.warning,
|
|
isError: true)
|
|
return
|
|
}
|
|
self.reviewSheet = nil
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Installed"),
|
|
message: result.message,
|
|
warning: result.warning,
|
|
isError: false)
|
|
} catch let error as GatewayResponseError {
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
let rejection = SkillManagementContract.rejection(from: error, attemptedVersion: review.version)
|
|
if rejection.requiresAcknowledgement, !acknowledgeRisk {
|
|
self.reviewSheet = .risk(
|
|
review,
|
|
route: route,
|
|
message: rejection.message,
|
|
warning: rejection.warning)
|
|
} else {
|
|
self.reviewSheet = nil
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Gateway blocked install"),
|
|
message: rejection.message,
|
|
warning: rejection.warning,
|
|
isError: true)
|
|
}
|
|
} catch {
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
if let skills = try? await fetchInstalledSkills(route: route),
|
|
appModel.connectedGatewayID == gatewayID
|
|
{
|
|
self.installedSkills = skills
|
|
if SkillManagementContract.installed(skills, slug: review.slug, version: review.version) {
|
|
self.reviewSheet = nil
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Installed"),
|
|
message: String(localized: "The Gateway installed the reviewed version."),
|
|
warning: nil,
|
|
isError: false)
|
|
return
|
|
}
|
|
}
|
|
self.reviewSheet = nil
|
|
self.notice = SkillsNotice(
|
|
title: String(localized: "Install result unknown"),
|
|
message: error.localizedDescription,
|
|
warning: nil,
|
|
isError: true)
|
|
}
|
|
}
|
|
|
|
private func setEnabled(_ skill: SkillStatus, enabled: Bool) async {
|
|
guard self.canAdmin,
|
|
self.loadedGatewayID == self.appModel.connectedGatewayID,
|
|
self.mutationIDs[skill.skillKey] == nil
|
|
else { return }
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
let operationID = UUID()
|
|
self.mutationIDs[skill.skillKey] = operationID
|
|
self.notice = nil
|
|
defer {
|
|
if self.mutationIDs[skill.skillKey] == operationID {
|
|
self.mutationIDs.removeValue(forKey: skill.skillKey)
|
|
}
|
|
}
|
|
do {
|
|
let route = try await gatewayRoute()
|
|
_ = try await self.request(
|
|
method: "skills.update",
|
|
params: SkillEnabledRequest(skillKey: skill.skillKey, enabled: enabled),
|
|
timeoutSeconds: 20,
|
|
route: route)
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
let skills = try await fetchInstalledSkills(route: route)
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.installedSkills = skills
|
|
self.notice = SkillsNotice(
|
|
title: enabled ? String(localized: "Skill enabled") : String(localized: "Skill disabled"),
|
|
message: skill.name,
|
|
warning: nil,
|
|
isError: false)
|
|
} catch {
|
|
guard self.appModel.connectedGatewayID == gatewayID else { return }
|
|
self.notice = SkillsNotice(
|
|
title: enabled ? String(localized: "Could not enable skill") :
|
|
String(localized: "Could not disable skill"),
|
|
message: error.localizedDescription,
|
|
warning: nil,
|
|
isError: true)
|
|
}
|
|
}
|
|
|
|
private func fetchInstalledSkills(route: GatewayNodeSessionRoute) async throws -> [SkillStatus] {
|
|
let data = try await request(
|
|
method: "skills.status",
|
|
params: EmptySkillsRequest(),
|
|
timeoutSeconds: 20,
|
|
route: route)
|
|
return try JSONDecoder().decode(SkillsStatusReport.self, from: data).skills.sorted {
|
|
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private func request(
|
|
method: String,
|
|
params: some Encodable,
|
|
timeoutSeconds: Int,
|
|
route: GatewayNodeSessionRoute) async throws -> Data
|
|
{
|
|
let data = try JSONEncoder().encode(params)
|
|
guard let json = String(data: data, encoding: .utf8) else { throw SkillsSettingsError.invalidPayload }
|
|
return try await self.appModel.operatorSession.request(
|
|
method: method,
|
|
paramsJSON: json,
|
|
timeoutSeconds: timeoutSeconds,
|
|
ifCurrentRoute: route,
|
|
distinguishPreDispatchRouteChange: true)
|
|
}
|
|
|
|
private func gatewayRoute() async throws -> GatewayNodeSessionRoute {
|
|
let gatewayID = self.appModel.connectedGatewayID
|
|
guard let route = await appModel.operatorSession.currentRoute(ifGatewayID: gatewayID),
|
|
appModel.connectedGatewayID == gatewayID
|
|
else {
|
|
throw SkillsSettingsError.gatewayChanged
|
|
}
|
|
return route
|
|
}
|
|
|
|
private func resetGatewayState() {
|
|
self.loadedGatewayID = nil
|
|
self.installedSkills = []
|
|
self.searchResults = []
|
|
self.clawHubSupported = nil
|
|
self.notice = nil
|
|
self.reviewSheet = nil
|
|
self.installedLoadID = nil
|
|
self.searchID = nil
|
|
self.reviewID = nil
|
|
self.reviewingSlug = nil
|
|
self.installID = nil
|
|
self.installingSlug = nil
|
|
self.mutationIDs = [:]
|
|
}
|
|
|
|
static func isReady(_ skill: SkillStatus) -> Bool {
|
|
SkillManagementContract.ready(skill)
|
|
}
|
|
|
|
static func needsSetup(_ skill: SkillStatus) -> Bool {
|
|
SkillManagementContract.needsSetup(skill)
|
|
}
|
|
}
|
|
|
|
private struct InstalledSkillRow: View {
|
|
let skill: SkillStatus
|
|
let canAdmin: Bool
|
|
let isBusy: Bool
|
|
let onToggle: (Bool) -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Text(self.skill.emoji ?? "✨")
|
|
.font(OpenClawType.title3)
|
|
.frame(width: 32, height: 32)
|
|
.accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 7) {
|
|
Text(self.skill.name).font(OpenClawType.subheadSemiBold)
|
|
Text(verbatim: self.statusLabel)
|
|
.font(OpenClawType.caption2SemiBold)
|
|
.foregroundStyle(self.statusColor)
|
|
}
|
|
Text(self.skill.description)
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
if !self.missingSummary.isEmpty {
|
|
Text(verbatim: self.missingSummary)
|
|
.font(OpenClawType.caption2)
|
|
.foregroundStyle(OpenClawBrand.warn)
|
|
}
|
|
if let link = self.skill.clawhub, link.valid, let slug = link.slug {
|
|
Text(verbatim: [slug, link.installedVersion].compactMap(\.self).joined(separator: " · "))
|
|
.font(OpenClawType.monoSmall)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer(minLength: 8)
|
|
Button {
|
|
self.onToggle(self.skill.disabled)
|
|
} label: {
|
|
Text(self.skill.disabled ? String(localized: "Enable") : String(localized: "Disable"))
|
|
.font(OpenClawType.captionSemiBold)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(!self.canAdmin || self.isBusy)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private var statusLabel: String {
|
|
if self.skill.disabled {
|
|
return String(localized: "Off")
|
|
}
|
|
return SettingsSkillsDestination.isReady(self.skill)
|
|
? String(localized: "Ready")
|
|
: String(localized: "Needs Setup")
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
if self.skill.disabled {
|
|
return .secondary
|
|
}
|
|
return SettingsSkillsDestination.isReady(self.skill) ? OpenClawBrand.ok : OpenClawBrand.warn
|
|
}
|
|
|
|
private var missingSummary: String {
|
|
let values = self.skill.missing.bins + self.skill.missing.env + self.skill.missing.config
|
|
return values.isEmpty ? "" : String(format: String(localized: "Missing: %@"), values.joined(separator: ", "))
|
|
}
|
|
}
|
|
|
|
private struct ClawHubSkillRow: View {
|
|
let skill: ClawHubSkillSummary
|
|
let installed: Bool
|
|
let isBusy: Bool
|
|
let onReview: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ProIconBadge(systemName: "shippingbox", color: self.installed ? OpenClawBrand.ok : OpenClawBrand.accent)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(self.skill.displayName).font(OpenClawType.subheadSemiBold)
|
|
Text(self.skill.summary ?? self.skill.slug)
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
HStack(spacing: 6) {
|
|
Text(self.skill.slug).font(OpenClawType.monoSmall).foregroundStyle(.secondary)
|
|
if let version = self.skill.version {
|
|
Text(verbatim: version).font(OpenClawType.monoSmall).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
Spacer(minLength: 8)
|
|
Button(action: self.onReview) {
|
|
Text(self.installed ? String(localized: "Installed") : String(localized: "Install"))
|
|
.font(OpenClawType.captionSemiBold)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(self.isBusy || self.installed)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
}
|
|
}
|
|
|
|
private struct SkillsNotice: Equatable {
|
|
let title: String
|
|
let message: String
|
|
let warning: String?
|
|
let isError: Bool
|
|
}
|
|
|
|
private struct SkillsNoticeCard: View {
|
|
let notice: SkillsNotice
|
|
|
|
var body: some View {
|
|
ProCard(radius: SettingsLayout.cardRadius) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ProIconBadge(
|
|
systemName: self.notice.isError ? "exclamationmark.triangle" : "checkmark.circle",
|
|
color: self.notice.isError ? OpenClawBrand.warn : OpenClawBrand.ok)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(self.notice.title).font(OpenClawType.subheadSemiBold)
|
|
Text(self.notice.message).font(OpenClawType.caption).textSelection(.enabled)
|
|
if let warning = self.notice.warning {
|
|
Text(warning)
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
}
|
|
|
|
private struct SkillsInstallReviewSheet: View {
|
|
let review: ClawHubSkillInstallReview
|
|
let canInstall: Bool
|
|
let isInstalling: Bool
|
|
let onCancel: () -> Void
|
|
let onInstall: () -> Void
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
SkillsReviewDetails(review: self.review)
|
|
Text("The Gateway will verify this exact release with ClawHub before download.")
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
if !self.canInstall {
|
|
Text("This gateway connection needs operator.admin to install skills.")
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(OpenClawBrand.warn)
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.navigationTitle("Review ClawHub skill")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(action: self.onCancel) { Text("Cancel").font(OpenClawType.subheadSemiBold) }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(action: self.onInstall) {
|
|
Text("Verify and install").font(OpenClawType.subheadSemiBold)
|
|
}
|
|
.disabled(!self.canInstall || self.isInstalling)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|
|
|
|
private struct SkillsRiskReviewSheet: 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 {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Label {
|
|
Text("Gateway warning").font(OpenClawType.headline)
|
|
} icon: {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
}
|
|
.foregroundStyle(OpenClawBrand.warn)
|
|
SkillsReviewDetails(review: self.review)
|
|
Text(self.message).font(OpenClawType.body)
|
|
DisclosureGroup(isExpanded: self.$warningExpanded) {
|
|
Text(self
|
|
.warning ??
|
|
String(localized: "The Gateway requires explicit acknowledgement for this release."))
|
|
.font(OpenClawType.caption)
|
|
.textSelection(.enabled)
|
|
.padding(.top, 8)
|
|
} label: {
|
|
Text("Review warning details").font(OpenClawType.subheadSemiBold)
|
|
}
|
|
Text("Expand and review the Gateway warning before acknowledging this exact version.")
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(20)
|
|
}
|
|
.navigationTitle("Review Gateway warning")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(action: self.onCancel) { Text("Cancel").font(OpenClawType.subheadSemiBold) }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(action: self.onInstall) {
|
|
Text("Acknowledge and install").font(OpenClawType.subheadSemiBold)
|
|
}
|
|
.disabled(!self.warningExpanded || self.isInstalling)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|
|
|
|
private struct SkillsReviewDetails: View {
|
|
let review: ClawHubSkillInstallReview
|
|
|
|
var body: some View {
|
|
ProCard(radius: SettingsLayout.cardRadius) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(self.review.displayName).font(OpenClawType.headline)
|
|
if let summary = self.review.summary {
|
|
Text(summary).font(OpenClawType.body).foregroundStyle(.secondary)
|
|
}
|
|
SkillsReviewLine(label: "Version", value: self.review.version)
|
|
SkillsReviewLine(label: "Publisher", value: self.review.author)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SkillsReviewLine: View {
|
|
let label: LocalizedStringKey
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(self.label).font(OpenClawType.captionSemiBold).foregroundStyle(.secondary)
|
|
Text(self.value).font(OpenClawType.body)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EmptySkillsRequest: Encodable {}
|
|
|
|
private struct ClawHubSearchRequest: Encodable {
|
|
let query: String?
|
|
let limit: Int
|
|
}
|
|
|
|
private struct ClawHubDetailRequest: Encodable { let slug: String }
|
|
|
|
private struct ClawHubInstallRequest: Encodable {
|
|
let source: String
|
|
let slug: String
|
|
let version: String
|
|
let acknowledgeClawHubRisk: Bool?
|
|
let timeoutMs: Int
|
|
}
|
|
|
|
private struct SkillEnabledRequest: Encodable {
|
|
let skillKey: String
|
|
let enabled: Bool
|
|
}
|
|
|
|
private enum SkillsSettingsError: LocalizedError {
|
|
case gatewayChanged
|
|
case invalidPayload
|
|
case missingInstallVersion
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .gatewayChanged: String(localized: "The connected Gateway changed. Refresh Skills and try again.")
|
|
case .invalidPayload: String(localized: "Could not encode the Gateway request.")
|
|
case .missingInstallVersion: String(localized: "ClawHub did not report an installable version for this skill.")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
fileprivate var nilIfEmpty: String? {
|
|
isEmpty ? nil : self
|
|
}
|
|
}
|