diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift index 7afbb4776..280ed5efa 100644 --- a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift +++ b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift @@ -119,35 +119,55 @@ enum PiOAuthStore { } static func hasAnthropicOAuth() -> Bool { - guard let dict = (try? self.loadStorage()) else { return false } - return dict[self.providerKey] != nil + let url = self.oauthURL() + guard FileManager.default.fileExists(atPath: url.path) else { return false } + + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data, options: []), + let storage = json as? [String: Any], + let entry = storage[self.providerKey] as? [String: Any] + else { + return false + } + + let refresh = entry["refresh"] as? String + let access = entry["access"] as? String + return (refresh?.isEmpty == false) && (access?.isEmpty == false) } static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { - var storage = (try? self.loadStorage()) ?? [:] - storage[self.providerKey] = creds - try self.saveStorage(storage) - } - - private static func loadStorage() throws -> [String: AnthropicOAuthCredentials] { let url = self.oauthURL() - guard FileManager.default.fileExists(atPath: url.path) else { return [:] } - let data = try Data(contentsOf: url) - return try JSONDecoder().decode([String: AnthropicOAuthCredentials].self, from: data) + let existing: [String: Any] + if FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data, options: []), + let dict = json as? [String: Any] + { + existing = dict + } else { + existing = [:] + } + + var updated = existing + updated[self.providerKey] = [ + "type": creds.type, + "refresh": creds.refresh, + "access": creds.access, + "expires": creds.expires, + ] + + try self.saveStorage(updated) } - private static func saveStorage(_ storage: [String: AnthropicOAuthCredentials]) throws { + private static func saveStorage(_ storage: [String: Any]) throws { let dir = self.oauthDir() try FileManager.default.createDirectory( at: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(storage) - let url = self.oauthURL() + let data = try JSONSerialization.data(withJSONObject: storage, options: [.prettyPrinted, .sortedKeys]) try data.write(to: url, options: [.atomic]) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 093949f0c..16f61ea41 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -57,6 +57,8 @@ struct OnboardingView: View { @State private var anthropicAuthStatus: String? @State private var anthropicAuthBusy = false @State private var anthropicAuthConnected = false + @State private var monitoringAuth = false + @State private var authMonitorTask: Task? @State private var identityName: String = "" @State private var identityTheme: String = "" @State private var identityEmoji: String = "" @@ -73,6 +75,7 @@ struct OnboardingView: View { private let pageWidth: CGFloat = 680 private let contentHeight: CGFloat = 520 private let connectionPageIndex = 1 + private let anthropicAuthPageIndex = 2 private let permissionsPageIndex = 5 private var pageOrder: [Int] { if self.state.connectionMode == .remote { @@ -149,6 +152,7 @@ struct OnboardingView: View { .onDisappear { self.stopPermissionMonitoring() self.stopDiscovery() + self.stopAuthMonitoring() } .task { await self.refreshPerms() @@ -324,6 +328,26 @@ struct OnboardingView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 12) { + Text(PiOAuthStore.oauthURL().path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()]) + } + .buttonStyle(.bordered) + + Button("Refresh") { + self.refreshAnthropicOAuthStatus() + } + .buttonStyle(.bordered) + } + Divider().padding(.vertical, 2) HStack(spacing: 12) { @@ -1070,6 +1094,7 @@ struct OnboardingView: View { private func updateMonitoring(for pageIndex: Int) { self.updatePermissionMonitoring(for: pageIndex) self.updateDiscoveryMonitoring(for: pageIndex) + self.updateAuthMonitoring(for: pageIndex) } private func stopPermissionMonitoring() { @@ -1084,6 +1109,33 @@ struct OnboardingView: View { self.masterDiscovery.stop() } + private func updateAuthMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local + if shouldMonitor, !self.monitoringAuth { + self.monitoringAuth = true + self.startAuthMonitoring() + } else if !shouldMonitor, self.monitoringAuth { + self.stopAuthMonitoring() + } + } + + private func startAuthMonitoring() { + self.refreshAnthropicOAuthStatus() + self.authMonitorTask?.cancel() + self.authMonitorTask = Task { + while !Task.isCancelled { + await MainActor.run { self.refreshAnthropicOAuthStatus() } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + private func stopAuthMonitoring() { + self.monitoringAuth = false + self.authMonitorTask?.cancel() + self.authMonitorTask = nil + } + private func installCLI() async { guard !self.installingCLI else { return } self.installingCLI = true