Move provider to a plugin-architecture (#661)
* refactor: introduce provider plugin registry * refactor: move provider CLI to plugins * docs: add provider plugin implementation notes * refactor: shift provider runtime logic into plugins * refactor: add plugin defaults and summaries * docs: update provider plugin notes * feat(commands): add /commands slash list * Auto-reply: tidy help message * Auto-reply: fix status command lint * Tests: align google shared expectations * Auto-reply: tidy help message * Auto-reply: fix status command lint * refactor: move provider routing into plugins * test: align agent routing expectations * docs: update provider plugin notes * refactor: route replies via provider plugins * docs: note route-reply plugin hooks * refactor: extend provider plugin contract * refactor: derive provider status from plugins * refactor: unify gateway provider control * refactor: use plugin metadata in auto-reply * fix: parenthesize cron target selection * refactor: derive gateway methods from plugins * refactor: generalize provider logout * refactor: route provider logout through plugins * refactor: move WhatsApp web login methods into plugin * refactor: generalize provider log prefixes * refactor: centralize default chat provider * refactor: derive provider lists from registry * refactor: move provider reload noops into plugins * refactor: resolve web login provider via alias * refactor: derive CLI provider options from plugins * refactor: derive prompt provider list from plugins * style: apply biome lint fixes * fix: resolve provider routing edge cases * docs: update provider plugin refactor notes * fix(gateway): harden agent provider routing * refactor: move provider routing into plugins * refactor: move provider CLI to plugins * refactor: derive provider lists from registry * fix: restore slash command parsing * refactor: align provider ids for schema * refactor: unify outbound target resolution * fix: keep outbound labels stable * feat: add msteams to cron surfaces * fix: clean up lint build issues * refactor: localize chat provider alias normalization * refactor: drive gateway provider lists from plugins * docs: update provider plugin notes * style: format message-provider * fix: avoid provider registry init cycles * style: sort message-provider imports * fix: relax provider alias map typing * refactor: move provider routing into plugins * refactor: add plugin pairing/config adapters * refactor: route pairing and provider removal via plugins * refactor: align auto-reply provider typing * test: stabilize telegram media mocks * docs: update provider plugin refactor notes * refactor: pluginize outbound targets * refactor: pluginize provider selection * refactor: generalize text chunk limits * docs: update provider plugin notes * refactor: generalize group session/config * fix: normalize provider id for room detection * fix: avoid provider init in system prompt * style: formatting cleanup * refactor: normalize agent delivery targets * test: update outbound delivery labels * chore: fix lint regressions * refactor: extend provider plugin adapters * refactor: move elevated/block streaming defaults to plugins * refactor: defer outbound send deps to plugins * docs: note plugin-driven streaming/elevated defaults * refactor: centralize webchat provider constant * refactor: add provider setup adapters * refactor: delegate provider add config to plugins * docs: document plugin-driven provider add * refactor: add plugin state/binding metadata * refactor: build agent provider status from plugins * docs: note plugin-driven agent bindings * refactor: centralize internal provider constant usage * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize default chat provider * refactor: centralize WhatsApp target normalization * refactor: move provider routing into plugins * refactor: normalize agent delivery targets * chore: fix lint regressions * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * feat: expand provider plugin adapters * refactor: route auto-reply via provider plugins * fix: align WhatsApp target normalization * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize WhatsApp target normalization * feat: add /config chat config updates * docs: add /config get alias * feat(commands): add /commands slash list * refactor: centralize default chat provider * style: apply biome lint fixes * chore: fix lint regressions * fix: clean up whatsapp allowlist typing * style: format config command helpers * refactor: pluginize tool threading context * refactor: normalize session announce targets * docs: note new plugin threading and announce hooks * refactor: pluginize message actions * docs: update provider plugin actions notes * fix: align provider action adapters * refactor: centralize webchat checks * style: format message provider helpers * refactor: move provider onboarding into adapters * docs: note onboarding provider adapters * feat: add msteams onboarding adapter * style: organize onboarding imports * fix: normalize msteams allowFrom types * feat: add plugin text chunk limits * refactor: use plugin chunk limit fallbacks * feat: add provider mention stripping hooks * style: organize provider plugin type imports * refactor: generalize health snapshots * refactor: update macOS health snapshot handling * docs: refresh health snapshot notes * style: format health snapshot updates * refactor: drive security warnings via plugins * docs: note provider security adapter * style: format provider security adapters * refactor: centralize provider account defaults * refactor: type gateway client identity constants * chore: regen gateway protocol swift * fix: degrade health on failed provider probe * refactor: centralize pairing approve hint * docs: add plugin CLI command references * refactor: route auth and tool sends through plugins * docs: expand provider plugin hooks * refactor: document provider docking touchpoints * refactor: normalize internal provider defaults * refactor: streamline outbound delivery wiring * refactor: make provider onboarding plugin-owned * refactor: support provider-owned agent tools * refactor: move telegram draft chunking into telegram module * refactor: infer provider tool sends via extractToolSend * fix: repair plugin onboarding imports * refactor: de-dup outbound target normalization * style: tidy plugin and agent imports * refactor: data-drive provider selection line * fix: satisfy lint after provider plugin rebase * test: deflake gateway-cli coverage * style: format gateway-cli coverage test * refactor(provider-plugins): simplify provider ids * test(pairing-cli): avoid provider-specific ternary * style(macos): swiftformat HealthStore * refactor(sandbox): derive provider tool denylist * fix(sandbox): avoid plugin init in defaults * refactor(provider-plugins): centralize provider aliases * style(test): satisfy biome * refactor(protocol): v3 providers.status maps * refactor(ui): adapt to protocol v3 * refactor(macos): adapt to protocol v3 * test: update providers.status v3 fixtures * refactor(gateway): map provider runtime snapshot * test(gateway): update reload runtime snapshot * refactor(whatsapp): normalize heartbeat provider id * docs(refactor): update provider plugin notes * style: satisfy biome after rebase * fix: describe sandboxed elevated in prompt * feat(gateway): add agent image attachments + live probe * refactor: derive CLI provider options from plugins * fix(gateway): harden agent provider routing * fix(gateway): harden agent provider routing * refactor: align provider ids for schema * fix(protocol): keep agent provider string * fix(gateway): harden agent provider routing * fix(protocol): keep agent provider string * refactor: normalize agent delivery targets * refactor: support provider-owned agent tools * refactor(config): provider-keyed elevated allowFrom * style: satisfy biome * fix(gateway): appease provider narrowing * style: satisfy biome * refactor(reply): move group intro hints into plugin * fix(reply): avoid plugin registry init cycle * refactor(providers): add lightweight provider dock * refactor(gateway): use typed client id in connect * refactor(providers): document docks and avoid init cycles * refactor(providers): make media limit helper generic * fix(providers): break plugin registry import cycles * style: satisfy biome * refactor(status-all): build providers table from plugins * refactor(gateway): delegate web login to provider plugin * refactor(provider): drop web alias * refactor(provider): lazy-load monitors * style: satisfy lint/format * style: format status-all providers table * style: swiftformat gateway discovery model * test: make reload plan plugin-driven * fix: avoid token stringification in status-all * refactor: make provider IDs explicit in status * feat: warn on signal/imessage provider runtime errors * test: cover gateway provider runtime warnings in status * fix: add runtime kind to provider status issues * test: cover health degradation on probe failure * fix: keep routeReply lightweight * style: organize routeReply imports * refactor(web): extract auth-store helpers * refactor(whatsapp): lazy login imports * refactor(outbound): route replies via plugin outbound * docs: update provider plugin notes * style: format provider status issues * fix: make sandbox scope warning wrap-safe * refactor: load outbound adapters from provider plugins * docs: update provider plugin outbound notes * style(macos): fix swiftformat lint * docs: changelog for provider plugins * fix(macos): satisfy swiftformat * fix(macos): open settings via menu action * style: format after rebase * fix(macos): open Settings via menu action --------- Co-authored-by: LK <luke@kyohere.com> Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com> Co-authored-by: Xin <xin@imfing.com>
This commit is contained in:
committed by
GitHub
parent
23eec7d841
commit
7acd26a2fc
@@ -61,8 +61,10 @@ final class CLIInstallPrompter {
|
||||
|
||||
private func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
NSApp.sendAction(#selector(NSApplication.showSettingsWindow), to: nil, from: nil)
|
||||
SettingsWindowOpener.shared.open()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appVersion() -> String? {
|
||||
|
||||
@@ -34,7 +34,7 @@ enum CLIInstaller {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
private func providerStatus<T: Decodable>(
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
{
|
||||
self.store.snapshot?.decodeProvider(id, as: type)
|
||||
}
|
||||
|
||||
var whatsAppTint: Color {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if !status.linked { return .red }
|
||||
if status.lastError != nil { return .orange }
|
||||
@@ -12,7 +20,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramTint: Color {
|
||||
guard let status = self.store.snapshot?.telegram else { return .secondary }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -21,7 +30,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var discordTint: Color {
|
||||
guard let status = self.store.snapshot?.discord else { return .secondary }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -30,7 +40,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var signalTint: Color {
|
||||
guard let status = self.store.snapshot?.signal else { return .secondary }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -39,7 +50,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var imessageTint: Color {
|
||||
guard let status = self.store.snapshot?.imessage else { return .secondary }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -48,7 +60,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var whatsAppSummary: String {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.linked { return "Not linked" }
|
||||
if status.connected { return "Connected" }
|
||||
if status.running { return "Running" }
|
||||
@@ -56,35 +69,40 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramSummary: String {
|
||||
guard let status = self.store.snapshot?.telegram else { return "Checking…" }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var discordSummary: String {
|
||||
guard let status = self.store.snapshot?.discord else { return "Checking…" }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var signalSummary: String {
|
||||
guard let status = self.store.snapshot?.signal else { return "Checking…" }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var imessageSummary: String {
|
||||
guard let status = self.store.snapshot?.imessage else { return "Checking…" }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var whatsAppDetails: String? {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
|
||||
lines.append("Linked as \(e164)")
|
||||
@@ -114,7 +132,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramDetails: String? {
|
||||
guard let status = self.store.snapshot?.telegram else { return nil }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
@@ -145,7 +164,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var discordDetails: String? {
|
||||
guard let status = self.store.snapshot?.discord else { return nil }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
@@ -173,7 +193,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var signalDetails: String? {
|
||||
guard let status = self.store.snapshot?.signal else { return nil }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
lines.append("Base URL: \(status.baseUrl)")
|
||||
if let probe = status.probe {
|
||||
@@ -199,7 +220,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var imessageDetails: String? {
|
||||
guard let status = self.store.snapshot?.imessage else { return nil }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let cliPath = status.cliPath, !cliPath.isEmpty {
|
||||
lines.append("CLI: \(cliPath)")
|
||||
@@ -221,11 +243,11 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.store.snapshot?.telegram.tokenSource == "env"
|
||||
self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.store.snapshot?.discord?.tokenSource == "env"
|
||||
self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var orderedProviders: [ConnectionProvider] {
|
||||
@@ -258,19 +280,24 @@ extension ConnectionsSettings {
|
||||
func providerEnabled(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.linked || status.running
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
}
|
||||
}
|
||||
@@ -344,35 +371,48 @@ extension ConnectionsSettings {
|
||||
func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||
case .telegram:
|
||||
return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .discord:
|
||||
return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .signal:
|
||||
return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
case .imessage:
|
||||
return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)?
|
||||
.lastProbeAt)
|
||||
}
|
||||
}
|
||||
|
||||
func providerHasError(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,12 @@ extension ConnectionsStore {
|
||||
self.whatsappBusy = true
|
||||
defer { self.whatsappBusy = false }
|
||||
do {
|
||||
let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .webLogout,
|
||||
params: nil,
|
||||
let params: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("whatsapp"),
|
||||
]
|
||||
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .providersLogout,
|
||||
params: params,
|
||||
timeoutMs: 15000)
|
||||
self.whatsappLoginMessage = result.cleared
|
||||
? "Logged out and cleared credentials."
|
||||
@@ -119,9 +122,12 @@ extension ConnectionsStore {
|
||||
self.telegramBusy = true
|
||||
defer { self.telegramBusy = false }
|
||||
do {
|
||||
let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .telegramLogout,
|
||||
params: nil,
|
||||
let params: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("telegram"),
|
||||
]
|
||||
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .providersLogout,
|
||||
params: params,
|
||||
timeoutMs: 15000)
|
||||
if result.envToken == true {
|
||||
self.configStatus = "Telegram token still set via env; config cleared."
|
||||
@@ -148,11 +154,9 @@ private struct WhatsAppLoginWaitResult: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct WhatsAppLogoutResult: Codable {
|
||||
let cleared: Bool
|
||||
}
|
||||
|
||||
private struct TelegramLogoutResult: Codable {
|
||||
private struct ProviderLogoutResult: Codable {
|
||||
let provider: String?
|
||||
let accountId: String?
|
||||
let cleared: Bool
|
||||
let envToken: Bool?
|
||||
}
|
||||
|
||||
@@ -121,12 +121,54 @@ struct ProvidersStatusSnapshot: Codable {
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct ProviderAccountSnapshot: Codable {
|
||||
let accountId: String
|
||||
let name: String?
|
||||
let enabled: Bool?
|
||||
let configured: Bool?
|
||||
let linked: Bool?
|
||||
let running: Bool?
|
||||
let connected: Bool?
|
||||
let reconnectAttempts: Int?
|
||||
let lastConnectedAt: Double?
|
||||
let lastError: String?
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastInboundAt: Double?
|
||||
let lastOutboundAt: Double?
|
||||
let lastProbeAt: Double?
|
||||
let mode: String?
|
||||
let dmPolicy: String?
|
||||
let allowFrom: [String]?
|
||||
let tokenSource: String?
|
||||
let botTokenSource: String?
|
||||
let appTokenSource: String?
|
||||
let baseUrl: String?
|
||||
let allowUnmentionedGroups: Bool?
|
||||
let cliPath: String?
|
||||
let dbPath: String?
|
||||
let port: Int?
|
||||
let probe: AnyCodable?
|
||||
let audit: AnyCodable?
|
||||
let application: AnyCodable?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let whatsapp: WhatsAppStatus
|
||||
let telegram: TelegramStatus
|
||||
let discord: DiscordStatus?
|
||||
let signal: SignalStatus?
|
||||
let imessage: IMessageStatus?
|
||||
let providerOrder: [String]
|
||||
let providerLabels: [String: String]
|
||||
let providers: [String: AnyCodable]
|
||||
let providerAccounts: [String: [ProviderAccountSnapshot]]
|
||||
let providerDefaultAccountId: [String: String]
|
||||
|
||||
func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
||||
guard let value = self.providers[id] else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSnapshot: Codable {
|
||||
|
||||
@@ -192,15 +192,17 @@ actor GatewayChannelActor {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientName = InstanceIdentity.displayName
|
||||
let clientDisplayName = InstanceIdentity.displayName
|
||||
let clientId = "clawdbot-macos"
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
"name": ProtoAnyCodable(clientName),
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(clientDisplayName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable("app"),
|
||||
"mode": ProtoAnyCodable("ui"),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||
|
||||
@@ -13,6 +13,7 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
case msteams
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
@@ -61,8 +62,7 @@ actor GatewayConnection {
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
case webLogout = "web.logout"
|
||||
case telegramLogout = "telegram.logout"
|
||||
case providersLogout = "providers.logout"
|
||||
case modelsList = "models.list"
|
||||
case chatHistory = "chat.history"
|
||||
case chatSend = "chat.send"
|
||||
|
||||
@@ -226,7 +226,10 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
|
||||
}
|
||||
return port
|
||||
}
|
||||
@@ -290,7 +293,10 @@ actor GatewayEndpointStore {
|
||||
let forwarded = try await ensure.task.value
|
||||
let stillRemote = await self.deps.mode() == .remote
|
||||
guard stillRemote else {
|
||||
throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
|
||||
@@ -319,5 +319,4 @@ enum GatewayEnvironment {
|
||||
else { return nil }
|
||||
return Semver.parse(version)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,6 +6,17 @@ enum GatewayLaunchAgentManager {
|
||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private enum GatewayProgramArgumentsError: LocalizedError {
|
||||
case cliNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cliNotFound:
|
||||
"clawdbot CLI not found in PATH; install the CLI."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
@@ -16,21 +27,27 @@ enum GatewayLaunchAgentManager {
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(port: Int, bind: String) -> Result<[String], String> {
|
||||
private static func gatewayProgramArguments(
|
||||
port: Int,
|
||||
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
||||
{
|
||||
#if DEBUG
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution()
|
||||
{
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
||||
switch CommandResolver.runtimeResolution() {
|
||||
case let .success(runtime):
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
@@ -38,19 +55,22 @@ enum GatewayLaunchAgentManager {
|
||||
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||
{
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
let fallbackProjectRoot = CommandResolver.projectRoot()
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: fallbackProjectRoot) {
|
||||
switch CommandResolver.runtimeResolution(searchPaths: searchPaths) {
|
||||
case let .success(runtime):
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return .failure("clawdbot CLI not found in PATH; install the CLI.")
|
||||
return .failure(.cliNotFound)
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
@@ -78,25 +98,26 @@ enum GatewayLaunchAgentManager {
|
||||
token: desiredToken,
|
||||
password: desiredPassword)
|
||||
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||
guard case let .success(programArguments) = programArgumentsResult else {
|
||||
if case let .failure(message) = programArgumentsResult {
|
||||
self.logger.error("launchd enable failed: \(message)")
|
||||
return message
|
||||
}
|
||||
return "Failed to resolve gateway command."
|
||||
let programArguments: [String]
|
||||
switch programArgumentsResult {
|
||||
case let .success(args):
|
||||
programArguments = args
|
||||
case let .failure(error):
|
||||
let message = error.errorDescription ?? "Failed to resolve gateway CLI"
|
||||
self.logger.error("launchd enable failed: \(message)")
|
||||
return message
|
||||
}
|
||||
|
||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||
let loaded = await self.isLoaded()
|
||||
if loaded,
|
||||
let existing = self.readPlistConfig(),
|
||||
existing.matches(desiredConfig)
|
||||
{
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
if loaded {
|
||||
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||
@@ -129,7 +150,6 @@ enum GatewayLaunchAgentManager {
|
||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String, port: Int) {
|
||||
private static func writePlist(programArguments: [String]) {
|
||||
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let token = self.preferredGatewayToken()
|
||||
|
||||
@@ -221,9 +221,21 @@ final class GatewayProcessManager {
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
return "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
let linkId = snap.providerOrder?.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.providers.keys.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"provider"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
|
||||
@@ -461,10 +461,8 @@ struct GeneralSettings: View {
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
await MainActor.run {
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +501,19 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
if let snap = snapshot {
|
||||
Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))")
|
||||
let linkId = snap.providerOrder?.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.providers.keys.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"Link provider"
|
||||
let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
|
||||
@@ -4,35 +4,29 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct HealthSnapshot: Codable, Sendable {
|
||||
struct Telegram: Codable, Sendable {
|
||||
struct ProviderSummary: Codable, Sendable {
|
||||
struct Probe: Codable, Sendable {
|
||||
struct Bot: Codable, Sendable {
|
||||
let id: Int?
|
||||
let username: String?
|
||||
}
|
||||
|
||||
let ok: Bool
|
||||
struct Webhook: Codable, Sendable {
|
||||
let url: String?
|
||||
}
|
||||
|
||||
let ok: Bool?
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let bot: Bot?
|
||||
let webhook: Webhook?
|
||||
}
|
||||
|
||||
let configured: Bool
|
||||
let probe: Probe?
|
||||
}
|
||||
|
||||
struct Web: Codable, Sendable {
|
||||
struct Connect: Codable, Sendable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
}
|
||||
|
||||
let linked: Bool
|
||||
let configured: Bool?
|
||||
let linked: Bool?
|
||||
let authAgeMs: Double?
|
||||
let connect: Connect?
|
||||
let probe: Probe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct SessionInfo: Codable, Sendable {
|
||||
@@ -50,8 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
|
||||
let ok: Bool?
|
||||
let ts: Double
|
||||
let durationMs: Double
|
||||
let web: Web
|
||||
let telegram: Telegram?
|
||||
let providers: [String: ProviderSummary]
|
||||
let providerOrder: [String]?
|
||||
let providerLabels: [String: String]?
|
||||
let heartbeatSeconds: Int?
|
||||
let sessions: Sessions
|
||||
}
|
||||
@@ -94,6 +89,13 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only escape hatch: the HealthStore is a process-wide singleton but
|
||||
// state derivation is pure from `snapshot` + `lastError`.
|
||||
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
|
||||
self.snapshot = snapshot
|
||||
self.lastError = lastError
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.loopTask == nil else { return }
|
||||
self.loopTask = Task { [weak self] in
|
||||
@@ -142,10 +144,49 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool {
|
||||
guard let tg = snap.telegram, tg.configured else { return false }
|
||||
private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool {
|
||||
guard summary.configured == true else { return false }
|
||||
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
||||
return tg.probe?.ok ?? true
|
||||
return summary.probe?.ok ?? true
|
||||
}
|
||||
|
||||
private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String {
|
||||
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
|
||||
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
|
||||
if let elapsed { return "Health check timed out (\(elapsed))" }
|
||||
return "Health check timed out"
|
||||
}
|
||||
let code = probe.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed"
|
||||
if let elapsed { return "\(reason) (\(code), \(elapsed))" }
|
||||
return "\(reason) (\(code))"
|
||||
}
|
||||
|
||||
private func resolveLinkProvider(
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
|
||||
{
|
||||
let order = snap.providerOrder ?? Array(snap.providers.keys)
|
||||
for id in order {
|
||||
if let summary = snap.providers[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveFallbackProvider(
|
||||
_ snap: HealthSnapshot,
|
||||
excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
|
||||
{
|
||||
let order = snap.providerOrder ?? Array(snap.providers.keys)
|
||||
for providerId in order {
|
||||
if providerId == id { continue }
|
||||
guard let summary = snap.providers[providerId] else { continue }
|
||||
if Self.isProviderHealthy(summary) {
|
||||
return (id: providerId, summary: summary)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var state: HealthState {
|
||||
@@ -153,13 +194,15 @@ final class HealthStore {
|
||||
return .degraded(error)
|
||||
}
|
||||
guard let snap = self.snapshot else { return .unknown }
|
||||
if !snap.web.linked {
|
||||
// WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red.
|
||||
return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded
|
||||
guard let link = self.resolveLinkProvider(snap) else { return .unknown }
|
||||
if link.summary.linked != true {
|
||||
// Linking is optional if any other provider is healthy; don't paint the whole app red.
|
||||
let fallback = self.resolveFallbackProvider(snap, excluding: link.id)
|
||||
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return .degraded(reason)
|
||||
// A provider can be "linked" but still unhealthy (failed probe / cannot connect).
|
||||
if let probe = link.summary.probe, probe.ok == false {
|
||||
return .degraded(Self.describeProbeFailure(probe))
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
@@ -168,19 +211,22 @@ final class HealthStore {
|
||||
if self.isRefreshing { return "Health check running…" }
|
||||
if let error = self.lastError { return "Health check failed: \(error)" }
|
||||
guard let snap = self.snapshot else { return "Health check pending" }
|
||||
if !snap.web.linked {
|
||||
if let tg = snap.telegram, tg.configured {
|
||||
let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded"
|
||||
return "\(tgLabel) · Not linked — run clawdbot login"
|
||||
guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" }
|
||||
if link.summary.linked != true {
|
||||
if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) {
|
||||
let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized
|
||||
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
|
||||
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
|
||||
}
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let code = connect.status.map(String.init) ?? "?"
|
||||
return "Link stale? status \(code)"
|
||||
let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let probe = link.summary.probe, probe.ok == false {
|
||||
let status = probe.status.map(String.init) ?? "?"
|
||||
let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)"
|
||||
return "linked · auth \(auth) · \(suffix)"
|
||||
}
|
||||
return "linked · auth \(auth) · socket ok"
|
||||
return "linked · auth \(auth)"
|
||||
}
|
||||
|
||||
/// Short, human-friendly detail for the last failure, used in the UI.
|
||||
@@ -201,17 +247,11 @@ final class HealthStore {
|
||||
}
|
||||
|
||||
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
||||
if !snap.web.linked {
|
||||
if let link = self.resolveLinkProvider(snap), link.summary.linked != true {
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
|
||||
if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
|
||||
return "Health check timed out (\(elapsed))"
|
||||
}
|
||||
let code = connect.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return "\(reason) (\(code), \(elapsed))"
|
||||
if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false {
|
||||
return Self.describeProbeFailure(probe)
|
||||
}
|
||||
if let fallback, !fallback.isEmpty {
|
||||
return fallback
|
||||
|
||||
@@ -242,6 +242,18 @@ final class InstancesStore {
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 8)
|
||||
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
||||
let linkId = snap.providerOrder?.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.providers.keys.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"provider"
|
||||
let entry = InstanceInfo(
|
||||
id: "health-\(snap.ts)",
|
||||
host: "gateway (health)",
|
||||
@@ -253,7 +265,7 @@ final class InstancesStore {
|
||||
lastInputSeconds: nil,
|
||||
mode: "health",
|
||||
reason: "health probe",
|
||||
text: "Health ok · linked=\(snap.web.linked)",
|
||||
text: "Health ok · \(linkLabel) linked=\(linked)",
|
||||
ts: snap.ts)
|
||||
if !self.instances.contains(where: { $0.id == entry.id }) {
|
||||
self.instances.insert(entry, at: 0)
|
||||
|
||||
@@ -153,6 +153,9 @@ struct MenuContent: View {
|
||||
self.micRefreshTask = nil
|
||||
self.micObserver.stop()
|
||||
}
|
||||
.task { @MainActor in
|
||||
SettingsWindowOpener.shared.register(openSettings: self.openSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionLabel: String {
|
||||
@@ -301,7 +304,9 @@ struct MenuContent: View {
|
||||
SettingsTabRouter.request(tab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -395,13 +395,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
return "Loading sessions…"
|
||||
"Loading sessions…"
|
||||
case .connecting:
|
||||
return "Connecting…"
|
||||
"Connecting…"
|
||||
case let .degraded(message):
|
||||
return message.nonEmpty ?? "Gateway disconnected"
|
||||
message.nonEmpty ?? "Gateway disconnected"
|
||||
case .disconnected:
|
||||
return "Gateway disconnected"
|
||||
"Gateway disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
@@ -41,7 +42,9 @@ extension OnboardingView {
|
||||
func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
func handleBack() {
|
||||
|
||||
@@ -95,7 +95,7 @@ extension OnboardingView {
|
||||
self.installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
await MainActor.run { self.cliStatus = message }
|
||||
self.cliStatus = message
|
||||
}
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
|
||||
@@ -345,7 +345,10 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
|
||||
// Legacy callback (still used on some macOS versions / configurations).
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
nonisolated func locationManager(
|
||||
_ manager: CLLocationManager,
|
||||
didChangeAuthorization status: CLAuthorizationStatus)
|
||||
{
|
||||
Task { @MainActor in
|
||||
self.finish(status: status)
|
||||
}
|
||||
|
||||
36
apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift
Normal file
36
apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@objc
|
||||
private protocol SettingsWindowMenuActions {
|
||||
@objc(showSettingsWindow:)
|
||||
optional func showSettingsWindow(_ sender: Any?)
|
||||
|
||||
@objc(showPreferencesWindow:)
|
||||
optional func showPreferencesWindow(_ sender: Any?)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SettingsWindowOpener {
|
||||
static let shared = SettingsWindowOpener()
|
||||
|
||||
private var openSettingsAction: OpenSettingsAction?
|
||||
|
||||
func register(openSettings: OpenSettingsAction) {
|
||||
self.openSettingsAction = openSettings
|
||||
}
|
||||
|
||||
func open() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let openSettingsAction {
|
||||
openSettingsAction()
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback path: mimic the built-in Settings menu item action.
|
||||
let didOpen = NSApp.sendAction(#selector(SettingsWindowMenuActions.showSettingsWindow(_:)), to: nil, from: nil)
|
||||
if !didOpen {
|
||||
_ = NSApp.sendAction(#selector(SettingsWindowMenuActions.showPreferencesWindow(_:)), to: nil, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,7 +602,7 @@ public final class GatewayDiscoveryModel {
|
||||
of: #"\s*-?\s*bridge$"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return normalizeHostToken(strippedBridge)
|
||||
return self.normalizeHostToken(strippedBridge)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
|
||||
import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 2
|
||||
public let GATEWAY_PROTOCOL_VERSION = 3
|
||||
|
||||
public enum ErrorCode: String, Codable, Sendable {
|
||||
case notLinked = "NOT_LINKED"
|
||||
@@ -1119,6 +1119,56 @@ public struct ProvidersStatusParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersStatusResult: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let providerorder: [String]
|
||||
public let providerlabels: [String: AnyCodable]
|
||||
public let providers: [String: AnyCodable]
|
||||
public let provideraccounts: [String: AnyCodable]
|
||||
public let providerdefaultaccountid: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ts: Int,
|
||||
providerorder: [String],
|
||||
providerlabels: [String: AnyCodable],
|
||||
providers: [String: AnyCodable],
|
||||
provideraccounts: [String: AnyCodable],
|
||||
providerdefaultaccountid: [String: AnyCodable]
|
||||
) {
|
||||
self.ts = ts
|
||||
self.providerorder = providerorder
|
||||
self.providerlabels = providerlabels
|
||||
self.providers = providers
|
||||
self.provideraccounts = provideraccounts
|
||||
self.providerdefaultaccountid = providerdefaultaccountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ts
|
||||
case providerorder = "providerOrder"
|
||||
case providerlabels = "providerLabels"
|
||||
case providers
|
||||
case provideraccounts = "providerAccounts"
|
||||
case providerdefaultaccountid = "providerDefaultAccountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersLogoutParams: Codable, Sendable {
|
||||
public let provider: String
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
provider: String,
|
||||
accountid: String?
|
||||
) {
|
||||
self.provider = provider
|
||||
self.accountid = accountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case provider
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginStartParams: Codable, Sendable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
Reference in New Issue
Block a user