Files
openclaw/apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift
Peter Steinberger 9ad59ae788 improve(macos): replace browser cookie-import modal with an inline dashboard banner (#104591)
* 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
2026-07-11 11:11:42 -07:00

266 lines
9.5 KiB
Swift

import Foundation
import Observation
import OpenClawKit
struct BrowserSystemProfile: Codable, Equatable {
let browser: String
let id: String
let name: String
let hasCookies: Bool
var displayName: String {
"\(self.browserDisplayName)\(self.name)"
}
var browserDisplayName: String {
self.browser.prefix(1).uppercased() + self.browser.dropFirst()
}
/// `id` is only unique per browser (Chrome and Brave both ship "Default");
/// UI identity must span browsers or menu rows collide.
var menuID: String {
"\(self.browser)/\(self.id)"
}
}
enum BrowserProfileImportDisposition: String, Codable, Equatable {
case dismissed
case imported
}
struct BrowserProfileImportOutcome: Codable, Equatable {
let status: BrowserProfileImportDisposition
}
struct BrowserProfileImportStatus: Codable, Equatable {
let enabled: Bool
let systemProfiles: [BrowserSystemProfile]
let state: BrowserProfileImportOutcome?
let suggestedTarget: String
var importableProfiles: [BrowserSystemProfile] {
self.systemProfiles.filter(\.hasCookies)
}
}
struct BrowserProfileImportResult: Codable, Equatable {
struct Counts: Codable, Equatable {
let total: Int
let imported: Int
}
let into: String
let cookies: Counts
}
struct BrowserProfileImportRequest: Equatable {
let method: String
let path: String
let body: [String: AnyCodable]?
let timeoutMs: Double?
}
/// Drives the dashboard's browser-login import banner: it mirrors the
/// gateway-persisted import state and owns the offer import outcome flow
/// that used to live in a modal alert.
@MainActor
@Observable
final class BrowserProfileImportModel {
enum Phase: Equatable {
case hidden
case offering(BrowserProfileImportStatus)
case importing(profile: BrowserSystemProfile, target: String)
case imported(BrowserProfileImportResult)
case failed(message: String, retry: BrowserProfileImportStatus)
}
enum ForceRefreshOutcome: Equatable {
case offering
case unavailable(title: String, message: String)
}
typealias Transport = @MainActor (BrowserProfileImportRequest) async throws -> Data
static let shared = BrowserProfileImportModel()
private(set) var phase: Phase = .hidden
/// Bumped by setPhase; see refresh(force:) for the staleness contract.
@ObservationIgnored private var phaseGeneration = 0
/// A dismissal must stick for this app session even while its
/// fire-and-forget persistence write is pending or lost otherwise the
/// next automatic poll reads the still-null server state and re-offers.
/// Only Settings force-refresh overrides.
@ObservationIgnored private var dismissedThisSession = false
private let transport: Transport
private let isOnboarded: @MainActor () -> Bool
private let isLocalMode: @MainActor () -> Bool
init(
transport: @escaping Transport = BrowserProfileImportModel.gatewayTransport,
isOnboarded: @escaping @MainActor () -> Bool = { AppStateStore.shared.onboardingSeen },
isLocalMode: @escaping @MainActor () -> Bool = { AppStateStore.shared.connectionMode == .local })
{
self.transport = transport
self.isOnboarded = isOnboarded
self.isLocalMode = isLocalMode
}
static func shouldOffer(status: BrowserProfileImportStatus, force: Bool) -> Bool {
status.enabled && !status.importableProfiles.isEmpty && (force || status.state == nil)
}
/// Every phase change goes through here. The generation lets an awaited
/// status poll detect that a dismissal, import, or fresher poll happened
/// while it was in flight "phase is .hidden again" alone cannot tell a
/// just-dismissed banner apart from one that never appeared.
private func setPhase(_ phase: Phase) {
self.phase = phase
self.phaseGeneration += 1
}
/// Launch/connect/window triggers. Only fills an empty banner slot so a
/// visible offer, in-flight import, or result is never clobbered by a
/// background status poll.
@discardableResult
func refreshIfIdle() async -> Bool {
guard !self.dismissedThisSession, case .hidden = self.phase else { return false }
await self.refresh(force: false)
return true
}
/// Force (Settings Import) re-offers even after a persisted dismissal
/// and reports why nothing can be offered so the caller can tell the user.
@discardableResult
func refresh(force: Bool) async -> ForceRefreshOutcome {
guard self.isOnboarded(), self.isLocalMode() else {
self.setPhase(.hidden)
return .unavailable(
title: String(localized: "Browser import requires Local mode"),
message: String(
localized: "Switch this Mac app to a local Gateway before importing browser cookies."))
}
let generation = self.phaseGeneration
do {
let status: BrowserProfileImportStatus = try await self.request(
method: "GET",
path: "/system-profile-import/status")
// The status await interleaves with user actions. Idle polls apply
// only if nothing changed since they started (a dismissal mid-poll
// must stay dismissed); a forced refresh wins over everything
// except an import that is already running.
if force {
if case .importing = self.phase { return .offering }
} else {
guard self.phaseGeneration == generation else { return .offering }
}
guard Self.shouldOffer(status: status, force: force) else {
self.setPhase(.hidden)
let message = status.enabled
? String(
localized: "No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac.")
: String(
localized: "System browser profile import is disabled in the local Gateway configuration.")
return .unavailable(title: String(localized: "No browser login available"), message: message)
}
self.setPhase(.offering(status))
return .offering
} catch {
// Same interleaving rule: a failed poll only clears state it owns.
if force, self.phaseGeneration == generation {
if case .importing = self.phase {} else { self.setPhase(.hidden) }
}
return .unavailable(
title: String(localized: "Browser import unavailable"),
message: error.localizedDescription)
}
}
func importProfile(_ profile: BrowserSystemProfile) async {
guard case let .offering(status) = self.phase else { return }
self.setPhase(.importing(profile: profile, target: status.suggestedTarget))
do {
let body: [String: AnyCodable] = [
"browser": AnyCodable(profile.browser),
"systemProfile": AnyCodable(profile.id),
"into": AnyCodable(status.suggestedTarget),
"makeDefault": AnyCodable(true),
]
let result: BrowserProfileImportResult = try await self.request(
method: "POST",
path: "/profiles/import",
body: body,
timeoutMs: 120_000)
self.setPhase(.imported(result))
} catch {
self.setPhase(.failed(message: error.localizedDescription, retry: status))
}
}
func retry() {
guard case let .failed(_, status) = self.phase else { return }
self.setPhase(.offering(status))
}
func dismiss() {
let wasOffering = if case .offering = self.phase {
true
} else {
false
}
self.setPhase(.hidden)
// Only an unanswered offer persists the dismissal; closing a result
// banner must not overwrite the recorded "imported" state.
guard wasOffering else { return }
self.dismissedThisSession = true
Task {
let _: [String: Bool]? = try? await self.request(
method: "POST",
path: "/system-profile-import/dismiss")
}
}
/// Import endpoints are host-local; a remote gateway can neither list nor
/// import system profiles, so the banner withdraws on mode switches.
func handleConnectionModeChange() {
guard !self.isLocalMode() else { return }
self.setPhase(.hidden)
}
private func request<T: Decodable>(
method: String,
path: String,
body: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> T
{
let data = try await self.transport(BrowserProfileImportRequest(
method: method,
path: path,
body: body,
timeoutMs: timeoutMs))
return try JSONDecoder().decode(T.self, from: data)
}
private static let gatewayTransport: Transport = { request in
var params: [String: AnyCodable] = [
"method": AnyCodable(request.method),
"path": AnyCodable(request.path),
]
if let body = request.body {
params["body"] = AnyCodable(body)
}
return try await GatewayConnection.shared.request(
method: "browser.request",
params: params,
timeoutMs: request.timeoutMs)
}
}
#if DEBUG
extension BrowserProfileImportModel {
func _testSetPhase(_ phase: Phase) {
self.setPhase(phase)
}
}
#endif