diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/DockToolsRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/DockToolsRows.swift new file mode 100644 index 000000000..cb58f7aa7 --- /dev/null +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/DockToolsRows.swift @@ -0,0 +1,172 @@ +import AppKit +import EdithKit +import SwiftUI +import UniformTypeIdentifiers + +struct DockToolsRows: View { + @AppStorage(AppStorageKeys.DockTools.enabled, store: SharedDefaults.store) private var enabled = + false + @AppStorage(AppStorageKeys.DockTools.previewMode, store: SharedDefaults.store) private + var previewMode = DockPreviewMode.hover.rawValue + @AppStorage(AppStorageKeys.DockTools.hoverDelay, store: SharedDefaults.store) private + var hoverDelay = DockToolsPreferences.defaultHoverDelay + @AppStorage(AppStorageKeys.DockTools.clickAction, store: SharedDefaults.store) private + var clickAction = DockClickAction.standard.rawValue + @AppStorage(AppStorageKeys.DockTools.greenButtonMaximizes, store: SharedDefaults.store) private + var greenButtonMaximizes = false + @AppStorage(AppStorageKeys.DockTools.quitOnLastWindow, store: SharedDefaults.store) private + var quitOnLastWindow = false + @AppStorage(AppStorageKeys.DockTools.excludedApps, store: SharedDefaults.store) private + var excludedApps = "" + @State private var pickerError: String? + + var body: some View { + Section("Preview") { + Picker( + "Open previews", + selection: $previewMode.configured(AppStorageKeys.DockTools.previewMode) + ) { + ForEach(DockPreviewMode.allCases, id: \.self) { mode in + Text(mode.title).tag(mode.rawValue) + } + } + .pickerStyle(.segmented) + Text( + previewMode == DockPreviewMode.hover.rawValue + ? "Rest the pointer on a running app to see its windows." + : "Hold Option while clicking a running app to open its window preview." + ) + .settingsCaption() + if previewMode == DockPreviewMode.hover.rawValue { + VStack(alignment: .leading, spacing: UIScale.pt(6)) { + LabeledContent("Hover delay") { + Text(String(format: "%.2fs", hoverDelay)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: $hoverDelay.configured(AppStorageKeys.DockTools.hoverDelay), + in: DockToolsPreferences.hoverDelayRange) + } + } + Text( + "Screen Recording adds live thumbnails. Window titles remain available without it." + ) + .settingsCaption() + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + + Section("Dock behavior") { + Picker( + "Active app click", + selection: $clickAction.configured(AppStorageKeys.DockTools.clickAction) + ) { + ForEach(DockClickAction.allCases, id: \.self) { action in + Text(action.title).tag(action.rawValue) + } + } + Text("Only overrides a click when that app is already frontmost.") + .settingsCaption() + Toggle( + "Green button maximizes without full screen", + isOn: $greenButtonMaximizes.configured( + AppStorageKeys.DockTools.greenButtonMaximizes)) + Toggle( + "Quit when the last window closes", + isOn: $quitOnLastWindow.configured(AppStorageKeys.DockTools.quitOnLastWindow)) + Text("Minimized windows and windows on another Space keep the app running.") + .settingsCaption() + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + + Section("Excluded apps") { + if identifiers.isEmpty { + Text("No excluded apps") + .foregroundStyle(.secondary) + } else { + ForEach(identifiers, id: \.self) { identifier in + HStack(spacing: UIScale.pt(10)) { + appIcon(identifier) + .frame(width: UIScale.pt(24), height: UIScale.pt(24)) + VStack(alignment: .leading, spacing: 1) { + Text(appName(identifier)) + Text(identifier) + .settingsCaption() + .textSelection(.enabled) + } + Spacer() + Button { + remove(identifier) + } label: { + Image(systemName: "minus.circle.fill") + } + .buttonStyle(.edith(.iconOnly)) + .foregroundStyle(.secondary) + .accessibilityLabel("Remove \(appName(identifier)) from exclusions") + } + } + } + Button("Add app...") { chooseApplication() } + if let pickerError { + Text(pickerError) + .foregroundStyle(.red) + .settingsCaption() + } + Text("Excluded apps keep standard Dock, green button, and close behavior.") + .settingsCaption() + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + } + + private var identifiers: [String] { + DockToolsPreferences.identifiers(excludedApps).sorted() + } + + private func chooseApplication() { + let panel = NSOpenPanel() + panel.title = "Exclude an app" + panel.prompt = "Exclude" + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.begin { response in + guard response == .OK else { return } + let additions = panel.urls.compactMap { Bundle(url: $0)?.bundleIdentifier } + guard additions.count == panel.urls.count else { + pickerError = "One selected app has no bundle identifier." + return + } + pickerError = nil + let updated = DockToolsPreferences.identifiers(excludedApps).union(additions) + excludedApps = DockToolsPreferences.encodedIdentifiers(updated) + IPC.post(IPC.Name.settingsChanged) + } + } + + private func remove(_ identifier: String) { + let updated = DockToolsPreferences.identifiers(excludedApps).subtracting([identifier]) + excludedApps = DockToolsPreferences.encodedIdentifiers(updated) + IPC.post(IPC.Name.settingsChanged) + } + + private func appName(_ identifier: String) -> String { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier) + .flatMap { + Bundle(url: $0)?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + } + ?? NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier)? + .deletingPathExtension().lastPathComponent + ?? identifier + } + + private func appIcon(_ identifier: String) -> some View { + let image = + NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier) + .map { NSWorkspace.shared.icon(forFile: $0.path) } + ?? NSImage(systemSymbolName: "app", accessibilityDescription: nil)! + return Image(nsImage: image).resizable() + } +} diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift index bc4877773..d9f944a2f 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift @@ -1065,6 +1065,7 @@ private struct ExtensionDetailRows: View { case .clipboard: ClipboardRows() case .keystrokeHighlight: KeystrokeHighlightRows() case .focusDim: FocusDimRows() + case .dockTools: DockToolsRows() case .presenter: PresenterRows() case .colorPicker: ColorPickerRows() case .emoji: EmojiRows() diff --git a/Packages/Edith/Sources/EdithCLI/CommandTree.swift b/Packages/Edith/Sources/EdithCLI/CommandTree.swift index 24a69be77..fcf743f65 100644 --- a/Packages/Edith/Sources/EdithCLI/CommandTree.swift +++ b/Packages/Edith/Sources/EdithCLI/CommandTree.swift @@ -255,6 +255,9 @@ public enum CommandTree { "ed usage machines disable": Spec(options: ["--json"], arguments: [.machine]), "ed usage machines forget": Spec(options: ["--json"], arguments: [.machine]), "ed usage refresh": Spec(options: ["--json", "--follow", "--machines", "--no-machines"]), + "ed dock status": Spec(options: common), + "ed dock windows": Spec(options: common, arguments: [.runningApp]), + "ed dock show": Spec(options: common, arguments: [.runningApp]), "ed system stats": Spec(options: ["--json", "-f", "--follow", "--interval", "--processes"]), "ed system disks": Spec(options: ["--json", "-h", "--help", "--version"]), "ed music": Spec( diff --git a/Packages/Edith/Sources/EdithCLI/Commands/DockToolsCommands.swift b/Packages/Edith/Sources/EdithCLI/Commands/DockToolsCommands.swift new file mode 100644 index 000000000..46675c660 --- /dev/null +++ b/Packages/Edith/Sources/EdithCLI/Commands/DockToolsCommands.swift @@ -0,0 +1,176 @@ +import ArgumentParser +import EdithKit +import Foundation + +struct DockCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dock", abstract: "Inspect and open Dock Tools previews.", + subcommands: [DockStatusCommand.self, DockWindowsCommand.self, DockShowCommand.self], + defaultSubcommand: DockStatusCommand.self) +} + +enum DockCLI { + static func request( + operation: String, bundleIdentifier: String? = nil + ) async throws -> [AnyHashable: Any] { + try AppBridge.requireHelper("Dock Tools") + let requestID = UUID().uuidString + var payload: [String: Any] = [ + DockToolsIPC.requestIDKey: requestID, + DockToolsIPC.operationKey: operation, + ] + if let bundleIdentifier { + payload[DockToolsIPC.bundleIdentifierKey] = bundleIdentifier + } + let requestPayload = payload + guard + let reply = await AppBridge.awaitReply( + IPC.Name.dockToolsOperationResult, timeout: 3, + matching: { $0[DockToolsIPC.requestIDKey] as? String == requestID }, + trigger: { + AppBridge.post(IPC.Name.requestDockToolsOperation, userInfo: requestPayload) + }) + else { + throw AppBridge.silence( + "Dock Tools", extensionKey: AppStorageKeys.DockTools.enabled) + } + switch reply[DockToolsIPC.statusKey] as? String { + case "ok": return reply + case "notAuthorized": + throw CLIFailure.unavailable( + "Dock Tools needs Accessibility permission", + hint: "run `ed permissions request accessibility`") + case "notFound": + throw CLIFailure.notFound( + "no running application matches that bundle identifier") + case "excluded": + throw CLIFailure.unavailable( + "that application is excluded from Dock Tools", + hint: "remove it from Dock Tools exclusions in Settings") + case "extensionOff": + throw CLIFailure.unavailable( + "the Dock Tools extension is off", + hint: "run `ed extensions enable dockTools`") + default: + throw CLIFailure("Dock Tools rejected the request") + } + } + + static func status() async throws -> DockToolsStatus { + if AppBridge.helperIsRunning { + let reply = try await request(operation: "status") + if let payload = reply[DockToolsIPC.payloadKey] as? String, + let value = DockToolsIPC.decode(DockToolsStatus.self, from: payload) + { + return value + } + } + let preferences = DockToolsPreferences(defaults: CLIEnvironment.sharedDefaults) + let permissions = PermissionOperationCenter( + environment: .status(defaults: CLIEnvironment.sharedDefaults) + ).grantedPermissions() + return DockToolsStatus( + preferences: preferences, helperRunning: false, + accessibilityGranted: permissions[.accessibility] == true, + screenRecordingGranted: permissions[.screenRecording] == true) + } + + static func json(_ status: DockToolsStatus) -> JSONValue { + .object([ + "enabled": .bool(status.enabled), + "ready": .bool(status.ready), + "helperRunning": .bool(status.helperRunning), + "accessibilityGranted": .bool(status.accessibilityGranted), + "screenRecordingGranted": .bool(status.screenRecordingGranted), + "previewsAvailable": .bool(status.previewsAvailable), + "previewMode": .string(status.previewMode.rawValue), + "clickAction": .string(status.clickAction.rawValue), + "greenButtonMaximizes": .bool(status.greenButtonMaximizes), + "quitOnLastWindow": .bool(status.quitOnLastWindow), + "excludedApps": .array(status.excludedApps.map(JSONValue.string)), + ]) + } + + static func print(_ status: DockToolsStatus) { + CLIOut.out("state: \(status.ready ? "ready" : status.enabled ? "needs setup" : "disabled")") + CLIOut.out("helper: \(status.helperRunning ? "running" : "not running")") + CLIOut.out("accessibility: \(status.accessibilityGranted ? "granted" : "required")") + CLIOut.out( + "screen recording: \(status.screenRecordingGranted ? "granted" : "optional")") + CLIOut.out("previews: \(status.previewMode.title)") + CLIOut.out("active app click: \(status.clickAction.title)") + CLIOut.out("excluded apps: \(status.excludedApps.count)") + } +} + +struct DockStatusCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", abstract: "Show Dock Tools readiness and behavior.") + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + try await execute { + let status = try await DockCLI.status() + json ? CLIOut.json(DockCLI.json(status)) : DockCLI.print(status) + } + } +} + +struct DockWindowsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "windows", abstract: "List windows for a running Dock app.") + + @Argument(help: "Bundle identifier, or the frontmost app when omitted.") + var bundleIdentifier: String? + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + try await execute { + let reply = try await DockCLI.request( + operation: "windows", bundleIdentifier: bundleIdentifier) + let payload = reply[DockToolsIPC.payloadKey] as? String ?? "[]" + let windows = DockToolsIPC.decode([DockToolsWindow].self, from: payload) ?? [] + if json { + CLIOut.out(payload) + return + } + guard !windows.isEmpty else { + CLIOut.out("no windows") + return + } + for window in windows { + CLIOut.out("\(window.minimized ? "minimized" : "open")\t\(window.displayTitle)") + } + } + } +} + +struct DockShowCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "show", abstract: "Open a Dock Tools preview for a running app.") + + @Argument(help: "Bundle identifier, or the frontmost app when omitted.") + var bundleIdentifier: String? + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + try await execute { + _ = try await DockCLI.request(operation: "show", bundleIdentifier: bundleIdentifier) + if json { + CLIOut.json( + .object([ + "shown": .bool(true), + "bundleIdentifier": .optional(bundleIdentifier), + ])) + } else { + CLIOut.out("dock preview shown") + } + } + } +} diff --git a/Packages/Edith/Sources/EdithCLI/Commands/Root.swift b/Packages/Edith/Sources/EdithCLI/Commands/Root.swift index 25cb942d7..12b98314e 100644 --- a/Packages/Edith/Sources/EdithCLI/Commands/Root.swift +++ b/Packages/Edith/Sources/EdithCLI/Commands/Root.swift @@ -53,6 +53,7 @@ public struct EdRoot: AsyncParsableCommand { MCPCommand.self, ExtensionsCommand.self, LidAwakeCLICommand.self, + DockCommand.self, PermissionsCommand.self, UsageCommand.self, SystemCommand.self, @@ -536,9 +537,13 @@ struct CompleteCommand: AsyncParsableCommand { || request.leading.starts(with: ["usage", "projects", "open"]) || request.leading.starts(with: ["usage", "projects", "copy-link"]) ? UsageAnalysis.projectSelectors(usageDocument?.daily ?? []) : [] - let runningApps = - request.leading.first == "apps" - ? RunningAppOperationCenter().completionValues() : [] + let runningApps: [String] + switch request.leading.first { + case "apps": runningApps = RunningAppOperationCenter().completionValues() + case "dock": + runningApps = Array(Set(CLIEnvironment.runningApps().compactMap(\.bundleID))).sorted() + default: runningApps = [] + } let appLinks = request.leading.first == "app" ? AppInspectionCLI.center.links( diff --git a/Packages/Edith/Sources/EdithCLI/Guide.swift b/Packages/Edith/Sources/EdithCLI/Guide.swift index 1eeda5995..0405ac481 100644 --- a/Packages/Edith/Sources/EdithCLI/Guide.swift +++ b/Packages/Edith/Sources/EdithCLI/Guide.swift @@ -38,6 +38,7 @@ public enum Guide { ed database capabilities detected support for one connection id ed database mcp read-only database tools over MCP stdio ed lid-awake status closed-lid state, session, battery and helper + ed dock status Dock Tools readiness and behavior ed permissions ls every macOS permission Edith uses ed color pick open Edith's system colour sampler ed color copy 1 --format hex @@ -112,6 +113,11 @@ public enum Guide { ed lid-awake battery 20 ed lid-awake status --json ed lid-awake off + + DOCK TOOLS + ed dock status --json + ed dock windows [bundle-id] --json + ed dock show [bundle-id] ``` ## Databases diff --git a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift index 31959a06b..ed3444462 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift @@ -865,6 +865,43 @@ public enum ExtensionLifecycleCatalog { "Confirm the effect is enabled and configured.", "ed config ls --group focusdim --json") ]), + descriptor( + "dockTools", "Preview and move through an app's windows from the Dock.", + workflows: [ + instruction( + "preview", "Preview windows", + "Hover or Option-click a running app in the Dock."), + instruction( + "control", "Tune Dock behavior", + "Cycle or minimize the active app and opt into window-close policies."), + ], + prerequisites: [ + instruction( + "accessibility", "Grant Accessibility", + "Accessibility identifies Dock items and controls app windows.", + "ed permissions request accessibility"), + instruction( + "screen", "Grant Screen Recording", + "Screen Recording adds live thumbnails to preview cards.", + "ed permissions request screenRecording"), + ], + examples: [ + "ed extensions enable dockTools", "ed dock status --json", + "ed config ls --group docktools --json", + ], + docs: [documentation("guide", "Dock Tools guide", "docs/cli/dock/README.md")], + recovery: [ + instruction( + "permissions", "Refresh permissions", + "Refresh Edith after changing macOS Privacy & Security settings.", + "ed permissions refresh") + ], + verification: [ + instruction( + "status", "Inspect Dock Tools", + "Confirm the helper and required permission are ready.", + "ed dock status --json") + ]), descriptor( "presenter", "Hide sensitive numbers automatically while sharing or recording your screen.", diff --git a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift index 68692cdb6..fd62199a9 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift @@ -220,6 +220,12 @@ public enum ExtensionRegistry { subtitle: "Dims everything behind your active app.", symbolName: "circle.lefthalf.filled", suite: .desk, host: .bar, featured: false, defaultsKey: "focusDimEnabled", requiredCapabilities: [.windowDimming]), + ExtensionRegistryEntry( + id: "dockTools", title: "Dock Tools", + subtitle: "Window previews, faster switching, and smarter Dock behavior.", + symbolName: "dock.rectangle", suite: .desk, host: .bar, featured: false, + defaultsKey: "dockToolsEnabled", requiredCapabilities: [.dockControl], + optionalCapabilities: [.windowPreviews]), ExtensionRegistryEntry( id: "presenter", title: "Presenter", subtitle: "Blurs sensitive numbers while sharing your screen.", diff --git a/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift b/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift index 687a45727..4d28edae4 100644 --- a/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift +++ b/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift @@ -7,6 +7,7 @@ public enum PlatformCapability: String, CaseIterable, Codable, Hashable, Sendabl case cameraPreview case clipboardHistory case companionService + case dockControl case databaseBroker case diskCleaning case emojiInsertion @@ -34,6 +35,7 @@ public enum PlatformCapability: String, CaseIterable, Codable, Hashable, Sendabl case systemMetrics case usageCollection case windowDimming + case windowPreviews } public enum PlatformCapabilityState: Equatable, Sendable { @@ -97,6 +99,7 @@ public struct PlatformCapabilities: Equatable, Sendable { .bluetoothMonitoring: .permissionRequired, .calendarEvents: .permissionRequired, .cameraPreview: .permissionRequired, + .dockControl: .permissionRequired, .emojiInsertion: .permissionRequired, .globalPaste: .permissionRequired, .inputSuppression: .permissionRequired, @@ -105,6 +108,7 @@ public struct PlatformCapabilities: Equatable, Sendable { .screenColorSampling: .permissionRequired, .screenShareDetection: .permissionRequired, .windowDimming: .permissionRequired, + .windowPreviews: .permissionRequired, ])) } diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift index ce41f7e64..f842e22d7 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift @@ -15,6 +15,7 @@ final class AppServices { private(set) var emoji: EmojiStore? private(set) var keystrokeHighlight: KeystrokeHighlightRuntime? private(set) var focusDim: FocusDimEngine? + private(set) var dockTools: DockToolsEngine? private(set) var presenter: PresenterDetector? private(set) var micMute: MicMuteEngine? private(set) var lidAwake: LidAwakeEngine? @@ -109,6 +110,7 @@ final class AppServices { await attentionStopTask?.value await PermissionsModel.shared.waitForShutdown() shutDownEmojiRuntime() + dockTools?.shutdown() keystrokeHighlight?.shutdown() if #available(macOS 14.4, *) { MixerEngine.shared.shutdown() } await lidAwake?.shutdownForTermination() @@ -232,6 +234,15 @@ final class AppServices { } func reconcileSystemServices() { + let dockToolsOn = + ExtensionRegistry.entry("dockTools")?.isEnabled(in: SharedDefaults.store) ?? false + if dockToolsOn, dockTools == nil { dockTools = DockToolsEngine() } + if !dockToolsOn, let engine = dockTools { + engine.shutdown() + dockTools = nil + } + dockTools?.syncSettings() + let systemOn = Self.extensionEnabled(AppStorageKeys.Tabs.systemEnabled) if systemOn, system == nil { system = SystemStore() } if !systemOn, let store = system { diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift index e78b27c19..00dc640d0 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift @@ -249,6 +249,15 @@ struct EdithApp { IPC.Name.appDiagnostics, userInfo: AppDiagnosticsPayload.encode(AppInspectionCenter().diagnostics())) } + _ = IPC.observe( + IPC.Name.requestDockToolsOperation, + info: { info in + if let dockTools = services.dockTools { + dockTools.perform(info) + } else { + DockToolsEngine.performWhileDisabled(info) + } + }) _ = IPC.observe( IPC.Name.requestQuitApps, info: { info in diff --git a/Packages/Edith/Sources/EdithHelper/Features/DockTools/DockToolsEngine.swift b/Packages/Edith/Sources/EdithHelper/Features/DockTools/DockToolsEngine.swift new file mode 100644 index 000000000..203e5af4c --- /dev/null +++ b/Packages/Edith/Sources/EdithHelper/Features/DockTools/DockToolsEngine.swift @@ -0,0 +1,1065 @@ +import AppKit +import ApplicationServices +import CoreGraphics +import EdithKit +import ScreenCaptureKit +import SwiftUI + +private struct DockToolsRuntimeWindow { + let value: DockToolsWindow + let element: AXUIElement + let windowID: CGWindowID? + let frame: CGRect? +} + +private struct DockToolsHit { + let application: NSRunningApplication + let iconFrame: CGRect +} + +private struct DockToolsGreenTarget { + let window: AXUIElement + let frame: CGRect + let identifier: String +} + +private func dockToolsEventCallback( + _: CGEventTapProxy, type: CGEventType, event: CGEvent, + userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + guard let userInfo else { return Unmanaged.passUnretained(event) } + let engine = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + return MainActor.assumeIsolated { + engine.handle(type: type, event: event) + } +} + +private func dockToolsAXCallback( + observer: AXObserver, element: AXUIElement, notification: CFString, + userInfo: UnsafeMutableRawPointer? +) { + guard let userInfo else { return } + let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + MainActor.assumeIsolated { + monitor.handle(observer: observer, element: element, notification: notification as String) + } +} + +@MainActor +final class DockToolsEngine { + private(set) var preferences = DockToolsPreferences() + private let preview = DockToolsPreviewController() + private lazy var autoQuit = DockToolsAutoQuitMonitor() + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var pendingHover: DispatchWorkItem? + private var pendingHide: DispatchWorkItem? + private var pendingApplicationPID: pid_t? + private var swallowedMouseUp = false + private var restoredFrames: [String: CGRect] = [:] + private var dockPID: pid_t? + private var lastMoveAt = CFAbsoluteTime(0) + + init() { + syncSettings() + } + + func syncSettings() { + preferences = DockToolsPreferences() + autoQuit.sync(enabled: preferences.enabled && preferences.quitOnLastWindow) + guard preferences.enabled, AXIsProcessTrusted() else { + stopEventTap() + preview.close() + return + } + startEventTap() + } + + func shutdown() { + pendingHover?.cancel() + pendingHide?.cancel() + stopEventTap() + preview.shutdown() + autoQuit.shutdown() + } + + func perform(_ info: [AnyHashable: Any]) { + let requestID = info[DockToolsIPC.requestIDKey] as? String ?? "" + let operation = info[DockToolsIPC.operationKey] as? String ?? "" + let bundleIdentifier = info[DockToolsIPC.bundleIdentifierKey] as? String + var status = "ok" + var payload = "" + switch operation { + case "status": + payload = DockToolsIPC.encode(runtimeStatus()) + case "windows": + guard AXIsProcessTrusted() else { + status = "notAuthorized" + break + } + guard let application = application(bundleIdentifier: bundleIdentifier) else { + status = "notFound" + break + } + payload = DockToolsIPC.encode(windows(for: application).map(\.value)) + case "show": + guard AXIsProcessTrusted() else { + status = "notAuthorized" + break + } + guard let application = application(bundleIdentifier: bundleIdentifier) else { + status = "notFound" + break + } + guard !preferences.excludes(application.bundleIdentifier) else { + status = "excluded" + break + } + showPreview(for: application, iconFrame: nil) + default: + status = "invalid" + } + IPC.post( + IPC.Name.dockToolsOperationResult, + userInfo: [ + DockToolsIPC.requestIDKey: requestID, + DockToolsIPC.statusKey: status, + DockToolsIPC.payloadKey: payload, + ]) + } + + func runtimeStatus() -> DockToolsStatus { + DockToolsStatus( + preferences: preferences, helperRunning: true, + accessibilityGranted: AXIsProcessTrusted(), + screenRecordingGranted: CGPreflightScreenCaptureAccess()) + } + + static func performWhileDisabled(_ info: [AnyHashable: Any]) { + let requestID = info[DockToolsIPC.requestIDKey] as? String ?? "" + let operation = info[DockToolsIPC.operationKey] as? String ?? "" + let preferences = DockToolsPreferences() + let status: String + let payload: String + if operation == "status" { + status = "ok" + payload = DockToolsIPC.encode( + DockToolsStatus( + preferences: preferences, helperRunning: true, + accessibilityGranted: AXIsProcessTrusted(), + screenRecordingGranted: CGPreflightScreenCaptureAccess())) + } else { + status = "extensionOff" + payload = "" + } + IPC.post( + IPC.Name.dockToolsOperationResult, + userInfo: [ + DockToolsIPC.requestIDKey: requestID, + DockToolsIPC.statusKey: status, + DockToolsIPC.payloadKey: payload, + ]) + } + + fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) } + return Unmanaged.passUnretained(event) + } + switch type { + case .mouseMoved: + handleMouseMove(event.location) + case .leftMouseDown: + if handleMouseDown(event) { return nil } + case .leftMouseUp: + if swallowedMouseUp { + swallowedMouseUp = false + return nil + } + default: + break + } + return Unmanaged.passUnretained(event) + } + + private func startEventTap() { + guard eventTap == nil else { return } + let mask = + CGEventMask(1 << CGEventType.mouseMoved.rawValue) + | CGEventMask(1 << CGEventType.leftMouseDown.rawValue) + | CGEventMask(1 << CGEventType.leftMouseUp.rawValue) + let info = Unmanaged.passUnretained(self).toOpaque() + guard + let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, place: .headInsertEventTap, + options: .defaultTap, eventsOfInterest: mask, + callback: dockToolsEventCallback, userInfo: info) + else { return } + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + eventTap = tap + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + } + + private func stopEventTap() { + guard let eventTap else { return } + CGEvent.tapEnable(tap: eventTap, enable: false) + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + self.eventTap = nil + runLoopSource = nil + } + + private func handleMouseMove(_ point: CGPoint) { + let now = CFAbsoluteTimeGetCurrent() + guard now - lastMoveAt >= 1.0 / 45 else { return } + lastMoveAt = now + if preview.contains(axPoint: point) { + cancelHide() + return + } + guard preferences.previewMode == .hover else { + scheduleHide() + return + } + guard let hit = dockHit(at: point), !preferences.excludes(hit.application.bundleIdentifier) + else { + pendingHover?.cancel() + pendingHover = nil + pendingApplicationPID = nil + scheduleHide() + return + } + cancelHide() + if preview.applicationPID == hit.application.processIdentifier { return } + if pendingApplicationPID == hit.application.processIdentifier { return } + pendingHover?.cancel() + pendingApplicationPID = hit.application.processIdentifier + let work = DispatchWorkItem { [weak self, weak application = hit.application] in + guard let self, let application, + self.pendingApplicationPID == application.processIdentifier + else { return } + self.pendingApplicationPID = nil + self.showPreview(for: application, iconFrame: hit.iconFrame) + } + pendingHover = work + DispatchQueue.main.asyncAfter(deadline: .now() + preferences.hoverDelay, execute: work) + } + + private func handleMouseDown(_ event: CGEvent) -> Bool { + let point = event.location + if preferences.greenButtonMaximizes, let target = greenTarget(at: point) { + toggleMaximize(target) + swallowedMouseUp = true + return true + } + guard let hit = dockHit(at: point), !preferences.excludes(hit.application.bundleIdentifier) + else { return false } + let option = event.flags.contains(.maskAlternate) + if preferences.previewMode == .optionClick, option { + showPreview(for: hit.application, iconFrame: hit.iconFrame) + swallowedMouseUp = true + return true + } + let frontmost = + NSWorkspace.shared.frontmostApplication?.processIdentifier + == hit.application.processIdentifier + guard + DockToolsPolicy.shouldHandleDockClick( + action: preferences.clickAction, appIsFrontmost: frontmost, excluded: false) + else { return false } + switch preferences.clickAction { + case .cycleWindows: + _ = cycleWindow(for: hit.application) + case .minimizeFrontWindow: + _ = minimizeFrontWindow(for: hit.application) + case .standard: + return false + } + preview.close() + swallowedMouseUp = true + return true + } + + private func showPreview(for application: NSRunningApplication, iconFrame: CGRect?) { + let values = windows(for: application) + guard !values.isEmpty else { + preview.close() + return + } + preview.show( + application: application, windows: values, + iconFrame: iconFrame.map(appKitFrame(fromAX:)), + activate: { [weak self] window in + guard let self else { return } + _ = self.activate(window, application: application) + self.preview.close() + }, + move: { [weak self] offset in + self?.preview.moveSelection(offset) + }) + } + + private func scheduleHide() { + guard preview.isVisible else { return } + pendingHide?.cancel() + let work = DispatchWorkItem { [weak self] in self?.preview.close() } + pendingHide = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.24, execute: work) + } + + private func cancelHide() { + pendingHide?.cancel() + pendingHide = nil + } + + private func windows(for application: NSRunningApplication) -> [DockToolsRuntimeWindow] { + let appElement = AXUIElementCreateApplication(application.processIdentifier) + AXUIElementSetMessagingTimeout(appElement, 0.35) + let elements: [AXUIElement] = attribute(appElement, kAXWindowsAttribute as CFString) ?? [] + let bundleIdentifier = application.bundleIdentifier ?? "" + let appName = application.localizedName ?? bundleIdentifier + return elements.enumerated().compactMap { index, element in + let role: String? = attribute(element, kAXRoleAttribute as CFString) + guard role == kAXWindowRole as String else { return nil } + let title: String = attribute(element, kAXTitleAttribute as CFString) ?? "" + let minimized: Bool = attribute(element, kAXMinimizedAttribute as CFString) ?? false + let identifier = "\(application.processIdentifier):\(CFHash(element)):\(index)" + let directWindowNumber: NSNumber? = attribute(element, "AXWindowNumber" as CFString) + let elementFrame = frame(of: element) + let windowID: CGWindowID? + if let directWindowNumber { + windowID = CGWindowID(directWindowNumber.uint32Value) + } else { + windowID = nil + } + return DockToolsRuntimeWindow( + value: DockToolsWindow( + id: windowID.map { "\(application.processIdentifier):\($0)" } + ?? identifier, + title: title, appName: appName, + bundleIdentifier: bundleIdentifier, pid: application.processIdentifier, + minimized: minimized), + element: element, windowID: windowID, frame: elementFrame) + } + } + + private func activate( + _ window: DockToolsRuntimeWindow, application: NSRunningApplication + ) -> Bool { + if window.value.minimized { + _ = AXUIElementSetAttributeValue( + window.element, kAXMinimizedAttribute as CFString, kCFBooleanFalse) + } + let activated = application.activate() + let main = AXUIElementSetAttributeValue( + window.element, kAXMainAttribute as CFString, kCFBooleanTrue) + let raised = AXUIElementPerformAction(window.element, kAXRaiseAction as CFString) + return activated && (main == .success || raised == .success) + } + + private func cycleWindow(for application: NSRunningApplication) -> Bool { + let values = windows(for: application) + guard !values.isEmpty else { return false } + let appElement = AXUIElementCreateApplication(application.processIdentifier) + let focused: AXUIElement? = attribute(appElement, kAXFocusedWindowAttribute as CFString) + let current = focused.flatMap { focused in + values.firstIndex { CFEqual($0.element, focused) } + } + guard + let index = DockToolsPolicy.adjacentIndex( + current: current, count: values.count, offset: 1) + else { return false } + return activate(values[index], application: application) + } + + private func minimizeFrontWindow(for application: NSRunningApplication) -> Bool { + let appElement = AXUIElementCreateApplication(application.processIdentifier) + let focused: AXUIElement? = attribute(appElement, kAXFocusedWindowAttribute as CFString) + guard let focused else { return false } + return AXUIElementSetAttributeValue( + focused, kAXMinimizedAttribute as CFString, kCFBooleanTrue) == .success + } + + private func dockHit(at point: CGPoint) -> DockToolsHit? { + guard let dockPID = dockProcessID() else { return nil } + let system = AXUIElementCreateSystemWide() + var raw: AXUIElement? + guard + AXUIElementCopyElementAtPosition(system, Float(point.x), Float(point.y), &raw) + == .success, + let raw + else { return nil } + let running = NSWorkspace.shared.runningApplications.filter { + $0.activationPolicy == .regular && !$0.isTerminated + } + for element in elementAndParents(raw) { + var pid = pid_t() + guard AXUIElementGetPid(element, &pid) == .success, pid == dockPID, + let frame = frame(of: element) + else { continue } + if let url: URL = attribute(element, kAXURLAttribute as CFString) { + let path = url.standardizedFileURL.path + if let app = running.first(where: { + $0.bundleURL?.standardizedFileURL.path == path + }) { + return DockToolsHit(application: app, iconFrame: frame) + } + } + } + return nil + } + + private func dockProcessID() -> pid_t? { + if let dockPID, + NSRunningApplication(processIdentifier: dockPID)?.isTerminated == false + { + return dockPID + } + let pid = NSWorkspace.shared.runningApplications.first { + $0.bundleIdentifier == "com.apple.dock" + }?.processIdentifier + dockPID = pid + return pid + } + + private func greenTarget(at point: CGPoint) -> DockToolsGreenTarget? { + let system = AXUIElementCreateSystemWide() + var raw: AXUIElement? + guard + AXUIElementCopyElementAtPosition(system, Float(point.x), Float(point.y), &raw) + == .success, + let raw + else { return nil } + let role: String? = attribute(raw, kAXRoleAttribute as CFString) + let subrole: String? = attribute(raw, kAXSubroleAttribute as CFString) + guard role == kAXButtonRole as String, + subrole == "AXFullScreenButton" || subrole == "AXZoomButton" + else { return nil } + guard + let window = elementAndParents(raw).first(where: { + let value: String? = attribute($0, kAXRoleAttribute as CFString) + return value == kAXWindowRole as String + }), let buttonFrame = frame(of: raw) + else { return nil } + var pid = pid_t() + guard AXUIElementGetPid(window, &pid) == .success, + let app = NSRunningApplication(processIdentifier: pid), + !preferences.excludes(app.bundleIdentifier) + else { return nil } + return DockToolsGreenTarget( + window: window, frame: buttonFrame, + identifier: "\(pid):\(CFHash(window))") + } + + private func toggleMaximize(_ target: DockToolsGreenTarget) { + guard let current = frame(of: target.window), + let screen = bestScreen(for: current) + else { return } + let maximized = axFrame(fromAppKit: screen.visibleFrame) + let close = + abs(current.minX - maximized.minX) < 3 + && abs(current.minY - maximized.minY) < 3 + && abs(current.width - maximized.width) < 6 + && abs(current.height - maximized.height) < 6 + let destination: CGRect + if close, let restored = restoredFrames[target.identifier] { + destination = restored + restoredFrames[target.identifier] = nil + } else { + restoredFrames[target.identifier] = current + destination = maximized + } + _ = setFrame(destination, on: target.window) + } + + private func bestScreen(for axFrame: CGRect) -> NSScreen? { + let appKit = appKitFrame(fromAX: axFrame) + return NSScreen.screens.max { first, second in + first.visibleFrame.intersection(appKit).area + < second.visibleFrame.intersection(appKit).area + } + } + + private func setFrame(_ frame: CGRect, on element: AXUIElement) -> Bool { + var point = frame.origin + var size = frame.size + guard let pointValue = AXValueCreate(.cgPoint, &point), + let sizeValue = AXValueCreate(.cgSize, &size) + else { return false } + let moved = AXUIElementSetAttributeValue( + element, kAXPositionAttribute as CFString, pointValue) + let resized = AXUIElementSetAttributeValue( + element, kAXSizeAttribute as CFString, sizeValue) + return moved == .success && resized == .success + } + + private func application(bundleIdentifier: String?) -> NSRunningApplication? { + guard let bundleIdentifier else { return NSWorkspace.shared.frontmostApplication } + return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) + .first { !$0.isTerminated } + } + + private func elementAndParents(_ element: AXUIElement) -> [AXUIElement] { + var result = [element] + var current = element + for _ in 0..<8 { + guard let parent: AXUIElement = attribute(current, kAXParentAttribute as CFString) + else { break } + result.append(parent) + current = parent + } + return result + } + + private func frame(of element: AXUIElement) -> CGRect? { + guard let point = pointAttribute(element, kAXPositionAttribute as CFString), + let size = sizeAttribute(element, kAXSizeAttribute as CFString), + size.width > 0, size.height > 0 + else { return nil } + return CGRect(origin: point, size: size) + } + + private func attribute(_ element: AXUIElement, _ name: CFString) -> T? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { return nil } + return value as? T + } + + private func pointAttribute(_ element: AXUIElement, _ name: CFString) -> CGPoint? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success, + let value, CFGetTypeID(value) == AXValueGetTypeID() + else { return nil } + var point = CGPoint.zero + return AXValueGetValue(value as! AXValue, .cgPoint, &point) ? point : nil + } + + private func sizeAttribute(_ element: AXUIElement, _ name: CFString) -> CGSize? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success, + let value, CFGetTypeID(value) == AXValueGetTypeID() + else { return nil } + var size = CGSize.zero + return AXValueGetValue(value as! AXValue, .cgSize, &size) ? size : nil + } + + private var screenTop: CGFloat { + (NSScreen.screens.first { abs($0.frame.minX) < 0.5 && abs($0.frame.minY) < 0.5 } + ?? NSScreen.main ?? NSScreen.screens.first)?.frame.maxY ?? 0 + } + + private func appKitFrame(fromAX frame: CGRect) -> CGRect { + CGRect(x: frame.minX, y: screenTop - frame.maxY, width: frame.width, height: frame.height) + } + + private func axFrame(fromAppKit frame: CGRect) -> CGRect { + CGRect(x: frame.minX, y: screenTop - frame.maxY, width: frame.width, height: frame.height) + } +} + +@MainActor +private final class DockToolsPreviewStore: ObservableObject { + @Published var application: NSRunningApplication? + @Published var windows: [DockToolsRuntimeWindow] = [] + @Published var images: [String: NSImage] = [:] + @Published var selectedIndex = 0 + var activate: ((DockToolsRuntimeWindow) -> Void)? + + var selectedID: String? { + windows.indices.contains(selectedIndex) ? windows[selectedIndex].value.id : nil + } +} + +@MainActor +private final class DockToolsPreviewPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +@MainActor +private final class DockToolsPreviewController { + private let store = DockToolsPreviewStore() + private var panel: DockToolsPreviewPanel? + private var imageTask: Task? + + var isVisible: Bool { panel?.isVisible == true } + var applicationPID: pid_t? { store.application?.processIdentifier } + + func show( + application: NSRunningApplication, windows: [DockToolsRuntimeWindow], + iconFrame: CGRect?, activate: @escaping (DockToolsRuntimeWindow) -> Void, + move: @escaping (Int) -> Void + ) { + store.application = application + store.windows = windows + store.images = [:] + store.selectedIndex = 0 + store.activate = activate + let panel = panel ?? makePanel(move: move) + self.panel = panel + let width = min(CGFloat(windows.count) * 218 + 32, 904) + let size = NSSize(width: max(width, 250), height: 202) + panel.setContentSize(size) + panel.contentViewController?.view.frame = NSRect(origin: .zero, size: size) + panel.setFrameOrigin(origin(for: size, iconFrame: iconFrame)) + panel.orderFrontRegardless() + loadImages(for: windows, applicationPID: application.processIdentifier) + } + + func close() { + imageTask?.cancel() + imageTask = nil + panel?.orderOut(nil) + store.windows = [] + store.images = [:] + store.application = nil + store.activate = nil + } + + func shutdown() { + close() + panel?.contentViewController = nil + panel = nil + } + + func moveSelection(_ offset: Int) { + guard + let index = DockToolsPolicy.adjacentIndex( + current: store.selectedIndex, count: store.windows.count, offset: offset) + else { return } + store.selectedIndex = index + } + + func contains(axPoint: CGPoint) -> Bool { + guard let frame = panel?.frame, isVisible else { return false } + let top = (NSScreen.main ?? NSScreen.screens.first)?.frame.maxY ?? 0 + let appKitPoint = CGPoint(x: axPoint.x, y: top - axPoint.y) + return frame.insetBy(dx: -8, dy: -8).contains(appKitPoint) + } + + private func makePanel(move: @escaping (Int) -> Void) -> DockToolsPreviewPanel { + let panel = DockToolsPreviewPanel( + contentRect: NSRect(x: 0, y: 0, width: 250, height: 202), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.isReleasedWhenClosed = false + panel.contentViewController = NSHostingController( + rootView: DockToolsPreviewPresentation(store: store, move: move)) + return panel + } + + private func origin(for size: NSSize, iconFrame: CGRect?) -> CGPoint { + let screen = + iconFrame.flatMap { frame in + NSScreen.screens.first { $0.frame.intersects(frame) } + } ?? NSScreen.main ?? NSScreen.screens.first + guard let screen else { return .zero } + let anchor = + iconFrame + ?? CGRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.minY, width: 1, height: 1) + var x = anchor.midX - size.width / 2 + var y = anchor.maxY + 10 + if anchor.midX < screen.visibleFrame.minX + 100 { + x = anchor.maxX + 10 + y = anchor.midY - size.height / 2 + } else if anchor.midX > screen.visibleFrame.maxX - 100 { + x = anchor.minX - size.width - 10 + y = anchor.midY - size.height / 2 + } + x = min(max(x, screen.visibleFrame.minX + 8), screen.visibleFrame.maxX - size.width - 8) + y = min(max(y, screen.visibleFrame.minY + 8), screen.visibleFrame.maxY - size.height - 8) + return CGPoint(x: x, y: y) + } + + private func loadImages(for windows: [DockToolsRuntimeWindow], applicationPID: pid_t) { + imageTask?.cancel() + imageTask = nil + guard CGPreflightScreenCaptureAccess() else { return } + imageTask = Task { [weak self] in + guard let self else { return } + let images = await captureImages(for: windows) + guard !Task.isCancelled, store.application?.processIdentifier == applicationPID else { + return + } + store.images = images + } + } + + private func captureImages(for windows: [DockToolsRuntimeWindow]) async -> [String: NSImage] { + guard + let content = try? await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false) + else { return [:] } + var available = content.windows.filter { sharedWindow in + windows.contains { $0.value.pid == sharedWindow.owningApplication?.processID } + } + var result: [String: NSImage] = [:] + for window in windows.prefix(8) { + guard !Task.isCancelled else { return [:] } + let sharedIndex: Int? + if let windowID = window.windowID { + sharedIndex = available.firstIndex { $0.windowID == windowID } + } else { + sharedIndex = bestSharedWindowIndex(for: window, available: available) + } + guard let sharedIndex else { continue } + let sharedWindow = available.remove(at: sharedIndex) + let configuration = SCStreamConfiguration() + let width = max(sharedWindow.frame.width, 1) + let height = max(sharedWindow.frame.height, 1) + let scale = min(2, 720 / width, 420 / height) + configuration.width = max(Int(width * scale), 1) + configuration.height = max(Int(height * scale), 1) + configuration.showsCursor = false + guard + let image = try? await SCScreenshotManager.captureImage( + contentFilter: SCContentFilter(desktopIndependentWindow: sharedWindow), + configuration: configuration) + else { continue } + guard !Task.isCancelled else { return [:] } + result[window.value.id] = NSImage(cgImage: image, size: .zero) + } + return result + } + + private func bestSharedWindowIndex( + for window: DockToolsRuntimeWindow, available: [SCWindow] + ) -> Int? { + guard !available.isEmpty else { return nil } + return available.indices.min { first, second in + sharedWindowScore(available[first], for: window) + < sharedWindowScore(available[second], for: window) + } + } + + private func sharedWindowScore(_ sharedWindow: SCWindow, for window: DockToolsRuntimeWindow) + -> CGFloat + { + let sharedTitle = sharedWindow.title ?? "" + let title = window.value.title + let titlePenalty = + title.isEmpty || sharedTitle.isEmpty || sharedTitle == title ? 0 : 100_000 + guard let frame = window.frame else { return CGFloat(titlePenalty) } + let sharedFrame = sharedWindow.frame + let frameDelta = + abs(sharedFrame.minX - frame.minX) + abs(sharedFrame.minY - frame.minY) + + abs(sharedFrame.width - frame.width) + abs(sharedFrame.height - frame.height) + return CGFloat(titlePenalty) + frameDelta + } +} + +private struct DockToolsPreviewPresentation: View { + @ObservedObject var store: DockToolsPreviewStore + let move: (Int) -> Void + + var body: some View { + DockToolsPreviewView( + applicationName: store.application?.localizedName ?? "Windows", + icon: store.application?.icon, windows: store.windows.map(\.value), + images: store.images, selectedID: store.selectedID, + activate: { value in + if let window = store.windows.first(where: { $0.value.id == value.id }) { + store.activate?(window) + } + }, move: move) + } +} + +struct DockToolsPreviewView: View { + let applicationName: String + let icon: NSImage? + let windows: [DockToolsWindow] + let images: [String: NSImage] + let selectedID: String? + let activate: (DockToolsWindow) -> Void + let move: (Int) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + appIcon + .frame(width: 22, height: 22) + Text(applicationName) + .font(.system(size: 13, weight: .semibold)) + Text("\(windows.count)") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + Spacer() + Button { + move(-1) + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.edith(.toolbar)) + .accessibilityLabel("Previous window") + Button { + move(1) + } label: { + Image(systemName: "chevron.right") + } + .buttonStyle(.edith(.toolbar)) + .accessibilityLabel("Next window") + } + .padding(.horizontal, 14) + .frame(height: 38) + + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(windows, id: \.id) { window in + Button { + activate(window) + } label: { + DockToolsWindowCard( + window: window, image: images[window.id], + icon: icon, + selected: selectedID == window.id) + } + .buttonStyle(.edith(.borderless)) + .accessibilityLabel("Open \(window.displayTitle)") + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + .scrollIndicators(.never) + } + .background(.ultraThickMaterial, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(.white.opacity(0.14)) + } + } + + private var appIcon: some View { + let image = + icon + ?? NSImage(systemSymbolName: "macwindow", accessibilityDescription: nil)! + return Image(nsImage: image).resizable() + } +} + +private struct DockToolsWindowCard: View { + let window: DockToolsWindow + let image: NSImage? + let icon: NSImage? + let selected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + ZStack { + RoundedRectangle(cornerRadius: 8).fill(.black.opacity(0.18)) + if let image { + Image(nsImage: image) + .resizable() + .scaledToFit() + .padding(4) + } else if let icon { + Image(nsImage: icon) + .resizable() + .frame(width: 52, height: 52) + } else { + Image(systemName: "macwindow") + .font(.system(size: 38)) + .foregroundStyle(.secondary) + } + } + .frame(width: 198, height: 116) + HStack(spacing: 6) { + Text(window.displayTitle) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Spacer(minLength: 0) + if window.minimized { + Image(systemName: "minus.square") + .foregroundStyle(.secondary) + .accessibilityLabel("Minimized") + } + } + } + .padding(7) + .background( + selected ? Color.accentColor.opacity(0.2) : Color.white.opacity(0.06), + in: RoundedRectangle(cornerRadius: 10) + ) + .overlay { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(selected ? Color.accentColor.opacity(0.7) : .clear, lineWidth: 1.5) + } + } +} + +@MainActor +private final class DockToolsAutoQuitMonitor { + private var enabled = false + private var observers: [pid_t: AXObserver] = [:] + private var hadWindows: [pid_t: Bool] = [:] + private var launchToken: NSObjectProtocol? + private var terminateToken: NSObjectProtocol? + private var activateToken: NSObjectProtocol? + + func sync(enabled: Bool) { + guard enabled, AXIsProcessTrusted() else { + shutdown() + return + } + self.enabled = true + start() + } + + func shutdown() { + enabled = false + if let launchToken { NSWorkspace.shared.notificationCenter.removeObserver(launchToken) } + if let terminateToken { + NSWorkspace.shared.notificationCenter.removeObserver(terminateToken) + } + if let activateToken { NSWorkspace.shared.notificationCenter.removeObserver(activateToken) } + launchToken = nil + terminateToken = nil + activateToken = nil + for pid in Array(observers.keys) { detach(pid) } + } + + func handle(observer: AXObserver, element: AXUIElement, notification: String) { + var pid = pid_t() + if AXUIElementGetPid(element, &pid) != .success || pid == 0 { + pid = observers.first { CFEqual($0.value, observer) }?.key ?? 0 + } + guard pid != 0 else { return } + if notification == kAXWindowCreatedNotification as String { + refresh(pid) + } + if notification == kAXUIElementDestroyedNotification as String { + scheduleCheck(pid) + } + } + + private func start() { + guard launchToken == nil, AXIsProcessTrusted() else { return } + for application in NSWorkspace.shared.runningApplications { attach(application) } + launchToken = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main + ) { [weak self] note in + guard + let application = note.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + else { return } + MainActor.assumeIsolated { self?.attach(application) } + } + terminateToken = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main + ) { [weak self] note in + guard + let application = note.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + else { return } + MainActor.assumeIsolated { self?.detach(application.processIdentifier) } + } + activateToken = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main + ) { [weak self] note in + guard + let application = note.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + else { return } + MainActor.assumeIsolated { self?.attach(application) } + } + } + + private func attach(_ application: NSRunningApplication) { + let pid = application.processIdentifier + guard enabled, application.activationPolicy == .regular, pid != getpid(), + observers[pid] == nil + else { return } + var observer: AXObserver? + guard AXObserverCreate(pid, dockToolsAXCallback, &observer) == .success, let observer + else { return } + let appElement = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(appElement, 0.35) + let info = Unmanaged.passUnretained(self).toOpaque() + _ = AXObserverAddNotification( + observer, appElement, kAXWindowCreatedNotification as CFString, info) + CFRunLoopAddSource( + CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .commonModes) + observers[pid] = observer + refresh(pid) + } + + private func detach(_ pid: pid_t) { + if let observer = observers[pid] { + CFRunLoopRemoveSource( + CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .commonModes) + } + observers[pid] = nil + hadWindows[pid] = nil + } + + private func refresh(_ pid: pid_t) { + guard let observer = observers[pid] else { return } + let appElement = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(appElement, 0.35) + let windows: [AXUIElement] = attribute(appElement, kAXWindowsAttribute as CFString) ?? [] + let standard = windows.filter { window in + let role: String? = attribute(window, kAXRoleAttribute as CFString) + return role == kAXWindowRole as String + } + if !standard.isEmpty { hadWindows[pid] = true } + let info = Unmanaged.passUnretained(self).toOpaque() + for window in standard { + _ = AXObserverAddNotification( + observer, window, kAXUIElementDestroyedNotification as CFString, info) + } + } + + private func scheduleCheck(_ pid: pid_t) { + for delay in [0.45, 1.2] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.check(pid) + } + } + } + + private func check(_ pid: pid_t) { + guard enabled, let application = NSRunningApplication(processIdentifier: pid) else { + return + } + let preferences = DockToolsPreferences() + let appElement = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(appElement, 0.35) + let windows: [AXUIElement] = attribute(appElement, kAXWindowsAttribute as CFString) ?? [] + let hasWindows = windows.contains { window in + let role: String? = attribute(window, kAXRoleAttribute as CFString) + return role == kAXWindowRole as String + } + guard + DockToolsPolicy.shouldQuit( + enabled: preferences.enabled && preferences.quitOnLastWindow, + hadWindows: hadWindows[pid] == true, hasWindows: hasWindows, + excluded: preferences.excludes(application.bundleIdentifier) + || application.bundleIdentifier?.hasPrefix("com.pulkit.edith") == true, + terminated: application.isTerminated, + regularApplication: application.activationPolicy == .regular) + else { return } + hadWindows[pid] = false + application.terminate() + } + + private func attribute(_ element: AXUIElement, _ name: CFString) -> T? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { return nil } + return value as? T + } +} + +private extension CGRect { + var area: CGFloat { max(width, 0) * max(height, 0) } +} diff --git a/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift b/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift index c0b0e423a..2bec69d0b 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift @@ -804,6 +804,10 @@ final class SettingsBackup { AppStorageKeys.FocusDim.otherDisplaysMode, AppStorageKeys.FocusDim.hotKeyCode, AppStorageKeys.FocusDim.hotKeyMods, AppStorageKeys.FocusDim.hotKeyLabel, + AppStorageKeys.DockTools.enabled, AppStorageKeys.DockTools.previewMode, + AppStorageKeys.DockTools.hoverDelay, AppStorageKeys.DockTools.clickAction, + AppStorageKeys.DockTools.greenButtonMaximizes, + AppStorageKeys.DockTools.quitOnLastWindow, AppStorageKeys.DockTools.excludedApps, AppStorageKeys.ColorPicker.enabled, AppStorageKeys.ColorPicker.copyFormat, AppStorageKeys.ColorPicker.profile, AppStorageKeys.ColorPicker.historySize, "colorPickerHotKeyCode", "colorPickerHotKeyMods", @@ -930,6 +934,10 @@ final class SettingsBackup { AppStorageKeys.FocusDim.otherDisplaysMode, AppStorageKeys.FocusDim.hotKeyCode, AppStorageKeys.FocusDim.hotKeyMods, AppStorageKeys.FocusDim.hotKeyLabel, + AppStorageKeys.DockTools.enabled, AppStorageKeys.DockTools.previewMode, + AppStorageKeys.DockTools.hoverDelay, AppStorageKeys.DockTools.clickAction, + AppStorageKeys.DockTools.greenButtonMaximizes, + AppStorageKeys.DockTools.quitOnLastWindow, AppStorageKeys.DockTools.excludedApps, AppStorageKeys.ColorPicker.enabled, AppStorageKeys.ColorPicker.copyFormat, AppStorageKeys.ColorPicker.profile, AppStorageKeys.ColorPicker.historySize, "colorPickerHotKeyCode", "colorPickerHotKeyMods", diff --git a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift index 8a6aeed1c..8189fce11 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift @@ -110,6 +110,16 @@ public enum AppStorageKeys { public static let usage = "emojiUsage" } + public enum DockTools { + public static let enabled = "dockToolsEnabled" + public static let previewMode = "dockToolsPreviewMode" + public static let hoverDelay = "dockToolsHoverDelay" + public static let clickAction = "dockToolsClickAction" + public static let greenButtonMaximizes = "dockToolsGreenButtonMaximizes" + public static let quitOnLastWindow = "dockToolsQuitOnLastWindow" + public static let excludedApps = "dockToolsExcludedApps" + } + public enum FocusDim { public static let animationDuration = "focusDimAnimationDuration" public static let hotKeyCode = "focusDimHotKeyCode" diff --git a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift index 93bd26304..afc47737b 100644 --- a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift +++ b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift @@ -112,6 +112,10 @@ public enum IPC { "com.pulkit.edith.requestAppDiagnostics") public static let appDiagnostics = IPC.scopedName( "com.pulkit.edith.appDiagnostics") + public static let requestDockToolsOperation = IPC.scopedName( + "com.pulkit.edith.requestDockToolsOperation") + public static let dockToolsOperationResult = IPC.scopedName( + "com.pulkit.edith.dockToolsOperationResult") public static let requestQuinjetSessionOperation = IPC.scopedName( "com.pulkit.edith.requestQuinjetSessionOperation") public static let quinjetSessionOperationResult = IPC.scopedName( diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift index 15970623b..ba1b75d59 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift @@ -53,7 +53,7 @@ public enum ConfigCatalog { "music", "calendar", "clipboard", "keystrokes", - "notch", "focusdim", "presenter", "colorpicker", "emoji", "micmute", + "notch", "focusdim", "docktools", "presenter", "colorpicker", "emoji", "micmute", "backup", "permissions", "terminal", ] @@ -61,7 +61,8 @@ public enum ConfigCatalog { agent + suites + appearance + panel + attention + usageAndLimits + menuBar + alerts + budget + dashboard + database + machines + herdr + quinjet + companion + finder + system + homebrew + cleaner - + music + calendar + clipboard + keystrokeHighlight + notch + focusDim + presenter + + music + calendar + clipboard + keystrokeHighlight + notch + focusDim + dockTools + + presenter + colorPicker + emoji + micMute + backup + permissions + terminal @@ -808,6 +809,35 @@ public enum ConfigCatalog { summary: "Printable label for the focus dim shortcut."), ] + private static let dockTools: [SettingDefinition] = [ + SettingDefinition( + AppStorageKeys.DockTools.enabled, .bool, group: "docktools", + summary: "Dock Tools extension: previews and Dock window controls.", + fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.DockTools.previewMode, .string, group: "docktools", + summary: "How Dock window previews open.", + allowed: DockPreviewMode.allCases.map(\.rawValue), fallback: .string("hover")), + SettingDefinition( + AppStorageKeys.DockTools.hoverDelay, .number, group: "docktools", + summary: "Seconds before a Dock hover preview opens.", + fallback: .double(DockToolsPreferences.defaultHoverDelay)), + SettingDefinition( + AppStorageKeys.DockTools.clickAction, .string, group: "docktools", + summary: "Action for clicking the active app in the Dock.", + allowed: DockClickAction.allCases.map(\.rawValue), fallback: .string("standard")), + SettingDefinition( + AppStorageKeys.DockTools.greenButtonMaximizes, .bool, group: "docktools", + summary: "Use the green window button to maximize without entering full screen.", + fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.DockTools.quitOnLastWindow, .bool, group: "docktools", + summary: "Quit regular apps when their last window closes.", fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.DockTools.excludedApps, .csv, group: "docktools", + summary: "Bundle identifiers excluded from Dock Tools."), + ] + private static let presenter: [SettingDefinition] = [ SettingDefinition( AppStorageKeys.Presenter.autoActive, .bool, group: "presenter", diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift index 2a943a4a3..62a2ef419 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift @@ -154,6 +154,11 @@ public enum UserOperationCatalog { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } + private static let dockRegistrations: [RegisteredUserOperation] = + DockToolsOperation.allCases.map { + RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) + } + private static let agentRegistrations: [RegisteredUserOperation] = AgentControlOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) @@ -208,7 +213,7 @@ public enum UserOperationCatalog { }() public static let registrations = - machineRegistrations + applicationRegistrations + featureRegistrations + machineRegistrations + applicationRegistrations + featureRegistrations + dockRegistrations + agentRegistrations + remoteFileRegistrations + remoteActionRegistrations public static let descriptors = registrations.map(\.descriptor) diff --git a/Packages/Edith/Sources/EdithKit/Features/DockTools/Models/DockToolsModels.swift b/Packages/Edith/Sources/EdithKit/Features/DockTools/Models/DockToolsModels.swift new file mode 100644 index 000000000..1d05439b4 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/DockTools/Models/DockToolsModels.swift @@ -0,0 +1,218 @@ +import EdithCore +import Foundation + +public enum DockPreviewMode: String, CaseIterable, Codable, Sendable { + case hover + case optionClick + + public var title: String { + switch self { + case .hover: "Hover" + case .optionClick: "Option-click" + } + } +} + +public enum DockClickAction: String, CaseIterable, Codable, Sendable { + case standard + case cycleWindows + case minimizeFrontWindow + + public var title: String { + switch self { + case .standard: "Standard" + case .cycleWindows: "Cycle windows" + case .minimizeFrontWindow: "Minimize front window" + } + } +} + +public struct DockToolsPreferences: Equatable, Sendable { + public static let hoverDelayRange = 0.15...1.0 + public static let defaultHoverDelay = 0.3 + + public let enabled: Bool + public let previewMode: DockPreviewMode + public let hoverDelay: Double + public let clickAction: DockClickAction + public let greenButtonMaximizes: Bool + public let quitOnLastWindow: Bool + public let excludedBundleIdentifiers: Set + + public init( + enabled: Bool, previewMode: DockPreviewMode, hoverDelay: Double, + clickAction: DockClickAction, greenButtonMaximizes: Bool, + quitOnLastWindow: Bool, excludedBundleIdentifiers: Set + ) { + self.enabled = enabled + self.previewMode = previewMode + self.hoverDelay = Self.sanitizedHoverDelay(hoverDelay) + self.clickAction = clickAction + self.greenButtonMaximizes = greenButtonMaximizes + self.quitOnLastWindow = quitOnLastWindow + self.excludedBundleIdentifiers = Set( + excludedBundleIdentifiers.map { $0.lowercased() }) + } + + public init(defaults: UserDefaults = SharedDefaults.store) { + let previewMode = + defaults.string(forKey: AppStorageKeys.DockTools.previewMode) + .flatMap(DockPreviewMode.init(rawValue:)) ?? .hover + let clickAction = + defaults.string(forKey: AppStorageKeys.DockTools.clickAction) + .flatMap(DockClickAction.init(rawValue:)) ?? .standard + let delay = + defaults.object(forKey: AppStorageKeys.DockTools.hoverDelay) as? Double + ?? Self.defaultHoverDelay + self.init( + enabled: ExtensionRegistry.entry("dockTools")?.isEnabled(in: defaults) ?? false, + previewMode: previewMode, hoverDelay: delay, clickAction: clickAction, + greenButtonMaximizes: defaults.bool( + forKey: AppStorageKeys.DockTools.greenButtonMaximizes), + quitOnLastWindow: defaults.bool(forKey: AppStorageKeys.DockTools.quitOnLastWindow), + excludedBundleIdentifiers: Self.identifiers( + defaults.string(forKey: AppStorageKeys.DockTools.excludedApps) ?? "")) + } + + public func excludes(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return excludedBundleIdentifiers.contains(bundleIdentifier.lowercased()) + } + + public static func sanitizedHoverDelay(_ value: Double) -> Double { + guard value.isFinite else { return defaultHoverDelay } + return min(max(value, hoverDelayRange.lowerBound), hoverDelayRange.upperBound) + } + + public static func identifiers(_ value: String) -> Set { + Set( + value.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty }) + } + + public static func encodedIdentifiers(_ values: some Sequence) -> String { + Set(values.map { $0.lowercased() }).sorted().joined(separator: ",") + } +} + +public struct DockToolsWindow: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let title: String + public let appName: String + public let bundleIdentifier: String + public let pid: Int32 + public let minimized: Bool + + public init( + id: String, title: String, appName: String, bundleIdentifier: String, + pid: Int32, minimized: Bool + ) { + self.id = id + self.title = title + self.appName = appName + self.bundleIdentifier = bundleIdentifier + self.pid = pid + self.minimized = minimized + } + + public var displayTitle: String { + let value = title.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? appName : value + } +} + +public struct DockToolsStatus: Codable, Equatable, Sendable { + public let enabled: Bool + public let helperRunning: Bool + public let accessibilityGranted: Bool + public let screenRecordingGranted: Bool + public let previewMode: DockPreviewMode + public let clickAction: DockClickAction + public let greenButtonMaximizes: Bool + public let quitOnLastWindow: Bool + public let excludedApps: [String] + + public init( + preferences: DockToolsPreferences, helperRunning: Bool, + accessibilityGranted: Bool, screenRecordingGranted: Bool + ) { + enabled = preferences.enabled + self.helperRunning = helperRunning + self.accessibilityGranted = accessibilityGranted + self.screenRecordingGranted = screenRecordingGranted + previewMode = preferences.previewMode + clickAction = preferences.clickAction + greenButtonMaximizes = preferences.greenButtonMaximizes + quitOnLastWindow = preferences.quitOnLastWindow + excludedApps = preferences.excludedBundleIdentifiers.sorted() + } + + public var ready: Bool { enabled && helperRunning && accessibilityGranted } + public var previewsAvailable: Bool { ready && screenRecordingGranted } +} + +public enum DockToolsPolicy { + public static func shouldHandleDockClick( + action: DockClickAction, appIsFrontmost: Bool, excluded: Bool + ) -> Bool { + action != .standard && appIsFrontmost && !excluded + } + + public static func shouldQuit( + enabled: Bool, hadWindows: Bool, hasWindows: Bool, excluded: Bool, + terminated: Bool, regularApplication: Bool + ) -> Bool { + enabled && hadWindows && !hasWindows && !excluded && !terminated && regularApplication + } + + public static func adjacentIndex(current: Int?, count: Int, offset: Int) -> Int? { + guard count > 0 else { return nil } + return ((current ?? (offset > 0 ? -1 : 0)) + offset + count) % count + } +} + +public enum DockToolsOperation: String, CaseIterable, Sendable { + case status + case windows + case show + + public var descriptor: UserOperationDescriptor { + UserOperationDescriptor( + id: UserOperationID(rawValue: "dock." + rawValue), summary: summary, + cli: ["dock", rawValue], effect: self == .show ? .write : .read) + } + + private var summary: String { + switch self { + case .status: "Inspect Dock Tools readiness." + case .windows: "List windows for a Dock application." + case .show: "Show a Dock application's window previews." + } + } + + public var interfaceExposure: UserOperationExposure { + .userInterface([ + UserInterfaceActionPlacement( + surface: self == .status ? "Dock Tools settings" : "Dock preview", + action: summary, exampleArguments: self == .status ? [] : ["com.example.Editor"]) + ]) + } +} + +public enum DockToolsIPC { + public static let requestIDKey = "requestID" + public static let operationKey = "operation" + public static let bundleIdentifierKey = "bundleIdentifier" + public static let statusKey = "status" + public static let payloadKey = "payload" + + public static func encode(_ value: T) -> String { + guard let data = try? JSONEncoder().encode(value) else { return "{}" } + return String(decoding: data, as: UTF8.self) + } + + public static func decode(_ type: T.Type, from value: String) -> T? { + try? JSONDecoder().decode(type, from: Data(value.utf8)) + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift index df3ee6525..a0d9612ac 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift @@ -59,7 +59,7 @@ public enum ExtensionPermission: String, CaseIterable, Hashable, Sendable { "Asked when you first use Clean keys to block key presses during cleaning." case .fullDisk: "Asked when a feature needs local service credentials or usage data." case .screenRecording: - "Required to detect shared content or sample colors from the screen." + "Required to detect shared content, sample colors, or show Dock window thumbnails." case .applicationAudio: "Asked when you first use the Notch Shelf per-app volume mixer." case .camera: "Asked when you first open the Notch Shelf camera preview." @@ -130,6 +130,7 @@ public extension ExtensionRegistryEntry { var requiredPermissions: [ExtensionPermission] { switch id { case "calendar": [.calendar] + case "dockTools": [.accessibility] case "focusDim", "presenter", "colorPicker": [.screenRecording] case "keystrokeHighlight": [.inputMonitoring] default: [] @@ -143,6 +144,7 @@ public extension ExtensionRegistryEntry { case "notchShelf": [.bluetooth, .camera, .automation] case "audioMixer": [.applicationAudio] case "clipboard", "emoji": [.accessibility] + case "dockTools": [.screenRecording] default: [] } } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift index 8bb0f668c..05543df5b 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift @@ -216,6 +216,8 @@ public struct ExtensionLifecycleProbe: Sendable { requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "focusDim": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), + "dockTools": Policy( + requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "presenter": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "colorPicker": Policy( diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift index cec0e0b1a..28a1b52c1 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift @@ -68,7 +68,8 @@ public enum ExtensionLiveAdapters { "usage", "quinjet", "plugins", "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", - "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", + "focusDim", "dockTools", "presenter", "music", "downloads", "notchShelf", "audioMixer", + "calendar", "attention", "seoAudit", ] @@ -119,6 +120,7 @@ public enum ExtensionLiveAdapters { case "clipboard": await clipboardReadiness() case "keystrokeHighlight": keystrokeHighlightReadiness(defaults: defaults) case "focusDim": await focusDimReadiness(defaults: defaults) + case "dockTools": dockToolsReadiness(defaults: defaults) case "presenter": presenterReadiness(defaults: defaults) case "colorPicker": await colorPickerReadiness(defaults: defaults) case "emoji": emojiReadiness(defaults: defaults) @@ -126,6 +128,25 @@ public enum ExtensionLiveAdapters { } } + static func dockToolsReadiness( + defaults: UserDefaults = SharedDefaults.store + ) -> ExtensionAdapterReadiness { + let delay = + defaults.object(forKey: AppStorageKeys.DockTools.hoverDelay) as? Double + ?? DockToolsPreferences.defaultHoverDelay + let previewMode = defaults.string(forKey: AppStorageKeys.DockTools.previewMode) + let clickAction = defaults.string(forKey: AppStorageKeys.DockTools.clickAction) + let configured = + delay.isFinite && DockToolsPreferences.hoverDelayRange.contains(delay) + && (previewMode == nil || DockPreviewMode(rawValue: previewMode ?? "") != nil) + && (clickAction == nil || DockClickAction(rawValue: clickAction ?? "") != nil) + return ExtensionAdapterFacts( + configured: configured, + readyDetail: "Dock window controls are configured.", + setupDetail: "A stored Dock Tools preference is invalid." + ).readiness + } + static func attentionReadiness( settings: AttentionSettings = AttentionRepository().loadSettings() ) -> ExtensionAdapterReadiness { diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift index 924abbc58..a4378d969 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift @@ -95,6 +95,7 @@ public enum ExtensionDetailRoute: String, CaseIterable, Sendable { case clipboard case keystrokeHighlight case focusDim + case dockTools case presenter case colorPicker case emoji diff --git a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift index d8d395bf8..0e8c28bc6 100644 --- a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift @@ -16,7 +16,8 @@ import Testing "usage", "herdr", "quinjet", "companion", "plugins", "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", - "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", + "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "dockTools", + "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", ]) diff --git a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift index d5d848bf8..15f61eb76 100644 --- a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift @@ -145,6 +145,12 @@ enum JSONContract { "ed calendar join", ["calendar", "join", "nothing-at-all", "--json"], mutatesTheMachine: true), JSONCase("ed presenter status", ["presenter", "status", "--json"]), + JSONCase("ed dock status", ["dock", "status", "--json"]), + JSONCase( + "ed dock windows", ["dock", "windows", "com.example.missing", "--json"]), + JSONCase( + "ed dock show", ["dock", "show", "com.example.missing", "--json"], + mutatesTheMachine: true), JSONCase( "ed presenter start", ["presenter", "start", "--json"], mutatesTheMachine: true), diff --git a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift index 55dabb8bb..e40492ae2 100644 --- a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift @@ -237,6 +237,7 @@ enum CommandCrawler { "ed companion connectors", "ed companion db", "ed companion stack", "ed machines terminal", "ed lid-awake", + "ed dock", "ed attention", "ed attention categories", "ed attention focus", "ed quinjet", "ed database", "ed database connections", "ed database saved-queries", diff --git a/Packages/Edith/Tests/EdithTests/DockToolsCLITests.swift b/Packages/Edith/Tests/EdithTests/DockToolsCLITests.swift new file mode 100644 index 000000000..240025e14 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/DockToolsCLITests.swift @@ -0,0 +1,137 @@ +import EdithKit +import Foundation +import Testing + +@testable import EdithCLI + +@Suite struct DockToolsCLITests { + static func requestID(in world: CLIWorld) -> String { + world.postedPayloads(for: IPC.Name.requestDockToolsOperation).last?[ + DockToolsIPC.requestIDKey] as? String ?? "" + } + + static func reply( + world: CLIWorld, status: String = "ok", payload: String = "" + ) -> [AnyHashable: Any] { + [ + DockToolsIPC.requestIDKey: requestID(in: world), + DockToolsIPC.statusKey: status, + DockToolsIPC.payloadKey: payload, + ] + } + + @Test func commandGroupIsRegisteredAtTheRoot() throws { + let parsed = try EdRoot.parseAsRoot(["dock", "status"]) + #expect(CommandCrawler.name(of: type(of: parsed)) == "status") + } + + @Test func statusWorksFromStoredPreferencesWithoutTheApp() async throws { + await CLIProbe.inWorld { world in + world.shared.set(true, forKey: AppStorageKeys.Suites.desk) + world.shared.set(true, forKey: AppStorageKeys.DockTools.enabled) + world.shared.set("optionClick", forKey: AppStorageKeys.DockTools.previewMode) + world.shared.set("cycleWindows", forKey: AppStorageKeys.DockTools.clickAction) + world.shared.set( + "com.example.two,com.example.one", + forKey: AppStorageKeys.DockTools.excludedApps) + + let result = await CLIProbe.capture(["dock", "status", "--json"]) + + #expect(result.code == 0) + #expect(result.object?["enabled"] as? Bool == true) + #expect(result.object?["helperRunning"] as? Bool == false) + #expect(result.object?["previewMode"] as? String == "optionClick") + #expect(result.object?["clickAction"] as? String == "cycleWindows") + #expect( + result.object?["excludedApps"] as? [String] + == ["com.example.one", "com.example.two"]) + } + } + + @Test func liveStatusUsesTheCorrelatedHelperReply() async throws { + await CLIProbe.inWorld { world in + world.helperRunning(true) + let preferences = DockToolsPreferences( + enabled: true, previewMode: .hover, hoverDelay: 0.3, + clickAction: .minimizeFrontWindow, greenButtonMaximizes: true, + quitOnLastWindow: true, excludedBundleIdentifiers: []) + let status = DockToolsStatus( + preferences: preferences, helperRunning: true, + accessibilityGranted: true, screenRecordingGranted: false) + world.answers { name in + name == IPC.Name.dockToolsOperationResult + ? Self.reply(world: world, payload: DockToolsIPC.encode(status)) : nil + } + + let result = await CLIProbe.capture(["dock", "status", "--json"]) + + #expect(result.code == 0) + #expect(result.object?["ready"] as? Bool == true) + #expect(result.object?["previewsAvailable"] as? Bool == false) + #expect(result.object?["greenButtonMaximizes"] as? Bool == true) + #expect(result.object?["quitOnLastWindow"] as? Bool == true) + #expect( + world.posted.first?.info[DockToolsIPC.operationKey] as? String == "status") + } + } + + @Test func windowsPrintsTheRuntimePayloadAsJSON() async throws { + await CLIProbe.inWorld { world in + world.helperRunning(true) + let windows = [ + DockToolsWindow( + id: "123:4", title: "Document", appName: "Example", + bundleIdentifier: "com.example.app", pid: 123, minimized: false) + ] + world.answers { name in + name == IPC.Name.dockToolsOperationResult + ? Self.reply(world: world, payload: DockToolsIPC.encode(windows)) : nil + } + + let result = await CLIProbe.capture([ + "dock", "windows", "com.example.app", "--json", + ]) + + #expect(result.code == 0) + #expect(result.array?.count == 1) + #expect((result.array?.first as? [String: Any])?["title"] as? String == "Document") + #expect( + world.posted.first?.info[DockToolsIPC.bundleIdentifierKey] as? String + == "com.example.app") + } + } + + @Test func actionsExplainWhenTheExtensionIsOff() async throws { + await CLIProbe.inWorld { world in + world.helperRunning(true) + world.answers { name in + name == IPC.Name.dockToolsOperationResult + ? Self.reply(world: world, status: "extensionOff") : nil + } + + let result = await CLIProbe.capture(["dock", "show", "com.example.app"]) + + #expect(result.code == ExitCodes.unavailable) + #expect(result.stderr.contains("Dock Tools extension is off")) + #expect(result.stderr.contains("extensions enable dockTools")) + } + } + @Test func completionUsesBundleIdentifiersAcceptedByDockCommands() async throws { + await CLIProbe.inWorld { _ in + CLIEnvironment.runningApps = { + [ + RunningAppSnapshot( + pid: 123, name: "Sample Editor", bundleID: "com.example.editor", + active: true) + ] + } + let result = await CLIProbe.capture([ + "__complete", "--index", "3", "--", "ed", "dock", "show", "com.example", + ]) + #expect(result.code == 0) + #expect(result.stdout.contains("com.example.editor")) + #expect(!result.stdout.contains("Sample Editor")) + } + } + +} diff --git a/Packages/Edith/Tests/EdithTests/DockToolsModelTests.swift b/Packages/Edith/Tests/EdithTests/DockToolsModelTests.swift new file mode 100644 index 000000000..6200fc86d --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/DockToolsModelTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing + +@testable import EdithKit + +@Suite struct DockToolsModelTests { + @Test func preferencesSanitizeDelayAndExclusions() { + let preferences = DockToolsPreferences( + enabled: true, previewMode: .hover, hoverDelay: 4, + clickAction: .cycleWindows, greenButtonMaximizes: true, + quitOnLastWindow: false, + excludedBundleIdentifiers: ["COM.APP.ONE", "com.app.two"]) + + #expect(preferences.hoverDelay == DockToolsPreferences.hoverDelayRange.upperBound) + #expect(preferences.excludes("com.app.one")) + #expect(preferences.excludes("COM.APP.TWO")) + #expect(!preferences.excludes("com.app.three")) + #expect(DockToolsPreferences.sanitizedHoverDelay(.nan) == 0.3) + } + + @Test func exclusionsRoundTripAsStableCSV() { + let value = DockToolsPreferences.encodedIdentifiers( + ["com.example.B", "com.example.a", "com.example.b"]) + + #expect(value == "com.example.a,com.example.b") + #expect( + DockToolsPreferences.identifiers(" com.example.a,\nCOM.EXAMPLE.B ") + == ["com.example.a", "com.example.b"]) + } + + @Test func clickPolicyOnlyOverridesTheFrontApp() { + #expect( + DockToolsPolicy.shouldHandleDockClick( + action: .cycleWindows, appIsFrontmost: true, excluded: false)) + #expect( + !DockToolsPolicy.shouldHandleDockClick( + action: .standard, appIsFrontmost: true, excluded: false)) + #expect( + !DockToolsPolicy.shouldHandleDockClick( + action: .cycleWindows, appIsFrontmost: false, excluded: false)) + #expect( + !DockToolsPolicy.shouldHandleDockClick( + action: .cycleWindows, appIsFrontmost: true, excluded: true)) + } + + @Test func quitPolicyRequiresARealLastWindowTransition() { + #expect( + DockToolsPolicy.shouldQuit( + enabled: true, hadWindows: true, hasWindows: false, excluded: false, + terminated: false, regularApplication: true)) + #expect( + !DockToolsPolicy.shouldQuit( + enabled: true, hadWindows: false, hasWindows: false, excluded: false, + terminated: false, regularApplication: true)) + #expect( + !DockToolsPolicy.shouldQuit( + enabled: true, hadWindows: true, hasWindows: false, excluded: true, + terminated: false, regularApplication: true)) + } + + @Test func adjacentIndexWrapsInBothDirections() { + #expect(DockToolsPolicy.adjacentIndex(current: 0, count: 3, offset: 1) == 1) + #expect(DockToolsPolicy.adjacentIndex(current: 2, count: 3, offset: 1) == 0) + #expect(DockToolsPolicy.adjacentIndex(current: 0, count: 3, offset: -1) == 2) + #expect(DockToolsPolicy.adjacentIndex(current: nil, count: 3, offset: 1) == 0) + #expect(DockToolsPolicy.adjacentIndex(current: nil, count: 0, offset: 1) == nil) + } + + @Test func statusAndWindowPayloadsRoundTrip() throws { + let preferences = DockToolsPreferences( + enabled: true, previewMode: .optionClick, hoverDelay: 0.3, + clickAction: .cycleWindows, greenButtonMaximizes: true, + quitOnLastWindow: true, excludedBundleIdentifiers: ["com.example.app"]) + let status = DockToolsStatus( + preferences: preferences, helperRunning: true, + accessibilityGranted: true, screenRecordingGranted: false) + let window = DockToolsWindow( + id: "10:20", title: "", appName: "Example", + bundleIdentifier: "com.example.app", pid: 10, minimized: true) + + #expect( + DockToolsIPC.decode(DockToolsStatus.self, from: DockToolsIPC.encode(status)) == status) + #expect( + DockToolsIPC.decode([DockToolsWindow].self, from: DockToolsIPC.encode([window])) + == [window]) + #expect(window.displayTitle == "Example") + #expect(status.ready) + #expect(!status.previewsAvailable) + } +} diff --git a/Packages/Edith/Tests/EdithTests/DockToolsRenderTests.swift b/Packages/Edith/Tests/EdithTests/DockToolsRenderTests.swift new file mode 100644 index 000000000..427fe4c2f --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/DockToolsRenderTests.swift @@ -0,0 +1,65 @@ +import AppKit +import SwiftUI +import Testing + +@testable import Edith +@testable import EdithHelper +@testable import EdithKit + +@Suite @MainActor struct DockToolsRenderTests { + @Test func settingsRenderWithIsolatedPreferences() throws { + _ = TestWindowHost.application + let defaults = SharedDefaults.store + let key = AppStorageKeys.DockTools.enabled + let previous = defaults.object(forKey: key) + defaults.set(true, forKey: key) + defer { defaults.set(previous, forKey: key) } + let view = Form { DockToolsRows() } + .formStyle(.grouped) + .environment(\.automaticViewActionsEnabled, false) + .environment(\.colorScheme, .light) + .frame(width: 680, height: 700) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: 680, height: 700) + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(bitmap.pixelsWide >= 680) + #expect(data.count > 15_000) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + try data.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("dock-tools.png")) + } + } + @Test func windowPreviewsRenderWithSyntheticTitles() throws { + _ = TestWindowHost.application + let windows = [ + DockToolsWindow( + id: "sample:1", title: "Project notes", appName: "Sample Editor", + bundleIdentifier: "com.example.editor", pid: 123, minimized: false), + DockToolsWindow( + id: "sample:2", title: "Reading list", appName: "Sample Editor", + bundleIdentifier: "com.example.editor", pid: 123, minimized: true), + ] + let view = DockToolsPreviewView( + applicationName: "Sample Editor", icon: nil, windows: windows, images: [:], + selectedID: "sample:1", activate: { _ in }, move: { _ in } + ) + .environment(\.colorScheme, .light) + .padding(12) + .frame(width: 470, height: 226) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: 470, height: 226) + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(data.count > 10_000) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + try data.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("dock-preview.png")) + } + } + +} diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift index 25a5fce67..9b6f1289c 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift @@ -89,6 +89,9 @@ import EdithDatabase id: "focusDim", helper: true, machine: false, toolRule: .all, adapter: true, requiredTools: [], optionalTools: []), + MatrixRow( + id: "dockTools", helper: true, machine: false, toolRule: .all, adapter: true, + requiredTools: [], optionalTools: []), MatrixRow( id: "presenter", helper: true, machine: false, toolRule: .all, adapter: true, diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift index 0246f6b4c..7fbf58ac7 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLiveAdapterTests.swift @@ -94,6 +94,27 @@ import EdithCore == .ready("Attention tracking is configured for the selected sources.")) } + @Test func dockToolsRejectsInvalidStoredBehavior() { + let suite = "test.extension-adapter.dock-tools.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + #expect( + ExtensionLiveAdapters.dockToolsReadiness(defaults: defaults) + == .ready("Dock window controls are configured.")) + + defaults.set("unknown", forKey: AppStorageKeys.DockTools.clickAction) + #expect( + ExtensionLiveAdapters.dockToolsReadiness(defaults: defaults) + == .needsSetup("A stored Dock Tools preference is invalid.")) + + defaults.removeObject(forKey: AppStorageKeys.DockTools.clickAction) + defaults.set(4.0, forKey: AppStorageKeys.DockTools.hoverDelay) + #expect( + ExtensionLiveAdapters.dockToolsReadiness(defaults: defaults) + == .needsSetup("A stored Dock Tools preference is invalid.")) + } + @Test func usageDetectsMissingLoadingEmptyReadyAndCorruptData() throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 8f9c05ea5..ae2da1822 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -25,6 +25,7 @@ import Testing "colorPickerEnabled", "keystrokeHighlightEnabled", "focusDimEnabled", + "dockToolsEnabled", "presenterEnabled", "tabMusicEnabled", "downloadsEnabled", @@ -47,7 +48,8 @@ import Testing "usage", "herdr", "quinjet", "companion", "plugins", "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", - "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", + "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "dockTools", + "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", ]) @@ -342,6 +344,7 @@ import Testing "colorPicker": [.screenRecording], "keystrokeHighlight": [.inputMonitoring], "focusDim": [.screenRecording], + "dockTools": [.accessibility], "presenter": [.screenRecording], "music": [], "downloads": [], @@ -371,6 +374,7 @@ import Testing "colorPicker": [], "keystrokeHighlight": [], "focusDim": [], + "dockTools": [.screenRecording], "presenter": [], "music": [], "downloads": [], diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index 41812fc37..9abae9291 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -272,6 +272,7 @@ import Testing "KeystrokeHighlightRows.swift" ), ("focusDim", "FocusDimRows", "enabled", "FocusDimRows.swift"), + ("dockTools", "DockToolsRows", "enabled", "DockToolsRows.swift"), ("presenter", "PresenterRows", "presenterEnabled", "PresenterRows.swift"), ("colorPicker", "ColorPickerRows", "colorPickerEnabled", "ColorPickerRows.swift"), ("emoji", "EmojiRows", "emojiEnabled", "EmojiRows.swift"), diff --git a/docs/cli/README.md b/docs/cli/README.md index 03b326e08..57da2168f 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -52,6 +52,7 @@ report still exits 0, so read `verified`, `state.phase`, `state.runtimePhase`, | [`ed extensions`](./extensions/README.md) | Enabling features, checking readiness, planning setup, verifying live adapters, and recovering failures | | [Keystroke Highlight](./keystroke-highlight/README.md) | Showing key presses on screen for demos and recordings | | [`ed lid-awake`](./lid-awake/README.md) | Closed-lid sessions, battery auto-pause and live state | +| [`ed dock`](./dock/README.md) | Dock Tools readiness, previews, switching and per-app exclusions | | [`ed permissions`](./permissions/README.md) | Inspecting and requesting Edith's macOS permissions | | [`ed usage`](./usage/README.md) | Agent usage: limits, cost, tokens, projects, sources, and machine attribution | | [`ed system`](./system/README.md) | CPU, memory, load, network and mounted volumes | diff --git a/docs/cli/config/README.md b/docs/cli/config/README.md index 75db9f1f0..dfa9cc5fe 100644 --- a/docs/cli/config/README.md +++ b/docs/cli/config/README.md @@ -329,6 +329,18 @@ not here cannot be set, and `import` skips it. | `focusDimHotKeyMods` | int | none | shared | Carbon modifier mask of the focus dim shortcut. | | `focusDimHotKeyLabel` | string | none | shared | Printable label for the focus dim shortcut. | +### `docktools` + +| Key | Type | Default | Scope | What it controls | +| --- | --- | --- | --- | --- | +| `dockToolsEnabled` | bool | `false` | shared | Dock Tools extension: previews and Dock window controls. | +| `dockToolsPreviewMode` | string: `hover`, `optionClick` | `hover` | shared | How Dock window previews open. | +| `dockToolsHoverDelay` | number | `0.3` | shared | Seconds before a Dock hover preview opens. | +| `dockToolsClickAction` | string: `standard`, `cycleWindows`, `minimizeFrontWindow` | `standard` | shared | Action for clicking the active app in the Dock. | +| `dockToolsGreenButtonMaximizes` | bool | `false` | shared | Use the green window button to maximize without entering full screen. | +| `dockToolsQuitOnLastWindow` | bool | `false` | shared | Quit regular apps when their last window closes. | +| `dockToolsExcludedApps` | csv | none | shared | Bundle identifiers excluded from Dock Tools. | + ### `presenter` | Key | Type | Default | Scope | What it controls | @@ -463,8 +475,8 @@ as writable objects, so a document that validates against the schema can still contain a key `import` will skip. **`ed schema` is the machine-readable half of this page.** It prints a JSON -Schema for the `import` document: the 191 writable keys as properties, -`additionalProperties: false`, the `enum` for each of the 19 keys with an +Schema for the `import` document: the 198 writable keys as properties, +`additionalProperties: false`, the `enum` for each of the 21 keys with an allowed list, the default where the catalogue declares one, and `x-group`, `x-scope` and `x-format` annotations. A `csv` setting is typed as a string with `"x-format": "comma-separated"`; a `stringList` is an array of strings. @@ -474,7 +486,7 @@ allowed list, the default where the catalogue declares one, and `x-group`, `tabSystemEnabled`, `tabMachinesEnabled`, `tabCompanionEnabled`, `menuBarSystemStats`, `micMuteEnabled`, `lidAwakeEnabled`, `tabMusicEnabled`, `tabCalendarEnabled`, `notchShelfEnabled`, `clipboardEnabled`, -`focusDimEnabled`, `presenterEnabled` and `colorPickerEnabled` are the same +`focusDimEnabled`, `dockToolsEnabled`, `presenterEnabled` and `colorPickerEnabled` are the same switches `ed extensions enable` and `ed extensions disable` flip. Prefer those verbs: they know which macOS permission the extension needs and say which one is missing, where diff --git a/docs/cli/dock/README.md b/docs/cli/dock/README.md new file mode 100644 index 000000000..a9a97c3de --- /dev/null +++ b/docs/cli/dock/README.md @@ -0,0 +1,26 @@ +# `ed dock` + +`ed dock` reports Dock Tools readiness and controls the preview surface from the shell. + +```sh +ed dock status +ed dock status --json +ed dock windows com.apple.Safari --json +ed dock show com.apple.Safari +``` + +Dock Tools requires Accessibility to identify Dock icons and focus app windows. Screen +Recording is optional and adds live window thumbnails. Without it, previews remain useful +with app icons and window titles. + +The extension supports hover previews or deliberate Option-click activation. Clicking the +active app can retain standard macOS behavior, cycle through that app's windows, or minimize +its front window. The green window button and quit-on-last-window policies are opt-in. + +Per-app exclusions use bundle identifiers and apply to every Dock Tools behavior. Configure +them in the extension settings or with `ed config set dockToolsExcludedApps`. + +Use `ed extensions enable dockTools` to turn the extension on and `ed permissions refresh` +after changing macOS Privacy & Security settings. + +- [`ed`](../README.md), the complete command line reference diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index bd9c2c93b..9cb0e485d 100644 --- a/docs/cli/extensions/README.md +++ b/docs/cli/extensions/README.md @@ -33,10 +33,10 @@ enables immediately and reports missing grants in plain text or JSON. | `ed extensions enable ` | Turns one on, and names on stderr any required permission still missing | | `ed extensions disable ` | Turns one off | | `ed extensions info ` | Describes one: name, summary, key, group, state, permissions | -| `ed extensions status [id]` | Summarises readiness for one extension or all twenty-two | +| `ed extensions status [id]` | Summarises readiness for one extension or all registered extensions | | `ed extensions setup ` | Enables one and reports the setup that remains | | `ed extensions verify ` | Runs every readiness check for one extension | -| `ed extensions doctor [id]` | Diagnoses one extension or all twenty-two, with recovery commands | +| `ed extensions doctor [id]` | Diagnoses one extension or all registered extensions, with recovery commands | The Extensions pane and each extension settings modal use these same typed read operations. Marketplace browsing maps to `ls`, opening a modal maps to `info`, @@ -52,7 +52,7 @@ operations as their command-line equivalents. ## The registry `ExtensionRegistry.entries` in EdithCore is the single list every command here -walks, and its order is the order `ls` prints. Twenty-two entries, in this order: +walks, and its order is the order `ls` prints. Entries appear in this order: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -76,11 +76,12 @@ walks, and its order is the order `ls` prints. Twenty-two entries, in this order | `clipboard` | Clipboard | Utilities | Clipboard history with instant paste | | `keystrokeHighlight` | Keystroke Highlight | Utilities | Shows each key press on screen for demos | | `focusDim` | Focus Dim | Utilities | Dims everything behind your active app | +| `dockTools` | Dock Tools | Utilities | Window previews, faster switching, and smarter Dock behavior | | `presenter` | Presenter | Utilities | Blurs sensitive numbers while sharing your screen | | `emoji` | Emoji Picker | Utilities | Every macOS emoji on a hotkey | | `colorPicker` | Color Picker | Utilities | System loupe on a hotkey, sampled color to your clipboard | -The same twenty-two, with what each one is made of. `Key` is the preference the app +The same entries, with what each one is made of. `Key` is the preference the app reads, and the key `ed config` writes for the same feature. `Featured` marks the eleven the welcome tour shows before you ask it for all of them. @@ -105,6 +106,7 @@ eleven the welcome tour shows before you ask it for all of them. | `clipboard` | `clipboardEnabled` | yes | none | `accessibility` | none | none | | `keystrokeHighlight` | `keystrokeHighlightEnabled` | yes | `inputMonitoring` | none | none | none | | `focusDim` | `focusDimEnabled` | no | `screenRecording` | none | none | none | +| `dockTools` | `dockToolsEnabled` | no | `accessibility` | `screenRecording` | none | none | | `presenter` | `presenterEnabled` | no | `screenRecording` | none | none | none | | `emoji` | `emojiEnabled` | no | none | `accessibility` | none | none | | `colorPicker` | `colorPickerEnabled` | no | `screenRecording` | none | none | none | @@ -135,6 +137,7 @@ the current platform, and which missing implementations merely degrade it: | `clipboard` | `clipboardHistory` | `globalPaste`, `globalShortcuts` | | `keystrokeHighlight` | `keystrokeObservation` | none | | `focusDim` | `windowDimming` | none | +| `dockTools` | `dockControl` | `windowPreviews` | | `presenter` | `screenShareDetection` | none | | `emoji` | `emojiInsertion` | `globalShortcuts` | | `colorPicker` | `screenColorSampling` | `globalShortcuts` | diff --git a/docs/cli/extensions/disable.md b/docs/cli/extensions/disable.md index aeb88a80d..3209b74a3 100644 --- a/docs/cli/extensions/disable.md +++ b/docs/cli/extensions/disable.md @@ -8,7 +8,7 @@ ed extensions disable [--json] | Argument | Type / values | Default | What it does | | --- | --- | --- | --- | -| `id` | one of the twenty-two ids, or a defaults key | required | The extension to turn off | +| `id` | a registered id, or a defaults key | required | The extension to turn off | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/doctor.md b/docs/cli/extensions/doctor.md index a566258dd..e9978a67f 100644 --- a/docs/cli/extensions/doctor.md +++ b/docs/cli/extensions/doctor.md @@ -1,6 +1,6 @@ # `ed extensions doctor` -Diagnoses setup and runtime problems for one extension or all twenty-two. +Diagnoses setup and runtime problems for one extension or all registered extensions. ``` ed extensions doctor [] [--json] @@ -15,7 +15,7 @@ Checks cover the stored enabled state, required and optional permissions, required and optional tools, helper availability, platform capabilities, configured machines and supported backend or session health. Checks that do not apply are omitted, and checks behind a disabled extension are skipped. -All twenty-two extensions have an explicit live adapter. A missing adapter is +All registered extensions have an explicit live adapter. A missing adapter is reported as a runtime error instead of silently falling back to helper availability. diff --git a/docs/cli/extensions/enable.md b/docs/cli/extensions/enable.md index 5b8e0441a..8ac4b768b 100644 --- a/docs/cli/extensions/enable.md +++ b/docs/cli/extensions/enable.md @@ -8,7 +8,7 @@ ed extensions enable [--json] | Argument | Type / values | Default | What it does | | --- | --- | --- | --- | -| `id` | one of the twenty-two ids, or a defaults key | required | The extension to turn on | +| `id` | a registered id, or a defaults key | required | The extension to turn on | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/info.md b/docs/cli/extensions/info.md index 377c4ed07..7e3ae673b 100644 --- a/docs/cli/extensions/info.md +++ b/docs/cli/extensions/info.md @@ -8,7 +8,7 @@ ed extensions info [--json] | Argument | Type / values | Default | What it does | | --- | --- | --- | --- | -| `id` | one of the twenty-two ids, or a defaults key | required | The extension to describe | +| `id` | a registered id, or a defaults key | required | The extension to describe | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/ls.md b/docs/cli/extensions/ls.md index 276c28288..62affdd44 100644 --- a/docs/cli/extensions/ls.md +++ b/docs/cli/extensions/ls.md @@ -32,6 +32,7 @@ calendar off Media Calendar notchShelf off Media Notch Shelf clipboard on Utilities Clipboard focusDim off Utilities Focus Dim +dockTools off Utilities Dock Tools presenter off Utilities Presenter emoji off Utilities Emoji Picker colorPicker on Utilities Color Picker diff --git a/docs/cli/extensions/runtime-detection.md b/docs/cli/extensions/runtime-detection.md index 56f46de90..16ffc4a7d 100644 --- a/docs/cli/extensions/runtime-detection.md +++ b/docs/cli/extensions/runtime-detection.md @@ -40,6 +40,7 @@ system query failure produces `failed` with runtime phase `error`. | Clipboard | decodable JSONL index | entry count and missing blob payloads | `ed clipboard stats --json`; `ed clipboard ls --json` | | Keystroke Highlight | enabled extension and active or paused state | listen-only keyboard event monitor while active | `ed config set keystrokeHighlightActive true`; `ed permissions request inputMonitoring` | | Focus Dim | finite intensity and animation values plus a valid display mode | active display count | `ed config ls --group focusdim --json`; `ed permissions refresh` | +| Dock Tools | valid preview and Dock click preferences | Dock access plus optional window preview capture | `ed dock status --json`; `ed permissions refresh` | | Presenter | at least one protected data category and coherent detector settings | manual protection or automatic detectors can operate | `ed presenter status --json`; `ed config ls --group presenter --json` | | Emoji Picker | bundled emoji catalog and valid picker settings | recent emoji usage | `ed emoji ls --json`; `ed permissions refresh` | | Color Picker | valid copy format, color profile, history limit, and decodable history | active display and saved sample count | `ed color ls --json`; `ed permissions refresh` | diff --git a/docs/cli/extensions/status.md b/docs/cli/extensions/status.md index 90650cf32..4fa56dc6b 100644 --- a/docs/cli/extensions/status.md +++ b/docs/cli/extensions/status.md @@ -8,7 +8,7 @@ ed extensions status [] [--json] | Argument | Type / values | Default | What it does | | --- | --- | --- | --- | -| `id` | one of the twenty-two ids, or a defaults key | all extensions | Limit the report to one extension | +| `id` | a registered id, or a defaults key | all extensions | Limit the report to one extension | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/getting-started/guide.md b/docs/cli/getting-started/guide.md index 121749670..58d3b4ede 100644 --- a/docs/cli/getting-started/guide.md +++ b/docs/cli/getting-started/guide.md @@ -36,7 +36,7 @@ ed guide | less `ed guide agent` prints a section you can paste into a repository instruction file so an agent working there knows `ed` exists, can discover the complete parser tree, can use structured output where advertised, and can inspect, set -up, verify, and recover all twenty-two extensions noninteractively. +up, verify, and recover all registered extensions noninteractively. Any topic other than `agent` exits 3 and lists the discovery forms: