mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 00:23:25 -06:00
00854a7002
* fix(macos): let AI setup proceed when the login keychain is unavailable A missing/locked login keychain made GatewayActivationBindingKeyStore unable to mint the activation binding key, and onboarding refused every candidate and manual-key activation with 'Secure storage is unavailable'. The fingerprint only protects the crash/relaunch resume receipt, so degrade instead of refusing: write an ownerless pending record (still matched exactly, never as a wildcard), keep the full activation-ambiguity window, and skip only restart reconciliation, which needs fingerprint proof by design. A relaunch then repeats activation rather than trusting the receipt. Reported by Peter Steinberger. * fix(macos): render proper provider brand icons in onboarding AI setup Nearly every provider icon URL the gateway sends is a simpleicons.org SVG, which AsyncImage cannot decode, so the Connect your AI page fell back to generic symbols for every tool and provider; the two GitHub avatar PNGs that did render clashed as full-color rasters. Bundle nine monochrome template marks (Simple Icons CC0; xAI from LobeHub icons, MIT) and resolve them local-first by brand id/kind, including composed choice ids like xai-oauth. Remote icons now load via NSImage(data:), which decodes SVG, with vector payloads tinted as templates so plugin-supplied icons blend too. Every glyph sits in a uniform rounded well; install cards show host-only links, hover states, and a softer translucent surface shared by all setup sections instead of controlBackgroundColor. Requested by Peter Steinberger. * fix(macos): use failable UTF-8 decode in icon vector sniffing (swiftlint) * fix(macos): address ClawSweeper review of onboarding icon/keychain changes - Refuse relaunch handoff from ownerless (keychain-unavailable) completed receipts at the reconciliation trust boundary; such receipts can belong to replaced credentials, so setup repeats a fresh activation instead. - Decode and pass the canonical brandId the gateway sends for candidates, auth options, and manual providers so bundled marks resolve for opaque choice ids. - Scan the bounded XML prolog (comments, declarations, doctype) when sniffing remote SVG payloads; comment-prefixed vectors now tint as templates. * refactor(macos): move setup error enum to support file (swiftlint file length) * chore(i18n): refresh native source inventory * fix(macos): give keychain-unavailable activations attempt-specific unbound leases A nil owner made concurrent unbound attempts indistinguishable: a stale attempt's delayed response could complete or clear a newer attempt's record. Unbound attempts now mint a random per-attempt lease id with a sentinel fingerprint — live matching stays attempt-exact, restart reconciliation's fingerprint guard rejects them, and relaunch verification refuses unbound receipts before any handoff. * chore(macos): ship third-party artwork notices with provider icons Simple Icons (CC0) attribution and the LobeHub Icons MIT license text for the xAI mark now travel inside the bundled ProviderIcons directory. * fix(ci): refresh plugin-sdk API manifest for private-type hashing #120975 changed the baseline generator to hash private types but landed without regenerating the manifest; the check-plugin-sdk-api-baseline lane was gated off on that PR and first failed here. Regenerated via pnpm plugin-sdk:api:gen with no SDK surface change in this branch.
206 lines
7.5 KiB
Swift
206 lines
7.5 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
enum OnboardingProviderIcon {
|
|
private static let resourceBundle: Bundle? = locateResourceBundle()
|
|
private static var imageCache: [String: NSImage] = [:]
|
|
|
|
static func resourceURL(for kind: String) -> URL? {
|
|
guard let name = resourceName(for: kind) else { return nil }
|
|
return self.resourceBundle?.url(
|
|
forResource: name,
|
|
withExtension: "svg",
|
|
subdirectory: "ProviderIcons")
|
|
}
|
|
|
|
static func image(for kind: String) -> NSImage? {
|
|
guard let name = resourceName(for: kind) else { return nil }
|
|
if let image = self.imageCache[name] {
|
|
return image
|
|
}
|
|
guard let url = self.resourceBundle?.url(
|
|
forResource: name,
|
|
withExtension: "svg",
|
|
subdirectory: "ProviderIcons"),
|
|
let image = NSImage(contentsOf: url)
|
|
else { return nil }
|
|
image.isTemplate = true
|
|
self.imageCache[name] = image
|
|
return image
|
|
}
|
|
|
|
static func image(brandCandidates: [String?]) -> NSImage? {
|
|
brandCandidates.lazy.compactMap(\.self).compactMap { self.image(for: $0) }.first
|
|
}
|
|
|
|
private static func resourceName(for kind: String) -> String? {
|
|
let normalized = kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if let name = self.brandResourceName(normalized) {
|
|
return name
|
|
}
|
|
// Auth/provider choice ids compose the brand with a method suffix
|
|
// ("xai-oauth", "anthropic-vertex"); fall back to the leading token.
|
|
guard let brand = normalized.split(separator: "-").first, brand != normalized[...]
|
|
else { return nil }
|
|
return self.brandResourceName(String(brand))
|
|
}
|
|
|
|
private static func brandResourceName(_ normalized: String) -> String? {
|
|
switch normalized {
|
|
case "claude-cli", "claude-code", "claude", "anthropic": "ProviderIcon-claude"
|
|
case "codex-cli", "codex", "openai", "chatgpt": "ProviderIcon-codex"
|
|
case "gemini-cli", "gemini", "googlegemini", "google": "ProviderIcon-gemini"
|
|
case "ollama": "ProviderIcon-ollama"
|
|
case "lmstudio", "lm-studio": "ProviderIcon-lmstudio"
|
|
case "pi": "ProviderIcon-pi"
|
|
case "opencode": "ProviderIcon-opencode"
|
|
case "kimi-code", "kimi", "moonshot": "ProviderIcon-kimi"
|
|
case "grok-build", "grok", "xai": "ProviderIcon-xai"
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
private static func locateResourceBundle() -> Bundle? {
|
|
if self.bundleContainsProviderIcons(Bundle.main) {
|
|
return Bundle.main
|
|
}
|
|
// Packaged apps copy these vectors into Bundle.main. SwiftPM's generated
|
|
// Bundle.module accessor can fatalError when that sidecar is absent.
|
|
if Bundle.main.bundleURL.pathExtension != "app",
|
|
self.bundleContainsProviderIcons(Bundle.module)
|
|
{
|
|
return Bundle.module
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func bundleContainsProviderIcons(_ bundle: Bundle) -> Bool {
|
|
bundle.url(
|
|
forResource: "ProviderIcon-claude",
|
|
withExtension: "svg",
|
|
subdirectory: "ProviderIcons") != nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
enum OnboardingRemoteProviderIcon {
|
|
struct LoadedIcon {
|
|
let nsImage: NSImage
|
|
let isVector: Bool
|
|
}
|
|
|
|
private static var cache: [String: LoadedIcon] = [:]
|
|
|
|
static func load(_ url: URL) async -> LoadedIcon? {
|
|
let key = url.absoluteString
|
|
if let cached = self.cache[key] {
|
|
return cached
|
|
}
|
|
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
|
let icon = self.decode(data)
|
|
else { return nil }
|
|
self.cache[key] = icon
|
|
return icon
|
|
}
|
|
|
|
static func decode(_ data: Data) -> LoadedIcon? {
|
|
let isVector = self.isVector(data)
|
|
guard let image = NSImage(data: data) else { return nil }
|
|
image.isTemplate = isVector
|
|
return LoadedIcon(nsImage: image, isVector: isVector)
|
|
}
|
|
|
|
private static func isVector(_ data: Data) -> Bool {
|
|
// XML declarations, comments, and doctypes routinely precede the <svg>
|
|
// root (simpleicons, exported assets); scan the bounded prolog for it.
|
|
let bytes = Array(data.prefix(512))
|
|
var index = bytes.starts(with: [0xEF, 0xBB, 0xBF]) ? 3 : 0
|
|
func skip(past terminator: [UInt8]) -> Bool {
|
|
while index + terminator.count <= bytes.count {
|
|
if Array(bytes[index..<index + terminator.count]) == terminator {
|
|
index += terminator.count
|
|
return true
|
|
}
|
|
index += 1
|
|
}
|
|
return false
|
|
}
|
|
while index < bytes.count {
|
|
while index < bytes.count, [0x09, 0x0A, 0x0C, 0x0D, 0x20].contains(bytes[index]) {
|
|
index += 1
|
|
}
|
|
guard let head = String(bytes: bytes[index...].prefix(9), encoding: .utf8)?.lowercased()
|
|
else { return false }
|
|
if head.hasPrefix("<svg") { return true }
|
|
if head.hasPrefix("<?xml") {
|
|
guard skip(past: Array("?>".utf8)) else { return false }
|
|
} else if head.hasPrefix("<!--") {
|
|
guard skip(past: Array("-->".utf8)) else { return false }
|
|
} else if head.hasPrefix("<!doctype") {
|
|
guard skip(past: Array(">".utf8)) else { return false }
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
struct OnboardingProviderArtwork: View {
|
|
let icon: String?
|
|
let brandCandidates: [String?]
|
|
let fallbackSymbol: String
|
|
var wellSize: CGFloat = 32
|
|
var iconSize: CGFloat = 18
|
|
|
|
@State private var remoteIcon: OnboardingRemoteProviderIcon.LoadedIcon?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(Color.primary.opacity(0.06))
|
|
self.glyph
|
|
.frame(width: self.iconSize, height: self.iconSize)
|
|
}
|
|
.frame(width: self.wellSize, height: self.wellSize)
|
|
.task(id: [self.icon] + self.brandCandidates) {
|
|
self.remoteIcon = nil
|
|
guard OnboardingProviderIcon.image(brandCandidates: self.brandCandidates) == nil,
|
|
let url = OnboardingProviderAuthLink.safeURL(self.icon)
|
|
else { return }
|
|
let loaded = await OnboardingRemoteProviderIcon.load(url)
|
|
guard !Task.isCancelled else { return }
|
|
self.remoteIcon = loaded
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var glyph: some View {
|
|
if let image = OnboardingProviderIcon.image(brandCandidates: self.brandCandidates) {
|
|
Image(nsImage: image)
|
|
.renderingMode(.template)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.foregroundStyle(.primary)
|
|
} else if let remoteIcon, remoteIcon.isVector {
|
|
Image(nsImage: remoteIcon.nsImage)
|
|
.renderingMode(.template)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.foregroundStyle(.primary)
|
|
} else if let remoteIcon {
|
|
Image(nsImage: remoteIcon.nsImage)
|
|
.renderingMode(.original)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
} else {
|
|
Image(systemName: self.fallbackSymbol)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|