Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingProviderArtwork.swift
Peter Steinberger 00854a7002 fix(macos): proper provider brand icons + softer contrast in onboarding AI setup (#120907)
* 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.
2026-08-09 01:49:50 -07:00

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)
}
}
}