Files
openclaw/apps/macos/Sources/OpenClaw/GatewaySettings.swift
2026-07-21 16:15:40 +08:00

293 lines
11 KiB
Swift

import SwiftUI
@MainActor
struct GatewaySettings: View {
@State private var profiles: [MacGatewayProfile]
@State private var hasLoaded: Bool
@State private var isLoading = false
@State private var isRemoving = false
@State private var showsAddGateway = false
@State private var pendingRemoval: MacGatewayProfile?
@State private var errorMessage: String?
private let isPreview: Bool
init(
profiles: [MacGatewayProfile]? = nil,
isPreview: Bool = ProcessInfo.processInfo.isPreview)
{
_profiles = State(initialValue: profiles ?? [])
_hasLoaded = State(initialValue: profiles != nil)
self.isPreview = isPreview
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
self.header
self.content
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.task {
guard !self.hasLoaded, !self.isPreview else { return }
self.hasLoaded = true
await self.refresh()
}
.sheet(isPresented: self.$showsAddGateway) {
GatewayProfileEditor { profile in
self.profiles.removeAll { $0.id == profile.id }
self.profiles.append(profile)
self.profiles = MacGatewayProfileStore.sortedProfiles(self.profiles)
}
}
.alert("Remove Gateway?", isPresented: Binding(
get: { self.pendingRemoval != nil },
set: {
if !$0 {
self.pendingRemoval = nil
}
})) {
Button("Cancel", role: .cancel) {
self.pendingRemoval = nil
}
Button("Remove", role: .destructive) {
guard let profile = self.pendingRemoval else { return }
self.pendingRemoval = nil
// Serialize profile mutations across the Keychain and
// connection-shutdown awaits so a same-ID re-add cannot race.
self.isRemoving = true
Task { await self.remove(profile) }
}
} message: {
if let profile = self.pendingRemoval {
Text("\(profile.name) and its saved credentials will be removed. Its open windows will close.")
}
}
.alert("Gateway Error", isPresented: Binding(
get: { self.errorMessage != nil },
set: {
if !$0 {
self.errorMessage = nil
}
})) {
Button("OK") {
self.errorMessage = nil
}
} message: {
Text(self.errorMessage ?? "")
}
}
private var header: some View {
HStack(alignment: .top, spacing: 16) {
SettingsPageHeader(
title: "Gateways",
subtitle: """
Save Gateway connections for chat windows. The primary Gateway under \
Connection still owns Mac integrations and Talk Mode.
""")
Spacer(minLength: 16)
Button {
guard !self.isRemoving else { return }
self.showsAddGateway = true
} label: {
Label("Add Gateway", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.disabled(self.isRemoving)
}
}
@ViewBuilder
private var content: some View {
if self.isLoading, self.profiles.isEmpty {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Loading Gateways…")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
} else if self.profiles.isEmpty {
SettingsCardGroup("Saved Gateways") {
VStack(alignment: .leading, spacing: 8) {
Text("No Gateways saved")
.font(.callout.weight(.medium))
Text(
"""
Add a Gateway here, then use File → New Gateway Window… (⌘N) whenever \
you want another window for it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
}
} else {
ScrollView(.vertical) {
SettingsCardGroup("Saved Gateways") {
ForEach(Array(self.profiles.enumerated()), id: \.element.id) { index, profile in
SettingsCardRow(
title: .verbatim(profile.name),
subtitle: .verbatim(profile.url.absoluteString),
showsDivider: index != self.profiles.count - 1)
{
HStack(spacing: 8) {
Button("Open Window") {
guard !self.isRemoving else { return }
WebChatManager.shared.openGatewayWindow(profile: profile)
}
.disabled(self.isRemoving)
Button(role: .destructive) {
guard !self.isRemoving else { return }
self.pendingRemoval = profile
} label: {
Image(systemName: "trash")
}
.accessibilityLabel("Remove \(profile.name)")
.help("Remove \(profile.name)")
.disabled(self.isRemoving)
}
}
}
}
}
}
}
private func refresh() async {
self.isLoading = true
defer { self.isLoading = false }
do {
self.profiles = try await MacGatewayProfileStore.shared.profiles()
} catch {
self.errorMessage = error.localizedDescription
}
}
private func remove(_ profile: MacGatewayProfile) async {
defer { self.isRemoving = false }
do {
try await MacGatewayProfileStore.shared.remove(profileID: profile.id)
// Reflect the durable removal before connection shutdown suspends;
// a same-endpoint re-add during shutdown must remain visible.
self.profiles.removeAll { $0.id == profile.id }
await WebChatManager.shared.closeGatewayWindows(profileID: profile.id)
} catch {
self.errorMessage = error.localizedDescription
}
}
}
@MainActor
private struct GatewayProfileEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var url = "wss://"
@State private var token = ""
@State private var password = ""
@State private var isSaving = false
@State private var errorMessage: String?
let onSaved: (MacGatewayProfile) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 18) {
SettingsPageHeader(
title: "Add Gateway",
subtitle: "Credentials are stored with this profile in your Mac Keychain.")
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 12) {
GridRow {
Text("Name")
TextField("Studio", text: self.$name)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Gateway URL")
TextField("wss://gateway.example.com", text: self.$url)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Token")
SecureField("Optional", text: self.$token)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Password")
SecureField("Optional", text: self.$password)
.textFieldStyle(.roundedBorder)
}
}
.gridColumnAlignment(.leading)
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
HStack {
Spacer()
Button("Cancel", role: .cancel) {
self.dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Add Gateway") {
Task { await self.save() }
}
.keyboardShortcut(.defaultAction)
.disabled(self.isSaving || self.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 540)
}
private func save() async {
self.isSaving = true
self.errorMessage = nil
defer { self.isSaving = false }
do {
let rawURL = self.url.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: rawURL) else {
throw MacGatewayProfileError.invalidURL
}
let profile = try await MacGatewayProfileStore.shared.upsert(
name: self.name,
url: url,
token: self.token,
password: self.password)
WebChatManager.shared.gatewayProfileDidSave(profileID: profile.id)
self.onSaved(profile)
self.dismiss()
} catch {
self.errorMessage = error.localizedDescription
}
}
}
#if DEBUG
struct GatewaySettings_Previews: PreviewProvider {
static var previews: some View {
GatewaySettings(profiles: [
MacGatewayProfile(
id: "studio",
name: "Studio",
url: URL(string: "wss://studio.example:443/")!),
MacGatewayProfile(
id: "production",
name: "Production",
url: URL(string: "wss://gateway.example:443/")!),
])
.frame(width: 840, height: 620)
}
}
#endif