mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 09:56:06 +00:00
* feat(macos): replace browser cookie-import modal with dashboard banner * fix(macos): cross-browser menu identity, banner pane ownership, docs map * chore: refresh native i18n inventory * fix(macos): stale import status polls no longer clobber fresher banner state * fix(macos): generation-guard stale import polls; drop dashboard-show poll trigger * fix(macos): session-sticky dismissal and localized banner copy
247 lines
9.4 KiB
Swift
247 lines
9.4 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Presentation for one banner phase, derived separately from the view so the
|
|
/// copy and available actions stay unit-testable.
|
|
struct BrowserProfileImportBannerContent: Equatable {
|
|
enum Badge: Equatable {
|
|
case globe
|
|
case progress
|
|
case success
|
|
case failure
|
|
}
|
|
|
|
enum Action: Equatable {
|
|
case importProfiles([BrowserSystemProfile])
|
|
case retry
|
|
case none
|
|
}
|
|
|
|
let title: String
|
|
let subtitle: String
|
|
let badge: Badge
|
|
let action: Action
|
|
|
|
static func content(for phase: BrowserProfileImportModel.Phase) -> BrowserProfileImportBannerContent? {
|
|
switch phase {
|
|
case .hidden:
|
|
return nil
|
|
case let .offering(status):
|
|
let profiles = status.importableProfiles
|
|
let browsers = Self.browserList(for: profiles)
|
|
return BrowserProfileImportBannerContent(
|
|
title: String(localized: "Use your browser logins"),
|
|
subtitle: String(localized: """
|
|
Copy cookies from \(browsers) into an isolated agent profile. \
|
|
Passwords are never touched.
|
|
"""),
|
|
badge: .globe,
|
|
action: .importProfiles(profiles))
|
|
case let .importing(profile, target):
|
|
return BrowserProfileImportBannerContent(
|
|
title: String(localized: "Importing browser cookies…"),
|
|
subtitle: String(localized: """
|
|
Copying \(profile.displayName) into “\(target)”. Touch ID may be required.
|
|
"""),
|
|
badge: .progress,
|
|
action: .none)
|
|
case let .imported(result):
|
|
return BrowserProfileImportBannerContent(
|
|
title: String(localized: "Browser logins imported"),
|
|
subtitle: String(localized: """
|
|
\(result.cookies.imported) of \(result.cookies.total) cookies copied into \
|
|
“\(result.into)” — now the default profile for agent browsing.
|
|
"""),
|
|
badge: .success,
|
|
action: .none)
|
|
case let .failed(message, _):
|
|
return BrowserProfileImportBannerContent(
|
|
title: String(localized: "Browser import failed"),
|
|
subtitle: message,
|
|
badge: .failure,
|
|
action: .retry)
|
|
}
|
|
}
|
|
|
|
static func browserList(for profiles: [BrowserSystemProfile]) -> String {
|
|
var names: [String] = []
|
|
for profile in profiles {
|
|
let name = profile.browserDisplayName
|
|
if !names.contains(name) {
|
|
names.append(name)
|
|
}
|
|
}
|
|
switch names.count {
|
|
case 0:
|
|
return String(localized: "your browser")
|
|
case 1:
|
|
return names[0]
|
|
default:
|
|
return ListFormatter.localizedString(byJoining: names)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Floating, dismissible import offer shown over the dashboard — the inline
|
|
/// replacement for the old modal browser-login alert.
|
|
struct BrowserProfileImportBannerView: View {
|
|
let model: BrowserProfileImportModel
|
|
|
|
var body: some View {
|
|
if let content = BrowserProfileImportBannerContent.content(for: self.model.phase) {
|
|
self.card(content)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
}
|
|
|
|
private func card(_ content: BrowserProfileImportBannerContent) -> some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
BannerBadgeIcon(badge: content.badge)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(content.title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
Text(content.subtitle)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 12)
|
|
self.trailingControls(for: content)
|
|
self.dismissButton(for: content)
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.leading, 12)
|
|
.padding(.trailing, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel("\(content.title). \(content.subtitle)")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func trailingControls(for content: BrowserProfileImportBannerContent) -> some View {
|
|
switch content.action {
|
|
case let .importProfiles(profiles):
|
|
if profiles.count == 1, let profile = profiles.first {
|
|
Button("Import") {
|
|
Task { await self.model.importProfile(profile) }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
} else {
|
|
Menu("Import") {
|
|
ForEach(profiles, id: \.menuID) { profile in
|
|
Button(profile.displayName) {
|
|
Task { await self.model.importProfile(profile) }
|
|
}
|
|
}
|
|
}
|
|
.menuStyle(.button)
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
.fixedSize()
|
|
}
|
|
case .retry:
|
|
Button("Retry") {
|
|
self.model.retry()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
case .none:
|
|
if content.badge == .progress {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dismissButton(for content: BrowserProfileImportBannerContent) -> some View {
|
|
Button {
|
|
self.model.dismiss()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 20, height: 20)
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(content.badge == .progress)
|
|
.opacity(content.badge == .progress ? 0.4 : 1)
|
|
.help("Dismiss")
|
|
.accessibilityLabel("Dismiss")
|
|
}
|
|
}
|
|
|
|
extension BrowserProfileImportBannerView {
|
|
/// Wraps the dashboard web view in a pane that floats the shared import
|
|
/// banner on top; the banner renders empty (zero height) until the model
|
|
/// has an offer or outcome to show.
|
|
@MainActor
|
|
static func makeDashboardPane(webView: NSView) -> NSView {
|
|
let container = NSView()
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
container.addSubview(webView)
|
|
let banner = NSHostingView(rootView: BrowserProfileImportBannerView(model: .shared))
|
|
banner.translatesAutoresizingMaskIntoConstraints = false
|
|
container.addSubview(banner)
|
|
// Preferred card width; the required side insets win on narrow panes.
|
|
let bannerWidth = banner.widthAnchor.constraint(equalToConstant: 560)
|
|
bannerWidth.priority = .defaultHigh
|
|
NSLayoutConstraint.activate([
|
|
webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
webView.topAnchor.constraint(equalTo: container.topAnchor),
|
|
webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
// Float just below the 50px titlebar clearance the native chrome
|
|
// CSS reserves so the card never collides with window controls.
|
|
banner.topAnchor.constraint(equalTo: container.topAnchor, constant: 58),
|
|
banner.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
banner.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 16),
|
|
bannerWidth,
|
|
])
|
|
return container
|
|
}
|
|
}
|
|
|
|
/// App icon with a status badge in the corner — mirrors the familiar
|
|
/// "import from browser" banner iconography without shipping third-party logos.
|
|
private struct BannerBadgeIcon: View {
|
|
let badge: BrowserProfileImportBannerContent.Badge
|
|
|
|
var body: some View {
|
|
Image(nsImage: NSApp.applicationIconImage ?? NSImage())
|
|
.resizable()
|
|
.frame(width: 34, height: 34)
|
|
.overlay(alignment: .bottomTrailing) {
|
|
self.badgeSymbol
|
|
.frame(width: 16, height: 16)
|
|
.background(Circle().fill(.background))
|
|
.offset(x: 4, y: 4)
|
|
}
|
|
.padding(.trailing, 2)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var badgeSymbol: some View {
|
|
switch self.badge {
|
|
case .globe, .progress:
|
|
Image(systemName: "globe")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
case .success:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.green)
|
|
case .failure:
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
}
|