diff --git a/Sources/Clavis/State/ApplicationSettingsModel.swift b/Sources/Clavis/State/ApplicationSettingsModel.swift new file mode 100644 index 0000000..27c93ce --- /dev/null +++ b/Sources/Clavis/State/ApplicationSettingsModel.swift @@ -0,0 +1,79 @@ +import AppKit +import ClavisCore + +@MainActor +final class ApplicationSettingsModel: ObservableObject { + @Published private(set) var snapshot: ApplicationRegistrySnapshot? + @Published private(set) var isBusy = false + @Published private(set) var message: String? + @Published var selection: UUID? + private let agent: AgentControlling + + init(agent: AgentControlling = AgentLifecycleManager.shared) { self.agent = agent } + var applications: [RegisteredApplication] { snapshot?.applications.filter { !$0.removed } ?? [] } + + func load() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + let agent = agent + snapshot = await Task.detached { agent.applicationRegistry() }.value + message = snapshot == nil ? "Could not load applications. Check that the Clavis daemon is running and up to date." : nil + if !applications.contains(where: { $0.id == selection }) { selection = nil } + } + + func classify(_ app: RegisteredApplication, as classification: ApplicationClassification) async { + guard app.classification != classification else { return } + var changed = app + changed.classification = classification + await perform(.set(changed)) + } + + func removeSelected() async { + guard let app = applications.first(where: { $0.id == selection }) else { return } + await perform(.remove(app.identity)) + } + + func add(url: URL) async { + guard !isBusy else { return } + do { + let executable = Bundle(url: url)?.executableURL ?? url + let identity = try ApplicationIdentityVerifier.identity(forExecutable: executable) + if let existing = applications.first(where: { $0.identity == identity }) { + selection = existing.id + return + } + let app = RegisteredApplication( + identity: identity, + name: url.deletingPathExtension().lastPathComponent, + path: url.path, classification: .bot + ) + await perform(.set(app)) + selection = applications.first(where: { $0.identity == identity })?.id + } catch { + message = "Could not identify the selected application or executable." + } + } + + private func perform(_ change: ApplicationPolicyChange) async { + guard !isBusy, snapshot != nil else { return } + isBusy = true + message = nil + defer { isBusy = false } + let agent = agent + let result = await Task.detached { + let applied = agent.changeApplicationPolicy(change) + return (applied, applied ? agent.applicationRegistry() : nil) + }.value + guard result.0 else { + message = "The change was not applied. The previous policy remains in effect." + return + } + guard let fresh = result.1 else { + message = "The daemon accepted the change, but its updated settings could not be loaded." + return + } + snapshot = fresh + if !applications.contains(where: { $0.id == selection }) { selection = nil } + } +} diff --git a/Sources/Clavis/Views/Sheets/SettingsNavigation.swift b/Sources/Clavis/Views/Sheets/SettingsNavigation.swift new file mode 100644 index 0000000..563ab3a --- /dev/null +++ b/Sources/Clavis/Views/Sheets/SettingsNavigation.swift @@ -0,0 +1,163 @@ +import SwiftUI +import AppKit +import ClavisCore + +private enum SettingsTab: String, CaseIterable { + case general = "General" + case bots = "Bots" + var symbol: String { self == .general ? "gearshape" : "desktopcomputer" } +} + +struct SettingsNavigation: View { + let general: General + @AppStorage("clavis.settings.tab") private var selectedTab = SettingsTab.general.rawValue + @StateObject private var applications: ApplicationSettingsModel + + init(general: General, agent: any AgentControlling) { + self.general = general + _applications = StateObject(wrappedValue: ApplicationSettingsModel(agent: agent)) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + general + .opacity(selectedTab == SettingsTab.general.rawValue ? 1 : 0) + .disabled(selectedTab != SettingsTab.general.rawValue) + .allowsHitTesting(selectedTab == SettingsTab.general.rawValue) + .accessibilityHidden(selectedTab != SettingsTab.general.rawValue) + ApplicationSettingsPane(model: applications) + .opacity(selectedTab == SettingsTab.bots.rawValue ? 1 : 0) + .disabled(selectedTab != SettingsTab.bots.rawValue) + .allowsHitTesting(selectedTab == SettingsTab.bots.rawValue) + .accessibilityHidden(selectedTab != SettingsTab.bots.rawValue) + } + HStack(spacing: 3) { + ForEach(SettingsTab.allCases, id: \.rawValue) { tab in + Button { + selectedTab = tab.rawValue + } label: { + VStack(spacing: 3) { + Image(systemName: tab.symbol).font(.system(size: 14)) + Text(tab.rawValue).font(.system(size: 10, weight: .medium)) + } + .foregroundStyle(selectedTab == tab.rawValue ? Color.accentColor : Color.secondary) + .frame(width: 66, height: 42) + .background { + if selectedTab == tab.rawValue { + Capsule().fill(.quaternary) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(tab.rawValue) settings") + .accessibilityAddTraits(selectedTab == tab.rawValue ? [.isSelected] : []) + } + } + .padding(4) + .liquidGlassCapsuleControl() + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + } + .onAppear { + if SettingsTab(rawValue: selectedTab) == nil { selectedTab = SettingsTab.general.rawValue } + } + } +} + +private struct ApplicationSettingsPane: View { + @ObservedObject var model: ApplicationSettingsModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Applications").font(.headline) + Text("Personal apps can use all keys. Bots can use Bot keys.") + .font(.callout).foregroundStyle(.secondary) + VStack(spacing: 0) { + if model.applications.isEmpty { + Text(model.snapshot == nil ? "Applications unavailable" : "No applications added") + .font(.callout).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 84) + } + if !model.applications.isEmpty { + List(selection: $model.selection) { + ForEach(model.applications) { app in + HStack(spacing: 10) { + Image(nsImage: NSWorkspace.shared.icon(forFile: app.path)) + .resizable().frame(width: 28, height: 28) + .accessibilityHidden(true) + Text(app.name).font(.system(size: 12, weight: .medium)) + .lineLimit(1).truncationMode(.middle).help(app.path) + Spacer(minLength: 8) + Picker("Access for \(app.name)", selection: Binding( + get: { app.classification }, + set: { value in Task { await model.classify(app, as: value) } } + )) { + ForEach(ApplicationClassification.allCases, id: \.self) { classification in + Text(classification.title).tag(classification) + } + } + .labelsHidden().frame(width: 110) + } + .padding(.horizontal, 14).padding(.vertical, 12) + .background(model.selection == app.id ? Color.accentColor.opacity(0.12) : .clear) + .contentShape(Rectangle()) + .tag(app.id) + .listRowInsets(EdgeInsets()) + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .frame(height: CGFloat(min(model.applications.count, 6)) * 54) + .onDeleteCommand { Task { await model.removeSelected() } } + } + HStack(spacing: 0) { + Button(action: chooseApplication) { Image(systemName: "plus").frame(width: 30, height: 28) } + .help("Add application or executable") + .accessibilityLabel("Add application or executable") + .disabled(model.snapshot == nil) + Divider().frame(height: 20) + Button { Task { await model.removeSelected() } } label: { + Image(systemName: "minus").frame(width: 30, height: 28) + } + .help("Remove selected application") + .accessibilityLabel("Remove selected application") + .disabled(model.selection == nil) + Spacer() + if model.isBusy { ProgressView().controlSize(.small).padding(.trailing, 10) } + } + .buttonStyle(.borderless) + .background(.quaternary.opacity(0.5)) + } + .disabled(model.isBusy) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary)) + Text("Changes require Touch ID or your macOS password. Removing an app blocks it and revokes its approvals.") + .font(.caption).foregroundStyle(.secondary) + if let message = model.message { + Text(message).font(.callout).foregroundStyle(.secondary) + Button("Reload") { Task { await model.load() } }.disabled(model.isBusy) + } + } + .padding(22) + } + .task { await model.load() } + } + + private func chooseApplication() { + let panel = NSOpenPanel() + panel.title = "Add application or executable" + panel.prompt = "Add" + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.treatsFilePackagesAsDirectories = false + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + Task { @MainActor in await model.add(url: url) } + } + } +} diff --git a/Sources/Clavis/Views/Sheets/SettingsSheet.swift b/Sources/Clavis/Views/Sheets/SettingsSheet.swift index 0bd4dee..19bdfac 100644 --- a/Sources/Clavis/Views/Sheets/SettingsSheet.swift +++ b/Sources/Clavis/Views/Sheets/SettingsSheet.swift @@ -14,6 +14,7 @@ public struct SettingsView: View { @EnvironmentObject var appState: AppState @ObservedObject var lock: PanelLockController private let modeChanger: PinModeChanger? + private let applicationAgent: any AgentControlling @State private var globalMaxLease: Int = 1440 @State private var lastSavedGlobalMaxLease: Int = 1440 @State private var globalPolicyError: String? = nil @@ -49,10 +50,12 @@ public struct SettingsView: View { public init( lock: PanelLockController? = nil, modeChanger: PinModeChanger? = nil, - isSettingsAuthenticated: Bool = false + isSettingsAuthenticated: Bool = false, + applicationAgent: any AgentControlling = AgentLifecycleManager.shared ) { let resolvedLock = lock ?? PanelLockController.shared self.lock = resolvedLock + self.applicationAgent = applicationAgent self.modeChanger = modeChanger ?? PinModeChanger.makeDefault() self._isSettingsAuthenticated = State( initialValue: isSettingsAuthenticated || !resolvedLock.isEffectivelyLocked @@ -122,6 +125,75 @@ public struct SettingsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { + SettingsNavigation(general: generalSettings, agent: applicationAgent) + + } + } + .background(Color(nsColor: .windowBackgroundColor)) + .topLiquidGlassBar { + settingsHeader + } + .frame(width: 580, height: 680) + .onExitCommand { + dismiss() + } + .onChange(of: lock.state) { _ in + isSettingsAuthenticated = !lock.isEffectivelyLocked + } + .alert(ClavisUIStrings.PinUnlock.forgotPIN, isPresented: $showingResetAlert) { + Button(ClavisUIStrings.PinUnlock.setPinTitle, role: .destructive) { + ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: user confirmed reset PIN dialog") + activeSheet = .resetPIN + } + Button(ClavisUIStrings.Common.cancel, role: .cancel) { + ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: user cancelled reset PIN dialog") + } + } message: { + Text(ClavisUIStrings.PinUnlock.resetWarning) + } + .sheet(item: $activeSheet) { sheet in + let title: String = { + switch sheet { + case .modeChange: return ClavisUIStrings.PinUnlock.setPinTitle + case .changePIN: return ClavisUIStrings.PinUnlock.changePIN + case .resetPIN: return ClavisUIStrings.PinUnlock.forgotPIN + } + }() + let subtitle: String? = { + if case .modeChange(let m) = sheet { return modeTitle(for: m) } + return nil + }() + let warning: String? = { + switch sheet { + case .modeChange(let m): return PinModeUI.showsWeakPINWarning(m) ? ClavisUIStrings.PinUnlock.warningOrMode : nil + case .changePIN: return nil + case .resetPIN: return ClavisUIStrings.PinUnlock.resetWarning + } + }() + PinModeChangeSheet( + title: title, + subtitle: subtitle, + warning: warning, + onSave: { pin, confirmPin in + switch sheet { + case .modeChange(let targetMode): + _ = try await modeChanger?.changeMode(to: targetMode, newPIN: pin, confirmPIN: confirmPin, oldContext: lock.unlockContext, skipPasswordAuth: true) + case .changePIN: + _ = try await modeChanger?.changeMode(to: lock.currentMode, newPIN: pin, confirmPIN: confirmPin, oldContext: lock.unlockContext, skipPasswordAuth: true) + case .resetPIN: + try await modeChanger?.resetPIN(newPIN: pin, confirmPIN: confirmPin, skipPasswordAuth: true) + } + await MainActor.run { lock.objectWillChange.send() } + }, + onDismiss: { activeSheet = nil } + ) + .dismissOnOutsideClick { + activeSheet = nil + } + } + } + + private var generalSettings: some View { Form { // Control Panel Lock Section SettingsGroup(title: ClavisUIStrings.PanelLock.settingsSection) { @@ -150,7 +222,7 @@ public struct SettingsView: View { SettingsRow( icon: "timer", iconColor: Color(red: 0.35, green: 0.78, blue: 0.98), - title: "Grace period", + title: "Unlocked Timeout", subtitle: Self.inactivityGraceNote(minutes: lock.idleMinutes) ) { Picker("", selection: Binding( @@ -168,49 +240,14 @@ public struct SettingsView: View { } } - // Unlock Method Section - SettingsGroup(title: ClavisUIStrings.PinUnlock.settingsSection) { - VStack(alignment: .leading, spacing: 10) { - ForEach([AuditReadMode.passwordOrBiometry, .biometryOrPIN, .biometryAndPIN], id: \.self) { mode in - let isSelected = lock.currentMode == mode - let isAvailable = !mode.requiresPIN || PlatformSupport.hasSecureEnclave - Button(action: { - guard !isSelected && isAvailable else { return } - selectMode(mode) - }) { - HStack(spacing: 8) { - Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") - .foregroundColor(isSelected ? .accentColor : .secondary) - Text(modeTitle(for: mode)) - .font(.system(size: 13)) - .foregroundColor(isAvailable ? .primary : .secondary) - Spacer() - } - } - .buttonStyle(.plain) - .focusable(false) - .disabled(!isAvailable) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } - + SettingsGroup(title: "Unlock Method") { + SettingsRow(icon: "touchid", iconColor: DesignTokens.accentBlue, + title: lock.currentMode.requiresPIN ? modeTitle(for: lock.currentMode) : "Touch ID or macOS Password", + subtitle: lock.currentMode.requiresPIN + ? "Your existing unlock method remains active until you change it." + : "Use Touch ID with your macOS login password as a fallback.") { if lock.currentMode.requiresPIN { - Divider() - - HStack(spacing: 12) { - Button(ClavisUIStrings.PinUnlock.changePIN) { - activeSheet = .changePIN - } - .liquidGlassButton() - .controlSize(.small) - - Button(ClavisUIStrings.PinUnlock.forgotPIN) { - ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: displaying forgot PIN reset confirmation alert") - showingResetAlert = true - } - .liquidGlassButton() - .controlSize(.small) - } - .padding(.top, 4) + Button("Use This Method…") { selectMode(.passwordOrBiometry) } } } } @@ -232,93 +269,6 @@ public struct SettingsView: View { } } - // SSH Integration Section - SettingsGroup(title: ClavisUIStrings.Settings.sshSection) { - VStack(alignment: .leading, spacing: 10) { - SettingsRow( - icon: "terminal.fill", - iconColor: Color(red: 0.55, green: 0.58, blue: 0.62), - title: ClavisUIStrings.Settings.agentSocketTitle, - subtitle: SSHAgentServer.defaultSocketPath - ) { - EmptyView() - } - - Divider() - - VStack(alignment: .leading, spacing: 6) { - Text(ClavisUIStrings.Settings.envVarTitle) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - Text(ClavisUIStrings.Settings.envVarCommand) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.primary) - .lineLimit(1) - .textSelection(.enabled) - - Spacer() - - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(ClavisUIStrings.Settings.envVarCommand, forType: .string) - copiedEnv = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - copiedEnv = false - } - }) { - HStack(spacing: 4) { - Image(systemName: copiedEnv ? "checkmark" : "doc.on.doc") - Text(copiedEnv ? ClavisUIStrings.Common.copied : ClavisUIStrings.Common.copy) - } - .font(.system(size: 11, weight: .medium)) - } - .liquidGlassButton() - .controlSize(.small) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .liquidGlassControl(cornerRadius: 6) - } - .padding(.top, 2) - } - } - - // Agent Session Policy Section - SettingsGroup(title: ClavisUIStrings.AgentPolicy.sectionTitle) { - SettingsRow( - icon: "person.crop.circle.badge.clock", - iconColor: Color(red: 0.58, green: 0.35, blue: 0.88), - title: ClavisUIStrings.AgentPolicy.globalMaxLease, - subtitle: "" - ) { - Picker("", selection: $globalMaxLease) { - ForEach(globalMaxLeaseOptions, id: \.self) { minutes in - Text(ClavisUIStrings.PanelLock.minutesFormat(minutes)) - .tag(minutes) - } - } - .labelsHidden() - .onChange(of: globalMaxLease) { newValue in - guard newValue != lastSavedGlobalMaxLease else { return } - globalPolicyError = nil - Task { @MainActor in - do { - try appState.setGlobalPolicy(AgentGlobalPolicy(maxLeaseMinutes: newValue)) - lastSavedGlobalMaxLease = newValue - } catch { - globalMaxLease = lastSavedGlobalMaxLease - globalPolicyError = error.localizedDescription - } - } - } - } - if let err = globalPolicyError { - Text(err).font(.caption2).foregroundColor(.red) - } - } - // Vault Integrity & Repair Section (#95) SettingsGroup(title: ClavisUIStrings.Settings.vaultSection) { VStack(alignment: .leading, spacing: 10) { @@ -431,22 +381,6 @@ public struct SettingsView: View { Divider() - // Code Signing Identity - SettingsRow( - icon: "signature", - iconColor: DesignTokens.accentOrange, - title: ClavisUIStrings.Settings.codeSigningLabel, - subtitle: "" - ) { - Text(AboutProvenance.codeSigningIdentity) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - - Divider() - // Daemon Version Parity let parity = AboutProvenance.daemonParity( isSocketActive: appState.isSocketActive, @@ -487,75 +421,8 @@ public struct SettingsView: View { .formStyle(.grouped) .scrollContentBackground(.hidden) .onAppear { - if let global = appState.getGlobalPolicy() { - globalMaxLease = global.maxLeaseMinutes - lastSavedGlobalMaxLease = global.maxLeaseMinutes - } checkVaultSync() } - } - } - .topLiquidGlassBar { - settingsHeader - } - .frame(width: 480, height: 600) - .onExitCommand { - dismiss() - } - .onChange(of: lock.state) { _ in - isSettingsAuthenticated = !lock.isEffectivelyLocked - } - .alert(ClavisUIStrings.PinUnlock.forgotPIN, isPresented: $showingResetAlert) { - Button(ClavisUIStrings.PinUnlock.setPinTitle, role: .destructive) { - ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: user confirmed reset PIN dialog") - activeSheet = .resetPIN - } - Button(ClavisUIStrings.Common.cancel, role: .cancel) { - ClavisLogger.promptDebug("calvis-ui", "SettingsSheet: user cancelled reset PIN dialog") - } - } message: { - Text(ClavisUIStrings.PinUnlock.resetWarning) - } - .sheet(item: $activeSheet) { sheet in - let title: String = { - switch sheet { - case .modeChange: return ClavisUIStrings.PinUnlock.setPinTitle - case .changePIN: return ClavisUIStrings.PinUnlock.changePIN - case .resetPIN: return ClavisUIStrings.PinUnlock.forgotPIN - } - }() - let subtitle: String? = { - if case .modeChange(let m) = sheet { return modeTitle(for: m) } - return nil - }() - let warning: String? = { - switch sheet { - case .modeChange(let m): return PinModeUI.showsWeakPINWarning(m) ? ClavisUIStrings.PinUnlock.warningOrMode : nil - case .changePIN: return nil - case .resetPIN: return ClavisUIStrings.PinUnlock.resetWarning - } - }() - PinModeChangeSheet( - title: title, - subtitle: subtitle, - warning: warning, - onSave: { pin, confirmPin in - switch sheet { - case .modeChange(let targetMode): - _ = try await modeChanger?.changeMode(to: targetMode, newPIN: pin, confirmPIN: confirmPin, oldContext: lock.unlockContext, skipPasswordAuth: true) - case .changePIN: - _ = try await modeChanger?.changeMode(to: lock.currentMode, newPIN: pin, confirmPIN: confirmPin, oldContext: lock.unlockContext, skipPasswordAuth: true) - case .resetPIN: - try await modeChanger?.resetPIN(newPIN: pin, confirmPIN: confirmPin, skipPasswordAuth: true) - } - await MainActor.run { lock.objectWillChange.send() } - }, - onDismiss: { activeSheet = nil } - ) - .dismissOnOutsideClick { - activeSheet = nil - } - } } private func authenticateSettings() async { diff --git a/Sources/ClavisCore/Models/ApplicationRegistry.swift b/Sources/ClavisCore/Models/ApplicationRegistry.swift index ec86436..e0cd80e 100644 --- a/Sources/ClavisCore/Models/ApplicationRegistry.swift +++ b/Sources/ClavisCore/Models/ApplicationRegistry.swift @@ -157,8 +157,12 @@ public final class AuthenticatedApplicationRegistry { } private func commit(_ application: RegisteredApplication, replacing old: ApplicationRegistrySnapshot) throws { - var entries = old.applications.filter { $0.identity != application.identity } - entries.append(application) + var entries = old.applications + if let index = entries.firstIndex(where: { $0.identity == application.identity }) { + entries[index] = application + } else { + entries.append(application) + } let updated = ApplicationRegistrySnapshot(applications: entries, requestsPaused: old.isPaused) try updated.validate() let context = try authenticator.authenticate( diff --git a/Tests/ClavisAppTests/ApplicationSettingsModelTests.swift b/Tests/ClavisAppTests/ApplicationSettingsModelTests.swift new file mode 100644 index 0000000..9c93412 --- /dev/null +++ b/Tests/ClavisAppTests/ApplicationSettingsModelTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import Clavis +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite @MainActor struct ApplicationSettingsModelTests { + private func application() throws -> RegisteredApplication { + RegisteredApplication(identity: try .init(kind: .designatedRequirement, value: "test"), name: "App", path: "/App.app", classification: .bot) + } + + @Test func unavailableDaemonDoesNotInventAnEmptyRegistry() async { + let model = ApplicationSettingsModel(agent: MockAgentController()) + await model.load() + #expect(model.snapshot == nil) + #expect(model.message != nil) + #expect(!model.isBusy) + } + + @Test func personalClassificationStaysInTheSameList() async throws { + let agent = MockAgentController() + let app = try application() + agent.stubbedApplicationRegistry = .init(applications: [app], requestsPaused: true) + agent.applicationPolicyHandler = { change in + guard case .set(let changed) = change else { return false } + agent.stubbedApplicationRegistry = .init(applications: [changed], requestsPaused: true) + return true + } + let model = ApplicationSettingsModel(agent: agent) + await model.load() + model.selection = app.id + await model.classify(app, as: .personal) + #expect(model.applications.count == 1) + #expect(model.applications.first?.classification == .personal) + #expect(model.selection == app.id) + #expect(model.snapshot?.isPaused == true) + #expect(model.message == nil) + } + + @Test func cancelledOrFailedChangePreservesPreviousSelectionAndPolicy() async throws { + let agent = MockAgentController() + let app = try application() + let original = ApplicationRegistrySnapshot(applications: [app]) + agent.stubbedApplicationRegistry = original + let model = ApplicationSettingsModel(agent: agent) + await model.load() + model.selection = app.id + await model.classify(app, as: .personal) + #expect(model.snapshot == original) + #expect(model.selection == app.id) + #expect(model.message?.contains("not applied") == true) + } + + @Test func removalUsesConfirmedTombstoneAndClearsSelection() async throws { + let agent = MockAgentController() + let app = try application() + agent.stubbedApplicationRegistry = .init(applications: [app]) + agent.applicationPolicyHandler = { change in + guard case .remove(let identity) = change, identity == app.identity else { return false } + var removed = app + removed.classification = .block + removed.removed = true + agent.stubbedApplicationRegistry = .init(applications: [removed]) + return true + } + let model = ApplicationSettingsModel(agent: agent) + await model.load() + model.selection = app.id + await model.removeSelected() + #expect(model.applications.isEmpty) + #expect(model.selection == nil) + #expect(model.snapshot?.applications.first?.classification == .block) + } + + @Test func acceptedButUnrefreshableChangeReportsUncertainty() async throws { + let agent = MockAgentController() + let app = try application() + agent.stubbedApplicationRegistry = .init(applications: [app]) + agent.applicationPolicyHandler = { _ in agent.stubbedApplicationRegistry = nil; return true } + let model = ApplicationSettingsModel(agent: agent) + await model.load() + await model.classify(app, as: .block) + #expect(model.message?.contains("accepted the change") == true) + #expect(model.applications.first?.classification == .bot) + #expect(!model.isBusy) + } + + @Test func sameClassificationDoesNotRequestAuthenticationAgain() async throws { + let agent = MockAgentController() + let app = try application() + agent.stubbedApplicationRegistry = .init(applications: [app]) + let model = ApplicationSettingsModel(agent: agent) + await model.load() + await model.classify(app, as: .bot) + #expect(agent.applicationPolicyChanges.isEmpty) + } +} diff --git a/Tests/ClavisAppTests/SettingsRenderingTests.swift b/Tests/ClavisAppTests/SettingsRenderingTests.swift new file mode 100644 index 0000000..577eb9c --- /dev/null +++ b/Tests/ClavisAppTests/SettingsRenderingTests.swift @@ -0,0 +1,52 @@ +import AppKit +import SwiftUI +import Testing +@testable import Clavis +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite @MainActor struct SettingsRenderingTests { + @Test(arguments: ["General", "Bots"], [false, true]) + func modalRendersWithFloatingNavigation(tab: String, dark: Bool) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let suite = "clavis.settings.render.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(tab, forKey: "clavis.settings.tab") + let agent = MockAgentController() + agent.stubbedApplicationRegistry = ApplicationRegistrySnapshot(applications: try [ + "Claude Code", "Codex", "Antigravity", "OpenCode" + ].map { name in + RegisteredApplication(identity: try .init(kind: .designatedRequirement, value: name), name: name, path: "/Applications/\(name).app", classification: .bot) + }) + let lock = PanelLockController(defaults: defaults, authenticator: FakePanelAuthenticator(), keyring: nil, recorder: InMemoryAuditRecorder(), vaultKeyCount: { 1 }) + let manager = fixture.makeKeyManager() + let state = AppState(keyManager: manager, sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager), agentLifecycle: agent) + let root = SettingsView(lock: lock, isSettingsAuthenticated: true, applicationAgent: agent) + .environmentObject(state) + .defaultAppStorage(defaults) + .environment(\.colorScheme, dark ? .dark : .light) + let view = NSHostingView(rootView: root) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 580, height: 680), styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = view + window.orderFront(nil) + defer { window.orderOut(nil) } + try await Task.sleep(nanoseconds: 150_000_000) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(view.bounds.width == 580) + #expect(view.bounds.height == 680) + #expect(png.count > 5_000) + if ProcessInfo.processInfo.environment["CLAVIS_RENDER_REVIEW"] == "1" { + let directory = URL(fileURLWithPath: "/private/tmp/clavis-settings-review") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try png.write(to: directory.appendingPathComponent("\(tab.lowercased())-\(dark ? "dark" : "light").png")) + } + } + } +} diff --git a/Tests/ClavisCoreTests/ApplicationRegistryTests.swift b/Tests/ClavisCoreTests/ApplicationRegistryTests.swift index 347d92d..59ed484 100644 --- a/Tests/ClavisCoreTests/ApplicationRegistryTests.swift +++ b/Tests/ClavisCoreTests/ApplicationRegistryTests.swift @@ -50,6 +50,16 @@ import Testing #expect(revocations == 3) } + @Test func classificationUpdatesPreserveApplicationOrder() throws { + var first = try application() + let second = RegisteredApplication(identity: try .init(kind: .designatedRequirement, value: "second"), name: "Second", path: "/Second.app", classification: .bot) + let store = MemoryRegistry(.init(applications: [first, second])) + let registry = AuthenticatedApplicationRegistry(store: store, authenticator: AllowingAuthenticator(), invalidateApprovals: {}) + first.classification = .personal + try registry.set(first) + #expect(try store.load().applications.map(\.id) == [first.id, second.id]) + } + @Test func cancelledOrFailedAuthPreservesPolicyAndApprovals() throws { let original = ApplicationRegistrySnapshot(applications: [try application()]) let store = MemoryRegistry(original) diff --git a/Tests/ClavisTestSupport/MockAgentController.swift b/Tests/ClavisTestSupport/MockAgentController.swift index f996577..3c6f354 100644 --- a/Tests/ClavisTestSupport/MockAgentController.swift +++ b/Tests/ClavisTestSupport/MockAgentController.swift @@ -35,6 +35,23 @@ public final class MockAgentController: AgentControlling, @unchecked Sendable { public var recordedExtendCalls: [(id: String, minutes: Int)] = [] public var recordedSetPolicies: [(target: String, json: String)] = [] + public var stubbedApplicationRegistry: ApplicationRegistrySnapshot? + public var applicationPolicyHandler: ((ApplicationPolicyChange) -> Bool)? + public private(set) var applicationPolicyChanges: [ApplicationPolicyChange] = [] + + public func applicationRegistry() -> ApplicationRegistrySnapshot? { + lock.lock(); defer { lock.unlock() } + return stubbedApplicationRegistry + } + + public func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool { + lock.lock() + applicationPolicyChanges.append(change) + let handler = applicationPolicyHandler + lock.unlock() + return handler?(change) ?? false + } + public init( isRunning: Bool = false, pid: pid_t? = nil,