mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 20:01:39 +00:00
293 lines
11 KiB
Swift
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
|