Skip to content
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions Packages/Edith/Sources/EdithCLI/CommandTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
176 changes: 176 additions & 0 deletions Packages/Edith/Sources/EdithCLI/Commands/DockToolsCommands.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
11 changes: 8 additions & 3 deletions Packages/Edith/Sources/EdithCLI/Commands/Root.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public struct EdRoot: AsyncParsableCommand {
MCPCommand.self,
ExtensionsCommand.self,
LidAwakeCLICommand.self,
DockCommand.self,
PermissionsCommand.self,
UsageCommand.self,
SystemCommand.self,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading