From c94e162e89aa96529c5a0bdbc6d287e76e8f934d Mon Sep 17 00:00:00 2001 From: Pulkit Date: Thu, 27 Aug 2026 23:05:15 +0530 Subject: [PATCH 01/11] feat: add capture tools foundation --- .../Extensions/Views/ExtensionPreview.swift | 31 ++++ .../Settings/Views/CaptureToolsRows.swift | 110 +++++++++++ .../Settings/Views/ExtensionsPane.swift | 2 + .../Settings/Views/ShortcutsPane.swift | 12 +- .../Edith/Shared/ConfigurationBinding.swift | 4 + .../Edith/Sources/EdithCLI/CommandTree.swift | 10 + .../EdithCLI/Commands/CaptureCommands.swift | 61 ++++++ .../Sources/EdithCLI/Commands/Root.swift | 1 + .../EdithCore/ExtensionLifecycle.swift | 34 ++++ .../Sources/EdithCore/ExtensionRegistry.swift | 7 + .../EdithCore/PlatformCapabilities.swift | 2 + .../Core/Application/AppServices.swift | 12 ++ .../Core/Application/EdithHelperApp.swift | 27 +++ .../ViewModels/CaptureToolsStore.swift | 170 +++++++++++++++++ .../Views/CapturePreviewController.swift | 164 ++++++++++++++++ .../Settings/Services/SettingsBackup.swift | 12 ++ .../Core/Defaults/AppStorageKeys.swift | 15 ++ .../Edith/Sources/EdithKit/Core/IPC/IPC.swift | 4 + .../Core/Operations/ConfigCatalog.swift | 45 ++++- .../Operations/UserOperationCatalog.swift | 14 ++ .../Models/CaptureRecognition.swift | 175 ++++++++++++++++++ .../Services/CaptureScreenshotSession.swift | 124 +++++++++++++ .../Services/CaptureToolOperation.swift | 41 ++++ .../Extensions/Models/ExtensionRegistry.swift | 4 +- .../Extensions/Models/FeatureGates.swift | 7 +- .../Services/ExtensionDefaultsMigration.swift | 1 + .../Services/ExtensionLifecycleProbe.swift | 2 + .../Services/ExtensionLiveAdapters.swift | 21 ++- .../Services/ExtensionMutationCenter.swift | 1 + .../Tests/EdithTests/AppServicesTests.swift | 1 + .../Tests/EdithTests/CLICaptureTests.swift | 19 ++ .../Tests/EdithTests/CLICompletionTests.swift | 5 + .../EdithTests/CaptureRecognitionTests.swift | 59 ++++++ .../CaptureToolOperationTests.swift | 24 +++ .../ExtensionLifecycleProbeTests.swift | 3 + .../EdithTests/ExtensionRegistryTests.swift | 8 +- .../ExtensionRuntimeStateTests.swift | 1 + .../Tests/EdithTests/FeatureGatesTests.swift | 8 +- .../EdithTests/PermissionsStatusTests.swift | 2 +- README.md | 4 + docs/cli/README.md | 1 + docs/cli/capture/README.md | 41 ++++ docs/cli/capture/read.md | 34 ++++ docs/cli/capture/screenshot.md | 31 ++++ docs/cli/config/README.md | 18 +- docs/cli/extensions/README.md | 11 +- docs/cli/extensions/disable.md | 2 +- docs/cli/extensions/doctor.md | 4 +- docs/cli/extensions/enable.md | 4 +- docs/cli/extensions/info.md | 2 +- docs/cli/extensions/runtime-detection.md | 1 + docs/cli/extensions/status.md | 2 +- docs/cli/getting-started/guide.md | 2 +- docs/cli/permissions/README.md | 2 +- docs/cli/permissions/ls.md | 7 +- 55 files changed, 1378 insertions(+), 31 deletions(-) create mode 100644 Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift create mode 100644 Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift create mode 100644 Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift create mode 100644 Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift create mode 100644 Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift create mode 100644 Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift create mode 100644 Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureToolOperation.swift create mode 100644 Packages/Edith/Tests/EdithTests/CLICaptureTests.swift create mode 100644 Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift create mode 100644 Packages/Edith/Tests/EdithTests/CaptureToolOperationTests.swift create mode 100644 docs/cli/capture/README.md create mode 100644 docs/cli/capture/read.md create mode 100644 docs/cli/capture/screenshot.md diff --git a/Packages/Edith/Sources/Edith/Features/Extensions/Views/ExtensionPreview.swift b/Packages/Edith/Sources/Edith/Features/Extensions/Views/ExtensionPreview.swift index 3dad5bdce..cf31f5ce9 100644 --- a/Packages/Edith/Sources/Edith/Features/Extensions/Views/ExtensionPreview.swift +++ b/Packages/Edith/Sources/Edith/Features/Extensions/Views/ExtensionPreview.swift @@ -38,6 +38,7 @@ struct ExtensionPreview: View { case "focusDim": focusDimPreview(phase: phase) case "presenter": presenterPreview(phase: phase) case "colorPicker": colorPickerPreview(phase: phase) + case "captureTools": captureToolsPreview(phase: phase) default: staticPreview } } @@ -484,6 +485,36 @@ struct ExtensionPreview: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private func captureToolsPreview(phase: Double) -> some View { + let progress = loopProgress(phase, duration: 3.2) + let scan = CGFloat(abs(progress * 2 - 1)) + return ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: UIScale.pt(8)) + .fill(DashSkin.paper2(dark)) + .overlay { + VStack(alignment: .leading, spacing: UIScale.pt(5)) { + Capsule().fill(DashSkin.inkFaint(dark)).frame(width: 72, height: 4) + Capsule().fill(DashSkin.inkFaint(dark)).frame(width: 96, height: 4) + Capsule().fill(DashSkin.inkFaint(dark)).frame(width: 54, height: 4) + } + } + Rectangle() + .fill(DashSkin.accent(dark).opacity(0.75)) + .frame(height: UIScale.pt(2)) + .offset(y: UIScale.pt(8 + scan * 34)) + Image(systemName: "qrcode") + .font(.system(size: UIScale.pt(13), weight: .medium)) + .foregroundStyle(DashSkin.accent(dark)) + .padding(UIScale.pt(6)) + } + .frame(width: UIScale.pt(118), height: UIScale.pt(52)) + .overlay { + RoundedRectangle(cornerRadius: UIScale.pt(8)).strokeBorder( + DashSkin.lineStrong(dark)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func loopProgress(_ phase: Double, duration: Double) -> Double { phase.truncatingRemainder(dividingBy: duration) / duration } diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift new file mode 100644 index 000000000..e5af36fc8 --- /dev/null +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift @@ -0,0 +1,110 @@ +import AppKit +import EdithKit +import SwiftUI + +struct CaptureToolsRows: View { + @AppStorage(AppStorageKeys.Capture.enabled, store: SharedDefaults.store) private + var captureToolsEnabled = false + @AppStorage(AppStorageKeys.Capture.copyMode, store: SharedDefaults.store) private + var copyMode: CaptureCopyMode = .smart + @AppStorage(AppStorageKeys.Capture.detectCodes, store: SharedDefaults.store) private + var detectCodes = true + @AppStorage(AppStorageKeys.Capture.historySize, store: SharedDefaults.store) private + var historySize = 10 + @AppStorage(AppStorageKeys.Capture.saveScreenshots, store: SharedDefaults.store) private + var saveScreenshots = false + @State private var history: [CaptureRecognition] = [] + + var body: some View { + Group { + Section("Capture") { + HStack { + Button("Read screen") { + _ = CaptureToolOperationExecution.request(.read) + } + .buttonStyle(.edith(.primary)) + Button("Screenshot") { + _ = CaptureToolOperationExecution.request(.screenshot) + } + .buttonStyle(.edith(.secondary)) + } + LabeledContent("Read shortcut") { + HotKeyRecorderControl( + keyPrefix: "captureReadHotKey", defaultLabel: "⌃⌥⌘R") + } + LabeledContent("Screenshot shortcut") { + HotKeyRecorderControl( + keyPrefix: "captureScreenshotHotKey", defaultLabel: "⌃⌥⌘S") + } + } + + Section("Recognition") { + Picker( + "Copy after reading", + selection: $copyMode.configured(AppStorageKeys.Capture.copyMode) + ) { + ForEach(CaptureCopyMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + Toggle( + "Read QR and matrix codes", + isOn: $detectCodes.configured(AppStorageKeys.Capture.detectCodes)) + Toggle( + "Keep read screenshots in Pictures", + isOn: $saveScreenshots.configured(AppStorageKeys.Capture.saveScreenshots)) + Stepper( + "Recent reads: \(historySize)", + value: $historySize.configured(AppStorageKeys.Capture.historySize), in: 1...25) + Text( + "Recognition runs offline with macOS Vision. Screen images are temporary unless you save them or enable screenshot retention." + ) + .settingsCaption() + } + + Section("Related tools") { + Text( + "Use Color Picker for exact pixel colors. The Notch Shelf owns the camera preview for mirror checks." + ) + .settingsCaption() + } + + if !history.isEmpty { + Section("Recent reads") { + ForEach(history.prefix(historySize)) { capture in + Button { + copy(capture.output(for: copyMode)) + } label: { + HStack { + Image( + systemName: capture.codes.isEmpty + ? "text.alignleft" : "qrcode.viewfinder" + ) + .foregroundStyle(.secondary) + Text(capture.output(for: .smart)) + .lineLimit(2) + Spacer() + Image(systemName: "doc.on.doc") + .foregroundStyle(.secondary) + } + } + .buttonStyle(.edith(.row)) + } + } + } + } + .disabled(!captureToolsEnabled) + .opacity(captureToolsEnabled ? 1 : 0.5) + .onAppear { history = CaptureHistoryStore.load() } + .onReceive( + DistributedNotificationCenter.default().publisher(for: IPC.Name.settingsChanged) + ) { _ in history = CaptureHistoryStore.load() } + } + + private func copy(_ value: String) { + guard !value.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + IPC.post(IPC.Name.clipboardChanged) + } +} diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift index 00e681515..d3d555fea 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift @@ -576,6 +576,7 @@ private struct ExtensionSettingsSheet: View { case "lidAwake": 400 case "music": 460 case "focusDim", "colorPicker": 430 + case "captureTools": 560 case "system": 500 case "notchShelf", "presenter": 580 default: 620 @@ -932,6 +933,7 @@ private struct ExtensionDetailRows: View { case .focusDim: FocusDimRows() case .presenter: PresenterRows() case .colorPicker: ColorPickerRows() + case .captureTools: CaptureToolsRows() } } else { Section("Controls") { diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift index d65c718c2..e8b3bc747 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift @@ -10,6 +10,8 @@ struct ShortcutsSettingsPane: View { @AppStorage(AppStorageKeys.ColorPicker.enabled, store: SharedDefaults.store) private var colorPickerEnabled = false + @AppStorage(AppStorageKeys.Capture.enabled, store: SharedDefaults.store) private + var captureToolsEnabled = false @AppStorage(FocusDimState.enabledKey, store: SharedDefaults.store) private var focusDimEnabled = false @AppStorage(AppStorageKeys.Mic.muteEnabled, store: SharedDefaults.store) private @@ -21,7 +23,7 @@ struct ShortcutsSettingsPane: View { private var extensionShortcuts: [ExtensionShortcut] { ExtensionShortcutVisibility.visible( clipboard: clipboardEnabled, micMute: micMuteEnabled, focusDim: focusDimEnabled, - presenter: presenterEnabled, colorPicker: colorPickerEnabled) + colorPicker: colorPickerEnabled, captureTools: captureToolsEnabled) } var body: some View { @@ -119,6 +121,14 @@ struct ShortcutsSettingsPane: View { shortcutRow( "Pick a color", subtitle: "Summons the color picker loupe", keyPrefix: "colorPickerHotKey", defaultLabel: "⌃⌥⌘C") + case .captureRead: + shortcutRow( + "Read screen", subtitle: "Recognizes text and codes in a selected region", + keyPrefix: "captureReadHotKey", defaultLabel: "⌃⌥⌘R") + case .captureScreenshot: + shortcutRow( + "Quick screenshot", subtitle: "Captures a selected region into a preview", + keyPrefix: "captureScreenshotHotKey", defaultLabel: "⌃⌥⌘S") } } } diff --git a/Packages/Edith/Sources/Edith/Shared/ConfigurationBinding.swift b/Packages/Edith/Sources/Edith/Shared/ConfigurationBinding.swift index 5e659d1d5..4fba5daf4 100644 --- a/Packages/Edith/Sources/Edith/Shared/ConfigurationBinding.swift +++ b/Packages/Edith/Sources/Edith/Shared/ConfigurationBinding.swift @@ -33,6 +33,10 @@ extension FocusDimDisplayMode: ConfigurationBindingValue { var configurationValue: JSONValue { .string(rawValue) } } +extension CaptureCopyMode: ConfigurationBindingValue { + var configurationValue: JSONValue { .string(rawValue) } +} + extension Binding where Value: ConfigurationBindingValue { func configured( _ key: String, executor: ConfigurationExecutor = .application diff --git a/Packages/Edith/Sources/EdithCLI/CommandTree.swift b/Packages/Edith/Sources/EdithCLI/CommandTree.swift index 4b14e4797..cdd50015d 100644 --- a/Packages/Edith/Sources/EdithCLI/CommandTree.swift +++ b/Packages/Edith/Sources/EdithCLI/CommandTree.swift @@ -612,6 +612,16 @@ public enum CommandTree { "clear", "Forget every picked colour.", options: ["--json", "--yes"], destructivePolicy: .previewThenYes), ]), + CommandNode( + "capture", "Offline screen recognition and quick screenshots.", + children: [ + CommandNode( + "read", "Select screen content and copy recognized text or codes.", + options: common), + CommandNode( + "screenshot", "Select screen content for a quick preview.", + options: common), + ]), CommandNode( "shelf", "The files parked on the notch shelf.", children: [ diff --git a/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift b/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift new file mode 100644 index 000000000..4a2f101c5 --- /dev/null +++ b/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift @@ -0,0 +1,61 @@ +import ArgumentParser +import EdithKit + +struct CaptureCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "capture", + abstract: "Offline screen recognition and quick screenshots.", + subcommands: [CaptureReadCommand.self, CaptureScreenshotCommand.self]) +} + +enum CaptureCommandBridge { + static func request(_ operation: CaptureToolOperation, json: Bool) async throws { + try await execute { + guard + CLIEnvironment.sharedDefaults.object(forKey: AppStorageKeys.Capture.enabled) + as? Bool == true + else { + throw CLIFailure.unavailable( + "the Capture Tools extension is off", + hint: "run `ed extensions enable captureTools`, then retry") + } + try AppBridge.requireHelper("capturing the screen") + let descriptor = CaptureToolOperationExecution.request(operation) { + AppBridge.post($0) + } + guard !json else { + CLIOut.json( + .object([ + "operation": .string(descriptor.id.rawValue), + "requested": .bool(true), + ])) + return + } + CLIOut.out(operation == .read ? "screen read requested" : "screenshot requested") + } + } +} + +struct CaptureReadCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "read", abstract: "Select screen content and copy recognized text or codes.") + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + try await CaptureCommandBridge.request(.read, json: json) + } +} + +struct CaptureScreenshotCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "screenshot", abstract: "Select screen content for a quick preview.") + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + try await CaptureCommandBridge.request(.screenshot, json: json) + } +} diff --git a/Packages/Edith/Sources/EdithCLI/Commands/Root.swift b/Packages/Edith/Sources/EdithCLI/Commands/Root.swift index d6215ecd7..f2b90a885 100644 --- a/Packages/Edith/Sources/EdithCLI/Commands/Root.swift +++ b/Packages/Edith/Sources/EdithCLI/Commands/Root.swift @@ -62,6 +62,7 @@ public struct EdRoot: AsyncParsableCommand { AppsCommand.self, ToolsCommand.self, ColorCommand.self, + CaptureCommand.self, ShelfCommand.self, CleanerCommand.self, QuinjetCommand.self, diff --git a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift index b4085397c..07190c580 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift @@ -685,6 +685,40 @@ public enum ExtensionLifecycleCatalog { "history", "Read sampled colors", "Confirm the color history repository responds.", "ed color ls --json") ]), + descriptor( + "captureTools", "Read text and codes from the screen without sending pixels anywhere.", + workflows: [ + instruction( + "read", "Read the screen", + "Select a region, recognize its text and codes, and copy the result.", + "ed capture read"), + instruction( + "screenshot", "Take a quick screenshot", + "Select a region and open a lightweight copy and save preview.", + "ed capture screenshot"), + ], + prerequisites: [ + instruction( + "permission", "Grant Screen Recording", + "Screen Recording lets Edith capture the region you select.", + "ed permissions request screenRecording") + ], + examples: [ + "ed extensions enable captureTools", "ed capture read --json", + "ed capture screenshot --json", + ], + docs: [documentation("guide", "Capture Tools guide", "docs/cli/capture/README.md")], + recovery: [ + instruction( + "permission", "Refresh capture access", + "Refresh the mirrored Screen Recording grant.", "ed permissions refresh") + ], + verification: [ + instruction( + "config", "Inspect capture settings", + "Confirm recognition, history, and screenshot preferences.", + "ed config ls --group capture --json") + ]), ] public static let byID = Dictionary(uniqueKeysWithValues: descriptors.map { ($0.id, $0) }) diff --git a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift index ccd3be244..a6e4a0b92 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift @@ -200,5 +200,12 @@ public enum ExtensionRegistry { symbolName: "eyedropper", group: .utilities, featured: false, defaultsKey: "colorPickerEnabled", requiredCapabilities: [.screenColorSampling], optionalCapabilities: [.globalShortcuts]), + ExtensionRegistryEntry( + id: "captureTools", title: "Capture Tools", + subtitle: "Offline screen OCR, QR recognition, and quick screenshots.", + symbolName: "viewfinder", group: .utilities, featured: false, + defaultsKey: "captureToolsEnabled", + requiredCapabilities: [.screenContentRecognition], + optionalCapabilities: [.globalShortcuts]), ] } diff --git a/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift b/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift index ae3311276..76d520393 100644 --- a/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift +++ b/Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift @@ -22,6 +22,7 @@ public enum PlatformCapability: String, CaseIterable, Codable, Hashable, Sendabl case preventSleep case runningApplications case screenColorSampling + case screenContentRecognition case screenShareDetection case systemMetrics case usageCollection @@ -93,6 +94,7 @@ public struct PlatformCapabilities: Equatable, Sendable { .inputSuppression: .permissionRequired, .notifications: .permissionRequired, .screenColorSampling: .permissionRequired, + .screenContentRecognition: .permissionRequired, .screenShareDetection: .permissionRequired, .windowDimming: .permissionRequired, ])) diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift index a399eebd9..084e298b1 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift @@ -11,6 +11,7 @@ final class AppServices { private(set) var calendar: CalendarStore? private(set) var notchShelf: NotchShelfController? private(set) var colorPicker: ColorPickerStore? + private(set) var captureTools: CaptureToolsStore? private(set) var clipboard: ClipboardStore? private(set) var focusDim: FocusDimEngine? private(set) var presenter: PresenterDetector? @@ -100,6 +101,8 @@ final class AppServices { func prepareForTermination() async { startup.cancel() terminating = true + captureTools?.shutdown() + captureTools = nil if #available(macOS 14.4, *) { MixerEngine.shared.shutdown() } await lidAwake?.shutdownForTermination() await lidAwakeRestorationGate.wait() @@ -277,6 +280,15 @@ final class AppServices { } colorPicker?.registerHotKey() + let captureToolsOn = + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.enabled) as? Bool ?? false + if captureToolsOn, captureTools == nil { captureTools = CaptureToolsStore() } + if !captureToolsOn, let store = captureTools { + store.shutdown() + captureTools = nil + } + captureTools?.registerHotKeys() + let clipboardOn = SharedDefaults.store.object(forKey: AppStorageKeys.Clipboard.enabled) as? Bool ?? false if clipboardOn { diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift index 5dee0c82f..06de83e32 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift @@ -295,6 +295,8 @@ enum GlobalHotKey { static let colorPicker: UInt32 = 5 static let micMute: UInt32 = 6 static let presenterToggle: UInt32 = 7 + static let captureRead: UInt32 = 20 + static let captureScreenshot: UInt32 = 21 } fileprivate static var refs: [UInt32: EventHotKeyRef] = [:] @@ -649,6 +651,31 @@ struct RootView: View { } } } + if let captureTools = services.captureTools { + Menu { + Button { + dismissPanel() + captureTools.start(.read) + } label: { + Label("Read screen", systemImage: "text.viewfinder") + } + Button { + dismissPanel() + captureTools.start(.screenshot) + } label: { + Label("Screenshot", systemImage: "camera.viewfinder") + } + } label: { + Image(systemName: captureTools.inProgress ? "xmark" : "viewfinder") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help( + captureTools.inProgress + ? "Cancel capture" : "Capture tools (\(CaptureToolsHotKeys.readLabel))") + } if focusDimEnabled { Button { toggleFocusDim() diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift new file mode 100644 index 000000000..5e00f156f --- /dev/null +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift @@ -0,0 +1,170 @@ +import AppKit +import Carbon.HIToolbox +import EdithKit +import Observation + +@MainActor +@Observable +final class CaptureToolsStore: FeatureModule { + private(set) var history: [CaptureRecognition] = [] + private(set) var inProgress = false + private(set) var errorMessage: String? + @ObservationIgnored private let session = CaptureScreenshotSession() + @ObservationIgnored private var task: Task? + @ObservationIgnored private var readObserver: NSObjectProtocol? + @ObservationIgnored private var screenshotObserver: NSObjectProtocol? + @ObservationIgnored private var preview: CapturePreviewController? + @ObservationIgnored private var generation = 0 + + init() { + history = CaptureHistoryStore.load() + readObserver = IPC.observe(IPC.Name.requestScreenRead) { [weak self] in + self?.start(.read) + } + screenshotObserver = IPC.observe(IPC.Name.requestScreenshot) { [weak self] in + self?.start(.screenshot) + } + } + + func registerHotKeys() { + GlobalHotKey.set( + id: GlobalHotKey.ID.captureRead, keyCode: CaptureToolsHotKeys.readCode, + modifiers: CaptureToolsHotKeys.readMods + ) { [weak self] in self?.start(.read) } + GlobalHotKey.set( + id: GlobalHotKey.ID.captureScreenshot, keyCode: CaptureToolsHotKeys.screenshotCode, + modifiers: CaptureToolsHotKeys.screenshotMods + ) { [weak self] in self?.start(.screenshot) } + } + + func start(_ operation: CaptureToolOperation) { + if inProgress { + task?.cancel() + session.cancel() + return + } + generation &+= 1 + let token = generation + inProgress = true + errorMessage = nil + task = Task { [weak self] in + await self?.run(operation, token: token) + } + } + + func shutdown() { + generation &+= 1 + task?.cancel() + task = nil + session.cancel() + preview?.close() + preview = nil + GlobalHotKey.clear(id: GlobalHotKey.ID.captureRead) + GlobalHotKey.clear(id: GlobalHotKey.ID.captureScreenshot) + if let readObserver { IPC.stopObserving(readObserver) } + if let screenshotObserver { IPC.stopObserving(screenshotObserver) } + readObserver = nil + screenshotObserver = nil + inProgress = false + } + + private func run(_ operation: CaptureToolOperation, token: Int) async { + var temporaryURL: URL? + defer { + if let temporaryURL { try? FileManager.default.removeItem(at: temporaryURL) } + if generation == token { + inProgress = false + task = nil + } + } + do { + let url = try await session.capture() + temporaryURL = url + guard !Task.isCancelled, generation == token else { return } + let data = try Data(contentsOf: url) + let image = try CaptureScreenshotImage.load(url) + let detectsCodes = + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.detectCodes) as? Bool + ?? true + let recognition = try await Task.detached(priority: .userInitiated) { + try CaptureRecognizer.recognize(image, detectCodes: detectsCodes) + }.value + guard !Task.isCancelled, generation == token else { return } + let result = try finalize(recognition, data: data, operation: operation) + let copied = operation == .read && copy(result) + let sourceImage = NSImage(data: data) ?? NSImage(size: .zero) + preview?.close() + preview = CapturePreviewController( + image: sourceImage, pngData: data, recognition: result, + operation: operation, copiedResult: copied) + preview?.show() + } catch CaptureScreenshotError.cancelled { + errorMessage = nil + } catch is CancellationError { + errorMessage = nil + } catch { + guard generation == token else { return } + errorMessage = error.localizedDescription + NSSound.beep() + } + } + + private func finalize( + _ recognition: CaptureRecognition, data: Data, operation: CaptureToolOperation + ) throws -> CaptureRecognition { + var savedPath: String? + let save = + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.saveScreenshots) as? Bool + ?? false + if operation == .read, save { + savedPath = try CaptureScreenshotArchive.save(data).path + } + let result = CaptureRecognition( + capturedAt: recognition.capturedAt, text: recognition.text, + codes: recognition.codes, imagePath: savedPath) + if operation == .read { + let raw = + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.historySize) as? Int ?? 10 + CaptureHistoryStore.add(result, limit: min(max(raw, 1), 25)) + history = CaptureHistoryStore.load() + IPC.post(IPC.Name.settingsChanged) + } + return result + } + + private func copy(_ result: CaptureRecognition) -> Bool { + let raw = SharedDefaults.store.string(forKey: AppStorageKeys.Capture.copyMode) ?? "" + let output = result.output(for: CaptureCopyMode(rawValue: raw) ?? .smart) + guard !output.isEmpty else { return false } + NSPasteboard.general.clearContents() + let copied = NSPasteboard.general.setString(output, forType: .string) + if copied { IPC.post(IPC.Name.clipboardChanged) } + return copied + } +} + +enum CaptureToolsHotKeys { + static var readCode: Int { + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.readHotKeyCode) as? Int + ?? kVK_ANSI_R + } + static var readMods: Int { + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.readHotKeyMods) as? Int + ?? (controlKey | optionKey | cmdKey) + } + static var readLabel: String { + SharedDefaults.store.string(forKey: AppStorageKeys.Capture.readHotKeyLabel) ?? "⌃⌥⌘R" + } + static var screenshotCode: Int { + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.screenshotHotKeyCode) as? Int + ?? kVK_ANSI_S + } + static var screenshotMods: Int { + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.screenshotHotKeyMods) as? Int + ?? (controlKey | optionKey | cmdKey) + } + static var screenshotLabel: String { + SharedDefaults.store.string(forKey: AppStorageKeys.Capture.screenshotHotKeyLabel) + ?? "⌃⌥⌘S" + } +} diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift new file mode 100644 index 000000000..5abaf9ea0 --- /dev/null +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift @@ -0,0 +1,164 @@ +import AppKit +import EdithKit +import SwiftUI + +@MainActor +final class CapturePreviewController: NSObject, NSWindowDelegate { + private let panel: NSPanel + private var timer: Timer? + + init( + image: NSImage, pngData: Data, recognition: CaptureRecognition, + operation: CaptureToolOperation, copiedResult: Bool + ) { + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 390, height: 360), + styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, + defer: false) + super.init() + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.isReleasedWhenClosed = false + panel.delegate = self + panel.contentView = NSHostingView( + rootView: CapturePreviewView( + image: image, recognition: recognition, operation: operation, + copiedResult: copiedResult, + copyImage: { Self.copyImage(pngData) }, + saveImage: { Self.saveImage(pngData) }, + copyResult: { Self.copyResult(recognition.output(for: .smart)) }, + openResult: { Self.openResult(recognition) }, + discard: { [weak self] in self?.close() }, + hovering: { [weak self] inside in self?.setHovering(inside) })) + } + + func show() { + if let screen = NSScreen.main ?? NSScreen.screens.first { + let frame = panel.frame + let visible = screen.visibleFrame + panel.setFrameOrigin( + NSPoint(x: visible.maxX - frame.width - 24, y: visible.minY + 24)) + } + panel.orderFrontRegardless() + scheduleClose() + } + + func close() { + timer?.invalidate() + timer = nil + panel.close() + panel.contentView = nil + } + + func windowWillClose(_ notification: Notification) { + timer?.invalidate() + timer = nil + panel.contentView = nil + } + + private func setHovering(_ hovering: Bool) { + if hovering { + timer?.invalidate() + timer = nil + } else { + scheduleClose() + } + } + + private func scheduleClose() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 12, repeats: false) { [weak self] _ in + Task { @MainActor in self?.close() } + } + } + + private static func copyImage(_ data: Data) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setData(data, forType: .png) + IPC.post(IPC.Name.clipboardChanged) + } + + private static func saveImage(_ data: Data) { + guard let url = try? CaptureScreenshotArchive.save(data) else { + NSSound.beep() + return + } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private static func copyResult(_ value: String) { + guard !value.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + IPC.post(IPC.Name.clipboardChanged) + } + + private static func openResult(_ recognition: CaptureRecognition) { + guard recognition.codes.count == 1, + let url = CaptureRecognizedLink.openable(recognition.codes[0].payload) + else { return } + NSWorkspace.shared.open(url) + } +} + +private struct CapturePreviewView: View { + let image: NSImage + let recognition: CaptureRecognition + let operation: CaptureToolOperation + let copiedResult: Bool + let copyImage: () -> Void + let saveImage: () -> Void + let copyResult: () -> Void + let openResult: () -> Void + let discard: () -> Void + let hovering: (Bool) -> Void + + private var output: String { recognition.output(for: .smart) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label( + operation == .read ? "Screen read" : "Screenshot", + systemImage: operation == .read ? "text.viewfinder" : "camera.viewfinder") + .font(.headline) + Spacer() + if copiedResult { + Label("Copied", systemImage: "checkmark.circle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + } + } + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: 178) + .background(.black.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Text(output.isEmpty ? "No text or codes found" : output) + .font(.system(size: 12, design: .rounded)) + .foregroundStyle(output.isEmpty ? .secondary : .primary) + .lineLimit(4) + .textSelection(.enabled) + HStack(spacing: 8) { + Button("Copy image", action: copyImage) + Button("Save", action: saveImage) + if !output.isEmpty { Button("Copy result", action: copyResult) } + if recognition.codes.count == 1, + CaptureRecognizedLink.openable(recognition.codes[0].payload) != nil + { + Button("Open", action: openResult) + } + Spacer() + Button("Discard", role: .destructive, action: discard) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(18) + .onHover(perform: hovering) + } +} diff --git a/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift b/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift index 84700470d..eebf21495 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/Settings/Services/SettingsBackup.swift @@ -689,6 +689,12 @@ final class SettingsBackup { AppStorageKeys.ColorPicker.profile, AppStorageKeys.ColorPicker.historySize, "colorPickerHotKeyCode", "colorPickerHotKeyMods", "colorPickerHotKeyLabel", + AppStorageKeys.Capture.enabled, AppStorageKeys.Capture.copyMode, + AppStorageKeys.Capture.detectCodes, AppStorageKeys.Capture.history, + AppStorageKeys.Capture.historySize, AppStorageKeys.Capture.saveScreenshots, + AppStorageKeys.Capture.readHotKeyCode, AppStorageKeys.Capture.readHotKeyMods, + AppStorageKeys.Capture.readHotKeyLabel, AppStorageKeys.Capture.screenshotHotKeyCode, + AppStorageKeys.Capture.screenshotHotKeyMods, AppStorageKeys.Capture.screenshotHotKeyLabel, AppStorageKeys.General.creditHidden, AppStorageKeys.General.homeClockZones, AppStorageKeys.Presenter.blurCalendar, AppStorageKeys.General.showDockIcon, AppStorageKeys.Tabs.calendarEnabled, AppStorageKeys.Music.looping, @@ -792,6 +798,12 @@ final class SettingsBackup { AppStorageKeys.ColorPicker.profile, AppStorageKeys.ColorPicker.historySize, "colorPickerHotKeyCode", "colorPickerHotKeyMods", "colorPickerHotKeyLabel", + AppStorageKeys.Capture.enabled, AppStorageKeys.Capture.copyMode, + AppStorageKeys.Capture.detectCodes, AppStorageKeys.Capture.history, + AppStorageKeys.Capture.historySize, AppStorageKeys.Capture.saveScreenshots, + AppStorageKeys.Capture.readHotKeyCode, AppStorageKeys.Capture.readHotKeyMods, + AppStorageKeys.Capture.readHotKeyLabel, AppStorageKeys.Capture.screenshotHotKeyCode, + AppStorageKeys.Capture.screenshotHotKeyMods, AppStorageKeys.Capture.screenshotHotKeyLabel, AppStorageKeys.General.creditHidden, AppStorageKeys.General.homeClockZones, AppStorageKeys.Presenter.blurCalendar, AppStorageKeys.Presenter.blurAgents, AppStorageKeys.General.showDockIcon, diff --git a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift index 5b3be7e16..418a1b33a 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift @@ -71,6 +71,21 @@ public enum AppStorageKeys { public static let profile = "colorPickerProfile" } + public enum Capture { + public static let copyMode = "captureCopyMode" + public static let detectCodes = "captureDetectCodes" + public static let enabled = "captureToolsEnabled" + public static let history = "captureHistory" + public static let historySize = "captureHistorySize" + public static let readHotKeyCode = "captureReadHotKeyCode" + public static let readHotKeyLabel = "captureReadHotKeyLabel" + public static let readHotKeyMods = "captureReadHotKeyMods" + public static let saveScreenshots = "captureSaveScreenshots" + public static let screenshotHotKeyCode = "captureScreenshotHotKeyCode" + public static let screenshotHotKeyLabel = "captureScreenshotHotKeyLabel" + public static let screenshotHotKeyMods = "captureScreenshotHotKeyMods" + } + public enum Companion { public static let endpoint = "companionEndpoint" public static let tab = "companionTab" diff --git a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift index d95f8d6dc..2bd90225b 100644 --- a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift +++ b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift @@ -35,6 +35,10 @@ public enum IPC { public static let clipboardChanged = Notification.Name("com.pulkit.edith.clipboardChanged") public static let requestColorPick = Notification.Name( "com.pulkit.edith.requestColorPick") + public static let requestScreenRead = Notification.Name( + "com.pulkit.edith.requestScreenRead") + public static let requestScreenshot = Notification.Name( + "com.pulkit.edith.requestScreenshot") public static let shelfChanged = Notification.Name("com.pulkit.edith.shelfChanged") public static let shelfOperation = Notification.Name("com.pulkit.edith.shelfOperation") public static let shelfOperationResult = Notification.Name( diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift index b1a448721..3b11a4cc5 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift @@ -50,7 +50,7 @@ public enum ConfigCatalog { "machines", "herdr", "quinjet", "companion", "finder", "system", "cleaner", "music", "calendar", "clipboard", - "notch", "focusdim", "presenter", "colorpicker", "micmute", + "notch", "focusdim", "presenter", "colorpicker", "capture", "micmute", "backup", "permissions", "terminal", ] @@ -58,7 +58,9 @@ public enum ConfigCatalog { appearance + panel + attention + usageAndLimits + menuBar + alerts + budget + dashboard + machines + herdr + quinjet + companion + finder + system + cleaner - + music + calendar + clipboard + notch + focusDim + presenter + colorPicker + micMute + + music + calendar + clipboard + notch + focusDim + presenter + colorPicker + + capture + + micMute + backup + permissions + terminal public static var keys: [String] { settings.map(\.key) } @@ -800,6 +802,45 @@ public enum ConfigCatalog { summary: "Printable label for the colour picker shortcut."), ] + private static let capture: [SettingDefinition] = [ + SettingDefinition( + AppStorageKeys.Capture.enabled, .bool, group: "capture", + summary: "Enable offline screen recognition and quick screenshots.", + fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.Capture.copyMode, .string, group: "capture", + summary: "Content copied after a screen read.", + allowed: CaptureCopyMode.allCases.map(\.rawValue), fallback: .string("smart")), + SettingDefinition( + AppStorageKeys.Capture.detectCodes, .bool, group: "capture", + summary: "Detect QR and other two-dimensional codes.", fallback: .bool(true)), + SettingDefinition( + AppStorageKeys.Capture.historySize, .int, group: "capture", + summary: "Maximum saved screen reads.", integerRange: 1...25, fallback: .int(10)), + SettingDefinition( + AppStorageKeys.Capture.saveScreenshots, .bool, group: "capture", + summary: "Keep the image behind every screen read in Pictures.", + fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.Capture.readHotKeyCode, .int, group: "capture", + summary: "Virtual key code for screen reading.", fallback: .int(15)), + SettingDefinition( + AppStorageKeys.Capture.readHotKeyMods, .int, group: "capture", + summary: "Carbon modifier mask for screen reading.", fallback: .int(6400)), + SettingDefinition( + AppStorageKeys.Capture.readHotKeyLabel, .string, group: "capture", + summary: "Printable screen reading shortcut.", fallback: .string("⌃⌥⌘R")), + SettingDefinition( + AppStorageKeys.Capture.screenshotHotKeyCode, .int, group: "capture", + summary: "Virtual key code for quick screenshots.", fallback: .int(1)), + SettingDefinition( + AppStorageKeys.Capture.screenshotHotKeyMods, .int, group: "capture", + summary: "Carbon modifier mask for quick screenshots.", fallback: .int(6400)), + SettingDefinition( + AppStorageKeys.Capture.screenshotHotKeyLabel, .string, group: "capture", + summary: "Printable screenshot shortcut.", fallback: .string("⌃⌥⌘S")), + ] + private static let micMute: [SettingDefinition] = [ SettingDefinition( "micMuted", .bool, group: "micmute", summary: "Microphone muted right now.", diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift index fce4a8974..d14d3b545 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift @@ -123,6 +123,9 @@ public enum UserOperationCatalog { + ColorSwatchOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } + + CaptureToolOperation.allCases.map { + RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) + } + CompanionSettingsOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } @@ -1092,6 +1095,17 @@ private extension ColorSwatchOperation { } } +private extension CaptureToolOperation { + var interfaceExposure: UserOperationExposure { + switch self { + case .read: + userInterface("Capture Tools settings", "read text and codes from the screen") + case .screenshot: + userInterface("Capture Tools settings", "take a quick screenshot") + } + } +} + private extension CompanionSettingsOperation { var interfaceExposure: UserOperationExposure { switch self { diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift new file mode 100644 index 000000000..3950b9386 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift @@ -0,0 +1,175 @@ +import CoreGraphics +import Foundation +import Vision + +public enum CaptureCopyMode: String, CaseIterable, Codable, Sendable { + case smart + case text + case codes + case combined + + public var displayName: String { + switch self { + case .smart: "Best result" + case .text: "Text only" + case .codes: "Codes only" + case .combined: "Text and codes" + } + } +} + +public struct CaptureCode: Codable, Equatable, Hashable, Sendable { + public let symbology: String + public let payload: String + + public init(symbology: String, payload: String) { + self.symbology = symbology + self.payload = payload + } +} + +public struct CaptureRecognition: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let capturedAt: Date + public let text: String + public let codes: [CaptureCode] + public let imagePath: String? + + public init( + id: UUID = UUID(), capturedAt: Date = Date(), text: String, + codes: [CaptureCode], imagePath: String? = nil + ) { + self.id = id + self.capturedAt = capturedAt + self.text = text.trimmingCharacters(in: .whitespacesAndNewlines) + self.codes = codes + self.imagePath = imagePath + } + + public var isEmpty: Bool { text.isEmpty && codes.isEmpty } + + public func output(for mode: CaptureCopyMode) -> String { + let payloads = codes.map(\.payload).uniqued() + switch mode { + case .smart: + return payloads.isEmpty ? text : payloads.joined(separator: "\n") + case .text: + return text + case .codes: + return payloads.joined(separator: "\n") + case .combined: + return joined(text: text, payloads: payloads) + } + } + + private func joined(text: String, payloads: [String]) -> String { + ([text].filter { !$0.isEmpty } + payloads).uniqued().joined(separator: "\n") + } +} + +public enum CaptureRecognitionError: LocalizedError, Equatable { + case unreadableImage + + public var errorDescription: String? { + "The captured image could not be read." + } +} + +public enum CaptureRecognizedLink { + public static func openable(_ value: String) -> URL? { + guard !value.contains(where: { $0.isWhitespace }), + let components = URLComponents(string: value), + ["http", "https"].contains(components.scheme?.lowercased() ?? ""), + components.host?.isEmpty == false + else { return nil } + return components.url + } +} + +public enum CaptureRecognizer { + public static func recognize( + _ image: CGImage, detectCodes: Bool = true + ) throws -> CaptureRecognition { + let accurateRequest = textRequest(level: .accurate, detectsLanguage: true) + let codeRequest = VNDetectBarcodesRequest() + codeRequest.symbologies = [.qr, .microQR, .aztec, .dataMatrix, .pdf417] + let requests: [VNRequest] = detectCodes ? [codeRequest, accurateRequest] : [accurateRequest] + try VNImageRequestHandler(cgImage: image, options: [:]).perform(requests) + + var lines = sortedText(accurateRequest.results ?? []) + if lines.isEmpty { + let fallback = textRequest(level: .fast, detectsLanguage: false) + try VNImageRequestHandler(cgImage: image, options: [:]).perform([fallback]) + lines = sortedText(fallback.results ?? []) + } + + let codes = (codeRequest.results ?? []) + .sorted { positioned($0.boundingBox, before: $1.boundingBox) } + .compactMap { observation -> CaptureCode? in + guard + let payload = observation.payloadStringValue?.trimmingCharacters( + in: .whitespacesAndNewlines), !payload.isEmpty + else { return nil } + return CaptureCode(symbology: observation.symbology.rawValue, payload: payload) + }.uniqued() + + return CaptureRecognition(text: lines.joined(separator: "\n"), codes: codes) + } + + private static func textRequest( + level: VNRequestTextRecognitionLevel, detectsLanguage: Bool + ) -> VNRecognizeTextRequest { + let request = VNRecognizeTextRequest() + request.recognitionLevel = level + request.usesLanguageCorrection = level == .accurate + request.automaticallyDetectsLanguage = detectsLanguage + return request + } + + private static func sortedText( + _ observations: [VNRecognizedTextObservation] + ) -> [String] { + observations.compactMap { observation -> (String, CGRect)? in + guard let candidate = observation.topCandidates(1).first else { return nil } + return (candidate.string, observation.boundingBox) + } + .sorted { positioned($0.1, before: $1.1) } + .map(\.0) + } + + private static func positioned(_ lhs: CGRect, before rhs: CGRect) -> Bool { + let rowDistance = abs(lhs.midY - rhs.midY) + return rowDistance > 0.02 ? lhs.midY > rhs.midY : lhs.minX < rhs.minX + } +} + +public enum CaptureHistoryStore { + public static func load( + from defaults: UserDefaults = SharedDefaults.store + ) -> [CaptureRecognition] { + guard let data = defaults.data(forKey: AppStorageKeys.Capture.history), + let captures = try? JSONDecoder().decode([CaptureRecognition].self, from: data) + else { return [] } + return captures + } + + public static func add( + _ capture: CaptureRecognition, limit: Int, + into defaults: UserDefaults = SharedDefaults.store + ) { + let captures = Array(([capture] + load(from: defaults)).prefix(max(1, limit))) + guard let data = try? JSONEncoder().encode(captures) else { return } + defaults.set(data, forKey: AppStorageKeys.Capture.history) + } + + public static func clear(in defaults: UserDefaults = SharedDefaults.store) { + defaults.removeObject(forKey: AppStorageKeys.Capture.history) + } +} + +private extension Array where Element: Hashable { + func uniqued() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift new file mode 100644 index 000000000..7b29d8c6d --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift @@ -0,0 +1,124 @@ +import AppKit +import Foundation + +public enum CaptureScreenshotError: LocalizedError, Equatable { + case busy + case cancelled + case captureFailed(Int32) + case saveFailed + + public var errorDescription: String? { + switch self { + case .busy: "A screen selection is already active." + case .cancelled: "Screen selection was cancelled." + case .captureFailed(let status): "Screen capture failed with status \(status)." + case .saveFailed: "The screenshot could not be saved." + } + } +} + +public final class CaptureScreenshotSession: @unchecked Sendable { + private let lock = NSLock() + private var process: Process? + + public init() {} + + public func capture() async throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("edith-capture-\(UUID().uuidString)") + .appendingPathExtension("png") + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + process.arguments = ["-i", "-x", url.path] + process.terminationHandler = { [weak self] completed in + self?.release(completed) + if completed.terminationStatus == 0, + FileManager.default.fileExists(atPath: url.path) + { + continuation.resume(returning: url) + } else { + try? FileManager.default.removeItem(at: url) + let error: CaptureScreenshotError = + completed.terminationStatus == 1 + ? .cancelled : .captureFailed(completed.terminationStatus) + continuation.resume(throwing: error) + } + } + do { + try prepare(process) + guard !Task.isCancelled else { + release(process) + throw CaptureScreenshotError.cancelled + } + try process.run() + } catch { + release(process) + try? FileManager.default.removeItem(at: url) + continuation.resume(throwing: error) + } + } + } onCancel: { + cancel() + } + } + + public func cancel() { + let active = lock.withLock { process } + if active?.isRunning == true { active?.terminate() } + } + + private func prepare(_ process: Process) throws { + try lock.withLock { + guard self.process == nil else { throw CaptureScreenshotError.busy } + self.process = process + } + } + + private func release(_ process: Process) { + lock.withLock { + if self.process === process { self.process = nil } + } + } +} + +public enum CaptureScreenshotArchive { + public static func save(_ source: URL, now: Date = Date()) throws -> URL { + let data = try Data(contentsOf: source) + return try save(data, now: now) + } + + public static func save(_ data: Data, now: Date = Date()) throws -> URL { + guard + let pictures = FileManager.default.urls( + for: .picturesDirectory, in: .userDomainMask + ).first + else { throw CaptureScreenshotError.saveFailed } + let directory = pictures.appendingPathComponent("Edith Captures", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss" + let destination = + directory + .appendingPathComponent("Edith Capture \(formatter.string(from: now))") + .appendingPathExtension("png") + do { + try data.write(to: destination, options: .atomic) + return destination + } catch { + throw CaptureScreenshotError.saveFailed + } + } +} + +public enum CaptureScreenshotImage { + public static func load(_ url: URL) throws -> CGImage { + guard let source = NSImage(contentsOf: url), + let image = source.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { throw CaptureRecognitionError.unreadableImage } + return image + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureToolOperation.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureToolOperation.swift new file mode 100644 index 000000000..84123fa69 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureToolOperation.swift @@ -0,0 +1,41 @@ +import EdithCore +import Foundation + +public enum CaptureToolOperation: String, CaseIterable, Sendable { + case read + case screenshot + + public var descriptor: UserOperationDescriptor { + switch self { + case .read: + UserOperationDescriptor( + id: UserOperationID(rawValue: "capture.read"), + summary: "Select part of the screen and recognize text or codes.", + cli: ["capture", rawValue], effect: .interactive) + case .screenshot: + UserOperationDescriptor( + id: UserOperationID(rawValue: "capture.screenshot"), + summary: "Select part of the screen for a lightweight preview.", + cli: ["capture", rawValue], effect: .interactive) + } + } +} + +public enum CaptureToolOperationExecution { + public static func request( + _ operation: CaptureToolOperation, + post: (Notification.Name) -> Void = { IPC.post($0) } + ) -> UserOperationDescriptor { + post(operation.notification) + return operation.descriptor + } +} + +private extension CaptureToolOperation { + var notification: Notification.Name { + switch self { + case .read: IPC.Name.requestScreenRead + case .screenshot: IPC.Name.requestScreenshot + } + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift index 4850072a1..1e6b934ed 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift @@ -56,7 +56,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 capture a selected screen region." 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." @@ -127,7 +127,7 @@ public extension ExtensionRegistryEntry { var requiredPermissions: [ExtensionPermission] { switch id { case "calendar": [.calendar] - case "focusDim", "presenter", "colorPicker": [.screenRecording] + case "focusDim", "presenter", "colorPicker", "captureTools": [.screenRecording] default: [] } } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift index 67d517568..407f02beb 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift @@ -28,11 +28,14 @@ public enum ExtensionShortcut: String, CaseIterable, Hashable, Sendable { case focusDim case presenter case colorPicker + case captureRead + case captureScreenshot } public enum ExtensionShortcutVisibility { public static func visible( - clipboard: Bool, micMute: Bool, focusDim: Bool, presenter: Bool, colorPicker: Bool + clipboard: Bool, micMute: Bool, focusDim: Bool, presenter: Bool, + colorPicker: Bool, captureTools: Bool ) -> [ExtensionShortcut] { let states: [(ExtensionShortcut, Bool)] = [ (.clipboard, clipboard), @@ -40,6 +43,8 @@ public enum ExtensionShortcutVisibility { (.focusDim, focusDim), (.presenter, presenter), (.colorPicker, colorPicker), + (.captureRead, captureTools), + (.captureScreenshot, captureTools), ] return states.compactMap { shortcut, enabled in enabled ? shortcut : nil } } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift index bb356c6e5..863f341cb 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift @@ -55,6 +55,7 @@ public enum ExtensionDefaultsMigration { FocusDimState.enabledKey: false, AppStorageKeys.Presenter.enabled: true, AppStorageKeys.ColorPicker.enabled: false, + AppStorageKeys.Capture.enabled: false, LidAwakeState.enabledKey: false, ] } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift index d231964ae..ff97aff54 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift @@ -215,6 +215,8 @@ public struct ExtensionLifecycleProbe: Sendable { requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "colorPicker": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), + "captureTools": Policy( + requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), ] public let environment: ExtensionLifecycleProbeEnvironment diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift index 17171d3e7..34b473360 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift @@ -66,7 +66,7 @@ public enum ExtensionLiveAdapters { public static let extensionIDs = [ "attention", "usage", "quinjet", "system", "machines", "systemStats", "micMute", "lidAwake", "music", "calendar", "notchShelf", "clipboard", "focusDim", "presenter", - "colorPicker", + "colorPicker", "captureTools", ] public static func provider( @@ -106,6 +106,7 @@ public enum ExtensionLiveAdapters { case "focusDim": await focusDimReadiness(defaults: defaults) case "presenter": presenterReadiness(defaults: defaults) case "colorPicker": await colorPickerReadiness(defaults: defaults) + case "captureTools": await captureToolsReadiness(defaults: defaults) default: nil } } @@ -462,6 +463,24 @@ public enum ExtensionLiveAdapters { ).readiness } + static func captureToolsReadiness(defaults: UserDefaults) async -> ExtensionAdapterReadiness { + let mode = defaults.string(forKey: AppStorageKeys.Capture.copyMode) + let historySize = defaults.object(forKey: AppStorageKeys.Capture.historySize) as? Int + let configured = + (mode == nil || CaptureCopyMode(rawValue: mode!) != nil) + && (historySize == nil || (1...25).contains(historySize!)) + let history = CaptureHistoryStore.load(from: defaults) + let screenCount = await MainActor.run { NSScreen.screens.count } + return ExtensionAdapterFacts( + configured: configured, contentCount: screenCount == 0 ? 0 : history.count, + readyDetail: "Saved screen reads: \(history.count).", + setupDetail: "The stored copy mode or history size is invalid.", + emptyDetail: screenCount == 0 + ? "No active display is available for capture." + : "Offline recognition is ready and the history is empty." + ).readiness + } + private static func inputDeviceCount() -> Result { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift index 28a9dd4a3..a2ef3177b 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift @@ -88,6 +88,7 @@ public enum ExtensionDetailRoute: String, CaseIterable, Sendable { case focusDim case presenter case colorPicker + case captureTools } public enum ExtensionModalMutationOutcome: Equatable, Sendable { diff --git a/Packages/Edith/Tests/EdithTests/AppServicesTests.swift b/Packages/Edith/Tests/EdithTests/AppServicesTests.swift index 0ca4210ad..051a28952 100644 --- a/Packages/Edith/Tests/EdithTests/AppServicesTests.swift +++ b/Packages/Edith/Tests/EdithTests/AppServicesTests.swift @@ -40,6 +40,7 @@ private actor AppServicesCallProbe { #expect(services.calendar == nil) #expect(services.notchShelf == nil) #expect(services.colorPicker == nil) + #expect(services.captureTools == nil) #expect(services.clipboard == nil) #expect(services.focusDim == nil) #expect(services.presenter == nil) diff --git a/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift b/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift new file mode 100644 index 000000000..51ade5dd4 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift @@ -0,0 +1,19 @@ +import ArgumentParser +import Testing + +@testable import EdithCLI + +@Suite struct CLICaptureTests { + @Test func parsesReadAndScreenshotLeaves() throws { + #expect(try EdRoot.parseAsRoot(["capture", "read"]) is CaptureReadCommand) + #expect( + try EdRoot.parseAsRoot(["capture", "screenshot", "--json"]) + is CaptureScreenshotCommand) + } + + @Test func commandTreeMatchesTheParserSurface() throws { + let capture = try #require(CommandTree.root.child("capture")) + #expect(capture.children.map(\.name) == ["read", "screenshot"]) + #expect(capture.children.allSatisfy { $0.options.contains("--json") }) + } +} diff --git a/Packages/Edith/Tests/EdithTests/CLICompletionTests.swift b/Packages/Edith/Tests/EdithTests/CLICompletionTests.swift index daeffe167..0c44e425e 100644 --- a/Packages/Edith/Tests/EdithTests/CLICompletionTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLICompletionTests.swift @@ -140,6 +140,11 @@ import Testing #expect(result.candidates.contains("clear")) } + @Test func captureOperationsComplete() { + let result = Self.plan(["ed", "capture", ""], 2) + #expect(result.candidates == ["read", "screenshot"]) + } + @Test func lidAwakeCommandsAndFlagsComplete() { let commands = Self.plan(["ed", "lid-awake", ""], 2) #expect( diff --git a/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift b/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift new file mode 100644 index 000000000..967727853 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift @@ -0,0 +1,59 @@ +import CoreImage +import Foundation +import Testing + +@testable import EdithKit + +@Suite struct CaptureRecognitionTests { + @Test func outputModesKeepTextAndUniqueCodePayloads() { + let capture = CaptureRecognition( + text: "invoice 42", + codes: [ + CaptureCode(symbology: "QR", payload: "https://edith.app"), + CaptureCode(symbology: "QR", payload: "https://edith.app"), + CaptureCode(symbology: "QR", payload: "second"), + ]) + + #expect(capture.output(for: .text) == "invoice 42") + #expect(capture.output(for: .codes) == "https://edith.app\nsecond") + #expect(capture.output(for: .combined) == "invoice 42\nhttps://edith.app\nsecond") + #expect(capture.output(for: .smart) == "https://edith.app\nsecond") + } + + @Test func smartModePrefersASingleCode() { + let capture = CaptureRecognition( + text: "printed beside the code", + codes: [CaptureCode(symbology: "QR", payload: "edith://capture")]) + #expect(capture.output(for: .smart) == "edith://capture") + } + + @Test func historyHonorsItsLimit() throws { + let suite = "CaptureRecognitionTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + CaptureHistoryStore.add( + CaptureRecognition(text: "first", codes: []), limit: 2, into: defaults) + CaptureHistoryStore.add( + CaptureRecognition(text: "second", codes: []), limit: 2, into: defaults) + CaptureHistoryStore.add( + CaptureRecognition(text: "third", codes: []), limit: 2, into: defaults) + #expect(CaptureHistoryStore.load(from: defaults).map(\.text) == ["third", "second"]) + } + + @Test func recognizesQRCodeOffline() throws { + let filter = try #require(CIFilter(name: "CIQRCodeGenerator")) + filter.setValue(Data("edith://capture".utf8), forKey: "inputMessage") + let output = try #require(filter.outputImage?.transformed(by: .init(scaleX: 12, y: 12))) + let image = try #require(CIContext().createCGImage(output, from: output.extent)) + let result = try CaptureRecognizer.recognize(image) + #expect(result.codes.map(\.payload) == ["edith://capture"]) + } + + @Test func onlyStrictWebLinksCanOpen() { + #expect(CaptureRecognizedLink.openable("https://edith.app/capture") != nil) + #expect(CaptureRecognizedLink.openable("http://localhost:8080") != nil) + #expect(CaptureRecognizedLink.openable("javascript:alert(1)") == nil) + #expect(CaptureRecognizedLink.openable("https://edith.app/a b") == nil) + #expect(CaptureRecognizedLink.openable("https:///missing-host") == nil) + } +} diff --git a/Packages/Edith/Tests/EdithTests/CaptureToolOperationTests.swift b/Packages/Edith/Tests/EdithTests/CaptureToolOperationTests.swift new file mode 100644 index 000000000..008e2e46d --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/CaptureToolOperationTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +@testable import EdithKit + +@Suite struct CaptureToolOperationTests { + @Test func descriptorsMatchTheCLILeaves() { + let descriptors = CaptureToolOperation.allCases.map(\.descriptor) + #expect(descriptors.map(\.id.rawValue) == ["capture.read", "capture.screenshot"]) + #expect(descriptors.map(\.cli) == [["capture", "read"], ["capture", "screenshot"]]) + #expect(descriptors.map(\.effect) == [.interactive, .interactive]) + for descriptor in descriptors { + #expect(UserOperationCatalog.descriptor(id: descriptor.id) == descriptor) + #expect(UserOperationCatalog.descriptor(cli: descriptor.cli) == descriptor) + } + } + + @Test func requestsUseDistinctNotifications() { + var posted: [Notification.Name] = [] + _ = CaptureToolOperationExecution.request(.read) { posted.append($0) } + _ = CaptureToolOperationExecution.request(.screenshot) { posted.append($0) } + #expect(posted == [IPC.Name.requestScreenRead, IPC.Name.requestScreenshot]) + } +} diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift index 16bec2aa2..8473b6c08 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift @@ -67,6 +67,9 @@ import EdithCore MatrixRow( id: "colorPicker", helper: true, machine: false, toolRule: .all, adapter: true, requiredTools: [], optionalTools: []), + MatrixRow( + id: "captureTools", helper: true, machine: false, toolRule: .all, adapter: true, + requiredTools: [], optionalTools: []), ] @Test func policiesCoverTheRegistryExactly() { diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 96c2cd884..8c2ec7ff9 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -24,6 +24,7 @@ import Testing "focusDimEnabled", "presenterEnabled", "colorPickerEnabled", + "captureToolsEnabled", ] @Test func registryIdentifiersAreUnique() { @@ -36,7 +37,7 @@ import Testing ExtensionRegistry.entries.map(\.id) == [ "attention", "usage", "herdr", "quinjet", "system", "machines", "companion", "systemStats", "micMute", "lidAwake", "music", "calendar", "notchShelf", - "clipboard", "focusDim", "presenter", "colorPicker", + "clipboard", "focusDim", "presenter", "colorPicker", "captureTools", ]) } @@ -195,7 +196,7 @@ import Testing #expect(titleMatches.map(\.id) == ["usage"]) #expect(subtitleMatches.map(\.id) == ["calendar"]) #expect(categoryMatches.allSatisfy { $0.group == .utilities }) - #expect(combinedMatches.map(\.id) == ["presenter"]) + #expect(combinedMatches.map(\.id) == ["presenter", "captureTools"]) #expect(attentionMatches.map(\.id) == ["attention"]) } @@ -243,6 +244,7 @@ import Testing "focusDim": [.screenRecording], "presenter": [.screenRecording], "colorPicker": [.screenRecording], + "captureTools": [.screenRecording], ] let optional: [String: [ExtensionPermission]] = [ "attention": [], @@ -262,6 +264,7 @@ import Testing "focusDim": [], "presenter": [], "colorPicker": [], + "captureTools": [], ] let identifiers = Set(ExtensionRegistry.entries.map(\.id)) @@ -393,6 +396,7 @@ import Testing "focusDimEnabled": false, "presenterEnabled": true, "colorPickerEnabled": false, + "captureToolsEnabled": false, ] for (key, value) in expected { #expect(defaults.object(forKey: key) as? Bool == value) diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index e4b7520f7..5fbe58508 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -264,6 +264,7 @@ import Testing ("focusDim", "FocusDimRows", "enabled", "FocusDimRows.swift"), ("presenter", "PresenterRows", "presenterEnabled", "PresenterRows.swift"), ("colorPicker", "ColorPickerRows", "colorPickerEnabled", "ColorPickerRows.swift"), + ("captureTools", "CaptureToolsRows", "captureToolsEnabled", "CaptureToolsRows.swift"), ] #expect(Set(routes.map(\.id)) == Set(ExtensionDetailRoute.allCases.map(\.rawValue))) diff --git a/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift b/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift index 03c37f272..2bf5f8dba 100644 --- a/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift +++ b/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift @@ -46,22 +46,22 @@ import Testing #expect( ExtensionShortcutVisibility.visible( clipboard: false, micMute: false, focusDim: false, presenter: false, - colorPicker: false + colorPicker: false, captureTools: false ).isEmpty) #expect( ExtensionShortcutVisibility.visible( clipboard: true, micMute: false, focusDim: false, presenter: true, - colorPicker: false) + colorPicker: false, captureTools: false) == [.clipboard, .presenter]) #expect( ExtensionShortcutVisibility.visible( clipboard: false, micMute: true, focusDim: false, presenter: false, - colorPicker: false) + colorPicker: false, captureTools: false) == [.micMute]) #expect( ExtensionShortcutVisibility.visible( clipboard: true, micMute: true, focusDim: true, presenter: true, - colorPicker: true) + colorPicker: true, captureTools: true) == ExtensionShortcut.allCases) } diff --git a/Packages/Edith/Tests/EdithTests/PermissionsStatusTests.swift b/Packages/Edith/Tests/EdithTests/PermissionsStatusTests.swift index 3d869e0dc..5d9e0ef06 100644 --- a/Packages/Edith/Tests/EdithTests/PermissionsStatusTests.swift +++ b/Packages/Edith/Tests/EdithTests/PermissionsStatusTests.swift @@ -33,7 +33,7 @@ import Testing } @Test func screenRecordingBlocksEveryExtensionThatRequiresIt() { - for id in ["focusDim", "presenter", "colorPicker"] { + for id in ["focusDim", "presenter", "colorPicker", "captureTools"] { #expect(needsAttention(enabled: [id], missing: [.screenRecording])) } } diff --git a/README.md b/README.md index 532037a45..93125b4f9 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ and platform details. - **Music player** - your local music folder with thumbnails, drag-to-seek, fades, auto-advance and media keys; also controls Spotify and Apple Music. - **Clipboard history** - a global paste panel with search and paste-in-place. - **Color picker** - system-wide eyedropper on a hotkey, with swatch history. +- **Capture tools** - offline screen OCR, QR recognition, and quick screenshot previews. - **Notch shelf** - the notch becomes a hover-to-open shelf for drag-and-drop file staging, now-playing controls and a camera check. - **Calendar** - your agenda grouped by day, with one-tap join links. - **Audio mixer** - per-app volume control. @@ -105,6 +106,9 @@ Usage data never leaves your Mac. There is no account and no telemetry. Rate-limit checks go directly from your machine to your provider. Optional iCloud backup merges selected app data across your own Macs and nowhere else. +Screen reads use macOS Vision locally. Selected images are temporary unless you +save them or enable screenshot retention. + The optional Companion stores its memory on the machine you choose. Local embedding, vision and speech models run there. Reasoning can stay on that host or use a provider you configure, in which case the requested context goes directly diff --git a/docs/cli/README.md b/docs/cli/README.md index f37849706..a44c1cfec 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -60,6 +60,7 @@ report still exits 0, so read `verified`, `state.phase`, `state.runtimePhase`, | [`ed clipboard`](./clipboard/README.md) | Clipboard history: read, copy, pin and prune | | [`ed attention`](./attention/README.md) | Application, website, music, focus and distraction insight | | [`ed color`](./color/README.md) | Launching the colour picker and reading its swatch history | +| [`ed capture`](./capture/README.md) | Offline screen OCR, QR recognition, and quick screenshots | | [`ed download`](./download/README.md) | The download queue and the tools that back it | | [`ed apps`](./apps/README.md) | Running applications, and quitting them | | [`ed tools`](./tools/README.md) | The command line tools Edith can install for you | diff --git a/docs/cli/capture/README.md b/docs/cli/capture/README.md new file mode 100644 index 000000000..c33f1f7d4 --- /dev/null +++ b/docs/cli/capture/README.md @@ -0,0 +1,41 @@ +# `ed capture` + +`ed capture` starts Edith's offline screen-reading and quick screenshot tools. +Both commands use the standard macOS selector, so you can drag a region or +choose a window without learning a separate capture interface. + +Recognition uses macOS Vision on this Mac. QR, Micro QR, Aztec, Data Matrix, +and PDF417 payloads take priority over OCR text. Edith copies the configured +result after a screen read, keeps a bounded recent-read history, and shows a +transient preview with Copy image, Save, Copy result, and Discard actions. A +single strict HTTP or HTTPS code also gets an explicit Open action. + +## Commands + +| Command | What it does | +| --- | --- | +| `ed capture read` | Selects screen content, recognizes text and codes, and copies the configured result. | +| `ed capture screenshot` | Selects screen content and opens the lightweight screenshot preview. | + +- [`ed capture read`](./read.md) +- [`ed capture screenshot`](./screenshot.md) + +Both commands require the Capture Tools extension, the running menu bar app, +and Screen Recording permission. They return after sending the request. The +desktop selection and recognition finish asynchronously. + +## Privacy and related tools + +Images and recognition results stay on this Mac. A selected image is temporary +unless you click Save or enable `captureSaveScreenshots`. Saved images go to +`~/Pictures/Edith Captures`. + +Capture Tools does not duplicate Edith's other camera features. Use Color +Picker for exact screen colors and Notch Shelf for its camera mirror preview. + +## Where to go next + +- [`ed permissions`](../permissions/README.md), for Screen Recording access +- [`ed config`](../config/README.md), for the `capture` settings group +- [`ed color`](../color/README.md), for exact pixel colors +- [All `ed` commands](../README.md) diff --git a/docs/cli/capture/read.md b/docs/cli/capture/read.md new file mode 100644 index 000000000..741950a21 --- /dev/null +++ b/docs/cli/capture/read.md @@ -0,0 +1,34 @@ +# `ed capture read` + +Selects part of the screen, recognizes text and supported codes offline, copies +the configured result, and opens the transient preview. + +Usage: + +```text +ed capture read [--json] +``` + +| Option | What it does | +| --- | --- | +| `--json` | Emits the request acknowledgement as JSON. | + +Plain output is `screen read requested`. JSON output is: + +```json +{"operation":"capture.read","requested":true} +``` + +The command exits 0 when the request is delivered, 2 for invalid arguments, +and 4 when the extension is off or the menu bar app is not running. Canceling +the selector does not change an already completed command. + +`captureCopyMode` controls what is copied. `smart` copies code payloads when +present and otherwise copies OCR text. `text`, `codes`, and `combined` provide +explicit alternatives. + +## Where to go next + +- [`ed capture`](./README.md) +- [`ed capture screenshot`](./screenshot.md) +- [`ed extensions enable`](../extensions/enable.md) diff --git a/docs/cli/capture/screenshot.md b/docs/cli/capture/screenshot.md new file mode 100644 index 000000000..afc3d0f0e --- /dev/null +++ b/docs/cli/capture/screenshot.md @@ -0,0 +1,31 @@ +# `ed capture screenshot` + +Selects part of the screen and opens a lightweight preview with Copy image, +Save, Copy result, safe Open, and Discard actions. + +Usage: + +```text +ed capture screenshot [--json] +``` + +| Option | What it does | +| --- | --- | +| `--json` | Emits the request acknowledgement as JSON. | + +Plain output is `screenshot requested`. JSON output is: + +```json +{"operation":"capture.screenshot","requested":true} +``` + +The command exits 0 when the request is delivered, 2 for invalid arguments, +and 4 when the extension is off or the menu bar app is not running. The preview +closes after 12 seconds while it is not under the pointer. Save writes a PNG to +`~/Pictures/Edith Captures`. + +## Where to go next + +- [`ed capture`](./README.md) +- [`ed capture read`](./read.md) +- [`ed permissions`](../permissions/README.md) diff --git a/docs/cli/config/README.md b/docs/cli/config/README.md index 98806f840..cf58514c3 100644 --- a/docs/cli/config/README.md +++ b/docs/cli/config/README.md @@ -1,7 +1,7 @@ # `ed config` Every preference the Edith UI writes is a key in the same defaults the app -reads, and `ed config` is that surface from the command line: 214 settings in 25 +reads, and `ed config` is that surface from the command line: 225 settings in 26 groups, each with a type, a default, a scope and a one-line summary. Reach for it when you want to know what a switch in Settings is actually called, flip one without opening the window, or move a whole setup to another Mac. @@ -364,6 +364,22 @@ not here cannot be set, and `import` skips it. | `colorPickerHotKeyMods` | int | none | shared | Carbon modifier mask of the colour picker shortcut. | | `colorPickerHotKeyLabel` | string | none | shared | Printable label for the colour picker shortcut. | +### `capture` + +| Key | Type | Default | Scope | What it controls | +| --- | --- | --- | --- | --- | +| `captureToolsEnabled` | bool | `false` | shared | Enable offline screen recognition and quick screenshots. | +| `captureCopyMode` | string: `smart`, `text`, `codes`, `combined` | `smart` | shared | Content copied after a screen read. | +| `captureDetectCodes` | bool | `true` | shared | Detect QR and other two-dimensional codes. | +| `captureHistorySize` | int: 1 through 25 | `10` | shared | Maximum saved screen reads. | +| `captureSaveScreenshots` | bool | `false` | shared | Keep the image behind every screen read in Pictures. | +| `captureReadHotKeyCode` | int | `15` | shared | Virtual key code for screen reading. | +| `captureReadHotKeyMods` | int | `6400` | shared | Carbon modifier mask for screen reading. | +| `captureReadHotKeyLabel` | string | `⌃⌥⌘R` | shared | Printable screen reading shortcut. | +| `captureScreenshotHotKeyCode` | int | `1` | shared | Virtual key code for quick screenshots. | +| `captureScreenshotHotKeyMods` | int | `6400` | shared | Carbon modifier mask for quick screenshots. | +| `captureScreenshotHotKeyLabel` | string | `⌃⌥⌘S` | shared | Printable screenshot shortcut. | + ### `micmute` | Key | Type | Default | Scope | What it controls | diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index fbffeffb1..14be8bc1a 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 seventeen | +| `ed extensions status [id]` | Summarises readiness for one extension or all eighteen | | `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 seventeen, with recovery commands | +| `ed extensions doctor [id]` | Diagnoses one extension or all eighteen, 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 EdithKit is the single list every command here -walks, and its order is the order `ls` prints. Seventeen entries, in this order: +walks, and its order is the order `ls` prints. Eighteen entries, in this order: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -73,8 +73,9 @@ walks, and its order is the order `ls` prints. Seventeen entries, in this order: | `focusDim` | Focus Dim | Utilities | Dims everything behind your active app | | `presenter` | Presenter | Utilities | Blurs sensitive numbers while sharing your screen | | `colorPicker` | Color Picker | Utilities | System loupe on a hotkey, sampled color to your clipboard | +| `captureTools` | Capture Tools | Utilities | Offline screen OCR, QR recognition, and quick screenshots | -The same seventeen, with what each one is made of. `Key` is the preference the app +The same eighteen, 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 eight the welcome tour shows before you ask it for all of them. @@ -97,6 +98,7 @@ eight the welcome tour shows before you ask it for all of them. | `focusDim` | `focusDimEnabled` | no | `screenRecording` | none | none | none | | `presenter` | `presenterEnabled` | no | `screenRecording` | none | none | none | | `colorPicker` | `colorPickerEnabled` | no | `screenRecording` | none | none | none | +| `captureTools` | `captureToolsEnabled` | no | `screenRecording` | none | none | none | The JSON form also exposes the platform capability registry. Capabilities are not permission ids. They say which implementation an extension requires from @@ -121,6 +123,7 @@ the current platform, and which missing implementations merely degrade it: | `focusDim` | `windowDimming` | none | | `presenter` | `screenShareDetection` | none | | `colorPicker` | `screenColorSampling` | `globalShortcuts` | +| `captureTools` | `screenContentRecognition` | `globalShortcuts` | An id is matched exactly and case-insensitively against the `ID` column first, then against the `Key` column, so `ed extensions info clipboard`, diff --git a/docs/cli/extensions/disable.md b/docs/cli/extensions/disable.md index 80a312a62..d065ca17e 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 seventeen ids, or a defaults key | required | The extension to turn off | +| `id` | one of the eighteen ids, 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 08de8a8c2..981439cb5 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 seventeen. +Diagnoses setup and runtime problems for one extension or all eighteen. ``` 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 seventeen extensions have an explicit live adapter. A missing adapter is +All eighteen 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 b2fd8eaae..74043dc18 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 seventeen ids, or a defaults key | required | The extension to turn on | +| `id` | one of the eighteen ids, or a defaults key | required | The extension to turn on | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | @@ -75,7 +75,7 @@ known id as the hint: ``` $ ed extensions enable clipbored error: no extension named clipbored -hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, focusDim, presenter, colorPicker +hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, focusDim, presenter, colorPicker, captureTools ``` Enabling never asks for a permission and never installs a tool. Agent Usage diff --git a/docs/cli/extensions/info.md b/docs/cli/extensions/info.md index 89b565e5b..fea168e37 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 seventeen ids, or a defaults key | required | The extension to describe | +| `id` | one of the eighteen ids, or a defaults key | required | The extension to describe | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/runtime-detection.md b/docs/cli/extensions/runtime-detection.md index 7a68c6dea..92182a93b 100644 --- a/docs/cli/extensions/runtime-detection.md +++ b/docs/cli/extensions/runtime-detection.md @@ -38,6 +38,7 @@ system query failure produces `failed` with runtime phase `error`. | Focus Dim | finite intensity and animation values plus a valid display mode | active display count | `ed config ls --group focusdim --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` | | 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` | +| Capture Tools | valid copy mode, history limit, and decodable read history | active display and saved read count | `ed config ls --group capture --json`; `ed permissions refresh` | ## Agent workflow diff --git a/docs/cli/extensions/status.md b/docs/cli/extensions/status.md index a93842f68..01a209a41 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 seventeen ids, or a defaults key | all extensions | Limit the report to one extension | +| `id` | one of the eighteen ids, 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 484a75d56..e662215b7 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 seventeen extensions noninteractively. +up, verify, and recover all eighteen extensions noninteractively. Any topic other than `agent` exits 3 and lists the discovery forms: diff --git a/docs/cli/permissions/README.md b/docs/cli/permissions/README.md index b739233f3..cd393377b 100644 --- a/docs/cli/permissions/README.md +++ b/docs/cli/permissions/README.md @@ -37,7 +37,7 @@ full list as the hint. | `accessibility` | Privacy & Security > Accessibility | Clean keys, and clipboard instant paste | optional for `system`, `clipboard` | | `inputMonitoring` | Privacy & Security > Input Monitoring | Block key presses while Clean keys is locking the keyboard | optional for `system` | | `fullDisk` | Privacy & Security > Full Disk Access | Reach local service credentials and usage data | nothing declares it | -| `screenRecording` | Privacy & Security > Screen Recording | Detect shared content, and sample colours from the screen | required by `focusDim`, `presenter`, `colorPicker` | +| `screenRecording` | Privacy & Security > Screen Recording | Detect, sample, or capture selected screen content | required by `focusDim`, `presenter`, `colorPicker`, `captureTools` | | `applicationAudio` | Privacy & Security > Screen & System Audio Recording, granted on first use | Notch Shelf per-app volume mixing | optional for `notchShelf` | | `camera` | Privacy & Security > Camera | The Notch Shelf camera preview | optional for `notchShelf` | | `bluetooth` | Privacy & Security > Bluetooth, granted on first use | Notch Shelf device connection alerts | optional for `notchShelf` | diff --git a/docs/cli/permissions/ls.md b/docs/cli/permissions/ls.md index 69dd4ee71..d037fe361 100644 --- a/docs/cli/permissions/ls.md +++ b/docs/cli/permissions/ls.md @@ -48,11 +48,12 @@ the fixed order of the table above. Trimmed here to three of the ten rows: "id": "screenRecording", "name": "Screen Recording", "optionalFor": [], - "reason": "Required to detect shared content or sample colors from the screen.", + "reason": "Required to detect shared content, sample colors, or capture a selected screen region.", "requiredBy": [ "focusDim", "presenter", - "colorPicker" + "colorPicker", + "captureTools" ], "usedByEnabledExtension": true }, @@ -108,7 +109,7 @@ notifications granted usage,machines accessibility granted system,clipboard inputMonitoring granted system fullDisk no -screenRecording granted focusDim,presenter,colorPicker +screenRecording granted focusDim,presenter,colorPicker,captureTools applicationAudio on first use notchShelf camera granted notchShelf bluetooth on first use notchShelf From c412ebd664c9b7b2cd0d4fa13a5d4503d770ce99 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Thu, 27 Aug 2026 23:08:55 +0530 Subject: [PATCH 02/11] feat: harden capture workflow --- .../Settings/Views/CaptureToolsRows.swift | 13 +++++- .../Core/Application/EdithHelperApp.swift | 44 ++++++++++++------- .../ViewModels/CaptureToolsStore.swift | 30 ++++++++++--- .../Views/CapturePreviewController.swift | 31 ++++++++++--- .../Models/CaptureRecognition.swift | 2 +- .../Services/CaptureScreenshotSession.swift | 17 +++++-- .../EdithTests/CaptureRecognitionTests.swift | 14 ++++++ 7 files changed, 116 insertions(+), 35 deletions(-) diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift index e5af36fc8..f3e59d383 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift @@ -70,7 +70,7 @@ struct CaptureToolsRows: View { } if !history.isEmpty { - Section("Recent reads") { + Section { ForEach(history.prefix(historySize)) { capture in Button { copy(capture.output(for: copyMode)) @@ -90,6 +90,17 @@ struct CaptureToolsRows: View { } .buttonStyle(.edith(.row)) } + } header: { + HStack { + Text("Recent reads") + Spacer() + Button("Clear") { + CaptureHistoryStore.clear() + history = [] + IPC.post(IPC.Name.settingsChanged) + } + .buttonStyle(.plain) + } } } } diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift index 06de83e32..bd3ee4df2 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift @@ -652,29 +652,39 @@ struct RootView: View { } } if let captureTools = services.captureTools { - Menu { + if captureTools.inProgress { Button { - dismissPanel() - captureTools.start(.read) + captureTools.cancel() } label: { - Label("Read screen", systemImage: "text.viewfinder") + Image(systemName: "xmark") + .font(.system(size: 13)) + .foregroundStyle(.secondary) } - Button { - dismissPanel() - captureTools.start(.screenshot) + .buttonStyle(.edith(.toolbar)) + .help("Cancel capture") + } else { + Menu { + Button { + dismissPanel() + captureTools.start(.read) + } label: { + Label("Read screen", systemImage: "text.viewfinder") + } + Button { + dismissPanel() + captureTools.start(.screenshot) + } label: { + Label("Screenshot", systemImage: "camera.viewfinder") + } } label: { - Label("Screenshot", systemImage: "camera.viewfinder") + Image(systemName: "viewfinder") + .font(.system(size: 13)) + .foregroundStyle(.secondary) } - } label: { - Image(systemName: captureTools.inProgress ? "xmark" : "viewfinder") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + .menuStyle(.borderlessButton) + .fixedSize() + .help("Capture tools (\(CaptureToolsHotKeys.readLabel))") } - .menuStyle(.borderlessButton) - .fixedSize() - .help( - captureTools.inProgress - ? "Cancel capture" : "Capture tools (\(CaptureToolsHotKeys.readLabel))") } if focusDimEnabled { Button { diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift index 5e00f156f..0bbd9c0c0 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift @@ -39,8 +39,12 @@ final class CaptureToolsStore: FeatureModule { func start(_ operation: CaptureToolOperation) { if inProgress { - task?.cancel() - session.cancel() + cancel() + return + } + guard CGPreflightScreenCaptureAccess() else { + errorMessage = "Screen Recording access is required to capture the screen." + IPC.post(IPC.Name.grantScreenRecording) return } generation &+= 1 @@ -52,6 +56,16 @@ final class CaptureToolsStore: FeatureModule { } } + func cancel() { + guard inProgress else { return } + generation &+= 1 + task?.cancel() + task = nil + session.cancel() + inProgress = false + errorMessage = nil + } + func shutdown() { generation &+= 1 task?.cancel() @@ -96,14 +110,14 @@ final class CaptureToolsStore: FeatureModule { preview?.close() preview = CapturePreviewController( image: sourceImage, pngData: data, recognition: result, - operation: operation, copiedResult: copied) + operation: operation, copyMode: copyMode(), copiedResult: copied) preview?.show() } catch CaptureScreenshotError.cancelled { errorMessage = nil } catch is CancellationError { errorMessage = nil } catch { - guard generation == token else { return } + guard generation == token, !Task.isCancelled else { return } errorMessage = error.localizedDescription NSSound.beep() } @@ -133,14 +147,18 @@ final class CaptureToolsStore: FeatureModule { } private func copy(_ result: CaptureRecognition) -> Bool { - let raw = SharedDefaults.store.string(forKey: AppStorageKeys.Capture.copyMode) ?? "" - let output = result.output(for: CaptureCopyMode(rawValue: raw) ?? .smart) + let output = result.output(for: copyMode()) guard !output.isEmpty else { return false } NSPasteboard.general.clearContents() let copied = NSPasteboard.general.setString(output, forType: .string) if copied { IPC.post(IPC.Name.clipboardChanged) } return copied } + + private func copyMode() -> CaptureCopyMode { + let raw = SharedDefaults.store.string(forKey: AppStorageKeys.Capture.copyMode) ?? "" + return CaptureCopyMode(rawValue: raw) ?? .smart + } } enum CaptureToolsHotKeys { diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift index 5abaf9ea0..569d42513 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift @@ -9,7 +9,7 @@ final class CapturePreviewController: NSObject, NSWindowDelegate { init( image: NSImage, pngData: Data, recognition: CaptureRecognition, - operation: CaptureToolOperation, copiedResult: Bool + operation: CaptureToolOperation, copyMode: CaptureCopyMode, copiedResult: Bool ) { panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 390, height: 360), @@ -26,10 +26,10 @@ final class CapturePreviewController: NSObject, NSWindowDelegate { panel.contentView = NSHostingView( rootView: CapturePreviewView( image: image, recognition: recognition, operation: operation, - copiedResult: copiedResult, + copyMode: copyMode, copiedResult: copiedResult, copyImage: { Self.copyImage(pngData) }, saveImage: { Self.saveImage(pngData) }, - copyResult: { Self.copyResult(recognition.output(for: .smart)) }, + copyResult: { Self.copyResult(recognition.output(for: copyMode)) }, openResult: { Self.openResult(recognition) }, discard: { [weak self] in self?.close() }, hovering: { [weak self] inside in self?.setHovering(inside) })) @@ -108,6 +108,7 @@ private struct CapturePreviewView: View { let image: NSImage let recognition: CaptureRecognition let operation: CaptureToolOperation + let copyMode: CaptureCopyMode let copiedResult: Bool let copyImage: () -> Void let saveImage: () -> Void @@ -116,7 +117,8 @@ private struct CapturePreviewView: View { let discard: () -> Void let hovering: (Bool) -> Void - private var output: String { recognition.output(for: .smart) } + private var output: String { recognition.output(for: copyMode) } + private var resultIsPrimary: Bool { operation == .read && !output.isEmpty } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -131,6 +133,11 @@ private struct CapturePreviewView: View { .font(.caption.weight(.semibold)) .foregroundStyle(.green) } + if !recognition.codes.isEmpty { + Label("\(recognition.codes.count)", systemImage: "qrcode") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } } Image(nsImage: image) .resizable() @@ -144,16 +151,28 @@ private struct CapturePreviewView: View { .lineLimit(4) .textSelection(.enabled) HStack(spacing: 8) { - Button("Copy image", action: copyImage) + Button(resultIsPrimary ? "Copy result" : "Copy image") { + resultIsPrimary ? copyResult() : copyImage() + } + .keyboardShortcut("c", modifiers: .command) + if resultIsPrimary { + Button("Copy image", action: copyImage) + .keyboardShortcut("c", modifiers: [.command, .shift]) + } else if !output.isEmpty { + Button("Copy result", action: copyResult) + .keyboardShortcut("c", modifiers: [.command, .shift]) + } Button("Save", action: saveImage) - if !output.isEmpty { Button("Copy result", action: copyResult) } + .keyboardShortcut("s", modifiers: .command) if recognition.codes.count == 1, CaptureRecognizedLink.openable(recognition.codes[0].payload) != nil { Button("Open", action: openResult) + .keyboardShortcut("o", modifiers: .command) } Spacer() Button("Discard", role: .destructive, action: discard) + .keyboardShortcut(.cancelAction) } .buttonStyle(.bordered) .controlSize(.small) diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift index 3950b9386..dee5c0e57 100644 --- a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift @@ -80,7 +80,7 @@ public enum CaptureRecognizedLink { guard !value.contains(where: { $0.isWhitespace }), let components = URLComponents(string: value), ["http", "https"].contains(components.scheme?.lowercased() ?? ""), - components.host?.isEmpty == false + components.host?.isEmpty == false, components.user == nil, components.password == nil else { return nil } return components.url } diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift index 7b29d8c6d..b85d25c98 100644 --- a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift @@ -96,15 +96,24 @@ public enum CaptureScreenshotArchive { ).first else { throw CaptureScreenshotError.saveFailed } let directory = pictures.appendingPathComponent("Edith Captures", isDirectory: true) + return try save(data, to: directory, now: now) + } + + public static func save(_ data: Data, to directory: URL, now: Date = Date()) throws -> URL { try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss" - let destination = - directory - .appendingPathComponent("Edith Capture \(formatter.string(from: now))") - .appendingPathExtension("png") + let name = "Edith Capture \(formatter.string(from: now))" + var destination = directory.appendingPathComponent(name).appendingPathExtension("png") + var suffix = 2 + while FileManager.default.fileExists(atPath: destination.path) { + destination = directory + .appendingPathComponent("\(name) \(suffix)") + .appendingPathExtension("png") + suffix += 1 + } do { try data.write(to: destination, options: .atomic) return destination diff --git a/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift b/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift index 967727853..5f2561684 100644 --- a/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift +++ b/Packages/Edith/Tests/EdithTests/CaptureRecognitionTests.swift @@ -55,5 +55,19 @@ import Testing #expect(CaptureRecognizedLink.openable("javascript:alert(1)") == nil) #expect(CaptureRecognizedLink.openable("https://edith.app/a b") == nil) #expect(CaptureRecognizedLink.openable("https:///missing-host") == nil) + #expect(CaptureRecognizedLink.openable("https://edith.app@example.com") == nil) + } + + @Test func archiveNeverOverwritesACaptureFromTheSameSecond() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("capture-archive-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date(timeIntervalSince1970: 1_700_000_000) + let first = try CaptureScreenshotArchive.save(Data([1]), to: directory, now: now) + let second = try CaptureScreenshotArchive.save(Data([2]), to: directory, now: now) + + #expect(first != second) + #expect(try Data(contentsOf: first) == Data([1])) + #expect(try Data(contentsOf: second) == Data([2])) } } From 345288eae34fad9805a86e9a1d20914738b9f5e2 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Thu, 27 Aug 2026 23:15:46 +0530 Subject: [PATCH 03/11] test: cover capture lifecycle and docs --- .../ViewModels/CaptureToolsStore.swift | 34 +++++++- .../EdithTests/CaptureToolsStoreTests.swift | 84 +++++++++++++++++++ docs/cli/capture/README.md | 3 + docs/cli/capture/read.md | 1 + docs/cli/capture/screenshot.md | 5 ++ docs/cli/config/README.md | 43 +++++++++- docs/cli/extensions/README.md | 17 +++- docs/cli/extensions/disable.md | 2 +- docs/cli/extensions/doctor.md | 4 +- docs/cli/extensions/enable.md | 4 +- docs/cli/extensions/info.md | 2 +- docs/cli/extensions/status.md | 2 +- docs/cli/getting-started/guide.md | 2 +- 13 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 Packages/Edith/Tests/EdithTests/CaptureToolsStoreTests.swift diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift index 0bbd9c0c0..972c72dae 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift @@ -3,13 +3,22 @@ import Carbon.HIToolbox import EdithKit import Observation +protocol CaptureScreenshotCapturing: Sendable { + func capture() async throws -> URL + func cancel() +} + +extension CaptureScreenshotSession: CaptureScreenshotCapturing {} + @MainActor @Observable final class CaptureToolsStore: FeatureModule { private(set) var history: [CaptureRecognition] = [] private(set) var inProgress = false private(set) var errorMessage: String? - @ObservationIgnored private let session = CaptureScreenshotSession() + @ObservationIgnored private let session: any CaptureScreenshotCapturing + @ObservationIgnored private let screenCaptureGranted: () -> Bool + @ObservationIgnored private let requestScreenCapture: () -> Void @ObservationIgnored private var task: Task? @ObservationIgnored private var readObserver: NSObjectProtocol? @ObservationIgnored private var screenshotObserver: NSObjectProtocol? @@ -17,7 +26,26 @@ final class CaptureToolsStore: FeatureModule { @ObservationIgnored private var generation = 0 init() { + session = CaptureScreenshotSession() + screenCaptureGranted = { CGPreflightScreenCaptureAccess() } + requestScreenCapture = { IPC.post(IPC.Name.grantScreenRecording) } + history = CaptureHistoryStore.load() + startObservers() + } + + init( + session: any CaptureScreenshotCapturing, + screenCaptureGranted: @escaping () -> Bool, + requestScreenCapture: @escaping () -> Void + ) { + self.session = session + self.screenCaptureGranted = screenCaptureGranted + self.requestScreenCapture = requestScreenCapture history = CaptureHistoryStore.load() + startObservers() + } + + private func startObservers() { readObserver = IPC.observe(IPC.Name.requestScreenRead) { [weak self] in self?.start(.read) } @@ -42,9 +70,9 @@ final class CaptureToolsStore: FeatureModule { cancel() return } - guard CGPreflightScreenCaptureAccess() else { + guard screenCaptureGranted() else { errorMessage = "Screen Recording access is required to capture the screen." - IPC.post(IPC.Name.grantScreenRecording) + requestScreenCapture() return } generation &+= 1 diff --git a/Packages/Edith/Tests/EdithTests/CaptureToolsStoreTests.swift b/Packages/Edith/Tests/EdithTests/CaptureToolsStoreTests.swift new file mode 100644 index 000000000..1205692a7 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/CaptureToolsStoreTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing + +@testable import EdithHelper +@testable import EdithKit + +private final class CaptureSessionProbe: CaptureScreenshotCapturing, @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var cancellationCount = 0 + + var started: Bool { + lock.withLock { continuation != nil } + } + + var cancellations: Int { + lock.withLock { cancellationCount } + } + + func capture() async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + lock.withLock { self.continuation = continuation } + } + } + + func cancel() { + let active = lock.withLock { () -> CheckedContinuation? in + cancellationCount += 1 + defer { continuation = nil } + return continuation + } + active?.resume(throwing: CaptureScreenshotError.cancelled) + } +} + +@Suite(.serialized) @MainActor struct CaptureToolsStoreTests { + @Test func missingPermissionRequestsAccessWithoutStartingCapture() { + let session = CaptureSessionProbe() + var requests = 0 + let store = CaptureToolsStore( + session: session, screenCaptureGranted: { false }, + requestScreenCapture: { requests += 1 }) + defer { store.shutdown() } + + store.start(.read) + + #expect(requests == 1) + #expect(!session.started) + #expect(!store.inProgress) + #expect(store.errorMessage != nil) + } + + @Test func cancellationStopsTheSessionAndClearsRuntimeState() async { + let session = CaptureSessionProbe() + let store = CaptureToolsStore( + session: session, screenCaptureGranted: { true }, requestScreenCapture: {}) + defer { store.shutdown() } + + store.start(.screenshot) + for _ in 0..<50 where !session.started { await Task.yield() } + store.cancel() + await Task.yield() + + #expect(session.started == false) + #expect(session.cancellations == 1) + #expect(!store.inProgress) + #expect(store.errorMessage == nil) + } + + @Test func shutdownCancelsAnActiveSession() async { + let session = CaptureSessionProbe() + let store = CaptureToolsStore( + session: session, screenCaptureGranted: { true }, requestScreenCapture: {}) + + store.start(.read) + for _ in 0..<50 where !session.started { await Task.yield() } + store.shutdown() + await Task.yield() + + #expect(session.started == false) + #expect(session.cancellations == 1) + #expect(!store.inProgress) + } +} diff --git a/docs/cli/capture/README.md b/docs/cli/capture/README.md index c33f1f7d4..8109c4c6d 100644 --- a/docs/cli/capture/README.md +++ b/docs/cli/capture/README.md @@ -10,6 +10,9 @@ result after a screen read, keeps a bounded recent-read history, and shows a transient preview with Copy image, Save, Copy result, and Discard actions. A single strict HTTP or HTTPS code also gets an explicit Open action. +The first capture requests Screen Recording access when it is missing. Recent +reads can be copied again or cleared from Capture Tools settings. + ## Commands | Command | What it does | diff --git a/docs/cli/capture/read.md b/docs/cli/capture/read.md index 741950a21..6059f842c 100644 --- a/docs/cli/capture/read.md +++ b/docs/cli/capture/read.md @@ -32,3 +32,4 @@ explicit alternatives. - [`ed capture`](./README.md) - [`ed capture screenshot`](./screenshot.md) - [`ed extensions enable`](../extensions/enable.md) +- [All `ed` commands](../README.md) diff --git a/docs/cli/capture/screenshot.md b/docs/cli/capture/screenshot.md index afc3d0f0e..13dd6dc33 100644 --- a/docs/cli/capture/screenshot.md +++ b/docs/cli/capture/screenshot.md @@ -24,8 +24,13 @@ and 4 when the extension is off or the menu bar app is not running. The preview closes after 12 seconds while it is not under the pointer. Save writes a PNG to `~/Pictures/Edith Captures`. +Command-C copies the primary result, Command-Shift-C copies the alternate text +or image result, Command-S saves, Command-O opens a single safe web code, and +Escape discards the preview. + ## Where to go next - [`ed capture`](./README.md) - [`ed capture read`](./read.md) - [`ed permissions`](../permissions/README.md) +- [All `ed` commands](../README.md) diff --git a/docs/cli/config/README.md b/docs/cli/config/README.md index cf58514c3..46ae193a6 100644 --- a/docs/cli/config/README.md +++ b/docs/cli/config/README.md @@ -1,7 +1,7 @@ # `ed config` Every preference the Edith UI writes is a key in the same defaults the app -reads, and `ed config` is that surface from the command line: 225 settings in 26 +reads, and `ed config` is that surface from the command line: 261 settings in 31 groups, each with a type, a default, a scope and a one-line summary. Reach for it when you want to know what a switch in Settings is actually called, flip one without opening the window, or move a whole setup to another Mac. @@ -296,6 +296,16 @@ not here cannot be set, and `import` skips it. | `clipboardWindowPositionX` | number | none | shared | Last clipboard panel x position. | | `clipboardWindowPositionY` | number | none | shared | Last clipboard panel y position. | +### `findertools` + +| Key | Type | Default | Scope | What it controls | +| --- | --- | --- | --- | --- | +| `finderToolsEnabled` | bool | `false` | shared | Finder Tools extension: file shortcuts and disk image installs. | +| `finderToolsCutPaste` | bool | `true` | shared | Move Finder selections with Command-X and Command-V. | +| `finderToolsRename` | bool | `true` | shared | Rename the Finder selection with F2. | +| `finderToolsPasteImages` | bool | `true` | shared | Save copied images as PNG files with Command-V in Finder. | +| `finderToolsDiskImageInstaller` | bool | `true` | shared | Offer to install the single app found on a mounted disk image. | + ### `notch` | Key | Type | Default | Scope | What it controls | @@ -329,6 +339,25 @@ 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. | +### `windowtools` + +| Key | Type | Default | Scope | What it controls | +| --- | --- | --- | --- | --- | +| `windowToolsEnabled` | bool | `false` | shared | Window Tools extension: arrange the active window. | +| `windowToolsGreenButtonMaximizes` | bool | `true` | shared | Make the green window button maximize without another Space. | +| `windowToolsLeftHotKeyCode` | int | `123` | shared | Virtual key code of the left-half shortcut. | +| `windowToolsLeftHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the left-half shortcut. | +| `windowToolsLeftHotKeyLabel` | string | `⌃⌥←` | shared | Printable label for the left-half shortcut. | +| `windowToolsRightHotKeyCode` | int | `124` | shared | Virtual key code of the right-half shortcut. | +| `windowToolsRightHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the right-half shortcut. | +| `windowToolsRightHotKeyLabel` | string | `⌃⌥→` | shared | Printable label for the right-half shortcut. | +| `windowToolsMaximizeHotKeyCode` | int | `46` | shared | Virtual key code of the maximize shortcut. | +| `windowToolsMaximizeHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the maximize shortcut. | +| `windowToolsMaximizeHotKeyLabel` | string | `⌃⌥M` | shared | Printable label for the maximize shortcut. | +| `windowToolsRestoreHotKeyCode` | int | `15` | shared | Virtual key code of the restore shortcut. | +| `windowToolsRestoreHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the restore shortcut. | +| `windowToolsRestoreHotKeyLabel` | string | `⌃⌥R` | shared | Printable label for the restore shortcut. | + ### `presenter` | Key | Type | Default | Scope | What it controls | @@ -364,6 +393,18 @@ not here cannot be set, and `import` skips it. | `colorPickerHotKeyMods` | int | none | shared | Carbon modifier mask of the colour picker shortcut. | | `colorPickerHotKeyLabel` | string | none | shared | Printable label for the colour picker shortcut. | +### `emoji` + +| Key | Type | Default | Scope | What it controls | +| --- | --- | --- | --- | --- | +| `emojiEnabled` | bool | `false` | shared | Emoji Picker extension: every macOS emoji on a hotkey. | +| `emojiPopupAt` | string: `cursor`, `statusItem`, `window`, `center`, `lastPosition` | `cursor` | shared | Where the emoji picker opens. | +| `emojiSkinTone` | int: 0 through 5 | `0` | shared | Default skin tone index applied to emoji that support one. | +| `emojiFrequentCount` | int: 0 through 24 | `10` | shared | How many frequently used emoji pin to the top of the picker. | +| `emojiHotKeyCode` | int | none | shared | Virtual key code of the emoji picker shortcut. | +| `emojiHotKeyMods` | int | none | shared | Carbon modifier mask of the emoji picker shortcut. | +| `emojiHotKeyLabel` | string | none | shared | Printable label for the emoji picker shortcut. | + ### `capture` | Key | Type | Default | Scope | What it controls | diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index 14be8bc1a..905f9dfd9 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 eighteen | +| `ed extensions status [id]` | Summarises readiness for one extension or all twenty-one | | `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 eighteen, with recovery commands | +| `ed extensions doctor [id]` | Diagnoses one extension or all twenty-one, 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 EdithKit is the single list every command here -walks, and its order is the order `ls` prints. Eighteen entries, in this order: +walks, and its order is the order `ls` prints. Twenty-one entries, in this order: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -70,12 +70,15 @@ walks, and its order is the order `ls` prints. Eighteen entries, in this order: | `calendar` | Calendar | Media | Shows your schedule in the panel and the app | | `notchShelf` | Notch Shelf | Media | File shelf, now playing, camera, and alerts around the notch | | `clipboard` | Clipboard | Utilities | Clipboard history with instant paste | +| `finderTools` | Finder Tools | Utilities | Cut and paste, F2 rename, image paste, and safe DMG installs | | `focusDim` | Focus Dim | Utilities | Dims everything behind your active app | | `presenter` | Presenter | Utilities | Blurs sensitive numbers while sharing your screen | +| `emoji` | Emoji Picker | Utilities | Every macOS emoji on a hotkey, straight into the app you are typing in | | `colorPicker` | Color Picker | Utilities | System loupe on a hotkey, sampled color to your clipboard | +| `windowTools` | Window Tools | Utilities | Snap, center, restore, and maximize windows without another Space | | `captureTools` | Capture Tools | Utilities | Offline screen OCR, QR recognition, and quick screenshots | -The same eighteen, with what each one is made of. `Key` is the preference the app +The same twenty-one, 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 eight the welcome tour shows before you ask it for all of them. @@ -95,9 +98,12 @@ eight the welcome tour shows before you ask it for all of them. | `calendar` | `tabCalendarEnabled` | no | `calendar` | none | none | none | | `notchShelf` | `notchShelfEnabled` | yes | none | `applicationAudio`, `bluetooth`, `camera`, `automation` | none | none | | `clipboard` | `clipboardEnabled` | yes | none | `accessibility` | none | none | +| `finderTools` | `finderToolsEnabled` | no | `accessibility` | `automation` | none | none | | `focusDim` | `focusDimEnabled` | no | `screenRecording` | none | none | none | | `presenter` | `presenterEnabled` | no | `screenRecording` | none | none | none | +| `emoji` | `emojiEnabled` | no | none | `accessibility` | none | none | | `colorPicker` | `colorPickerEnabled` | no | `screenRecording` | none | none | none | +| `windowTools` | `windowToolsEnabled` | no | `accessibility` | none | none | none | | `captureTools` | `captureToolsEnabled` | no | `screenRecording` | none | none | none | The JSON form also exposes the platform capability registry. Capabilities are @@ -120,9 +126,12 @@ the current platform, and which missing implementations merely degrade it: | `calendar` | `calendarEvents` | none | | `notchShelf` | `fileShelf` | `applicationAudio`, `bluetoothMonitoring`, `cameraPreview`, `externalMediaControl` | | `clipboard` | `clipboardHistory` | `globalPaste`, `globalShortcuts` | +| `finderTools` | `globalShortcuts`, `runningApplications` | none | | `focusDim` | `windowDimming` | none | | `presenter` | `screenShareDetection` | none | +| `emoji` | `emojiInsertion` | `globalShortcuts` | | `colorPicker` | `screenColorSampling` | `globalShortcuts` | +| `windowTools` | `windowManagement` | `globalShortcuts` | | `captureTools` | `screenContentRecognition` | `globalShortcuts` | An id is matched exactly and case-insensitively against the `ID` column first, diff --git a/docs/cli/extensions/disable.md b/docs/cli/extensions/disable.md index d065ca17e..24b8fa760 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 eighteen ids, or a defaults key | required | The extension to turn off | +| `id` | one of the twenty-one ids, 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 981439cb5..25018771a 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 eighteen. +Diagnoses setup and runtime problems for one extension or all twenty-one. ``` 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 eighteen extensions have an explicit live adapter. A missing adapter is +All twenty-one 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 74043dc18..a3aeb2cd5 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 eighteen ids, or a defaults key | required | The extension to turn on | +| `id` | one of the twenty-one ids, or a defaults key | required | The extension to turn on | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | @@ -75,7 +75,7 @@ known id as the hint: ``` $ ed extensions enable clipbored error: no extension named clipbored -hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, focusDim, presenter, colorPicker, captureTools +hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, finderTools, focusDim, presenter, emoji, colorPicker, windowTools, captureTools ``` Enabling never asks for a permission and never installs a tool. Agent Usage diff --git a/docs/cli/extensions/info.md b/docs/cli/extensions/info.md index fea168e37..e5b8d83f7 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 eighteen ids, or a defaults key | required | The extension to describe | +| `id` | one of the twenty-one ids, or a defaults key | required | The extension to describe | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/status.md b/docs/cli/extensions/status.md index 01a209a41..4b077b4fd 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 eighteen ids, or a defaults key | all extensions | Limit the report to one extension | +| `id` | one of the twenty-one ids, 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 e662215b7..fb135247d 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 eighteen extensions noninteractively. +up, verify, and recover all twenty-one extensions noninteractively. Any topic other than `agent` exits 3 and lists the discovery forms: From b4f128064afd28a5e55c7f69c44bf61fb9a01c1f Mon Sep 17 00:00:00 2001 From: Pulkit Date: Sat, 29 Aug 2026 00:08:49 +0530 Subject: [PATCH 04/11] fix: align capture CLI contracts --- .../Edith/Features/Settings/Views/CaptureToolsRows.swift | 2 +- .../Edith/Sources/EdithCLI/Commands/CaptureCommands.swift | 3 ++- Packages/Edith/Sources/EdithCLI/Guide.swift | 2 ++ Packages/Edith/Tests/EdithTests/CLICaptureTests.swift | 1 + Packages/Edith/Tests/EdithTests/CLIContractTests.swift | 4 ++++ Packages/Edith/Tests/EdithTests/CLIShapeTests.swift | 2 +- docs/cli/capture/README.md | 2 ++ 7 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift index f3e59d383..f5cc706cb 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/CaptureToolsRows.swift @@ -99,7 +99,7 @@ struct CaptureToolsRows: View { history = [] IPC.post(IPC.Name.settingsChanged) } - .buttonStyle(.plain) + .buttonStyle(.edith(.borderless)) } } } diff --git a/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift b/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift index 4a2f101c5..096978308 100644 --- a/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift +++ b/Packages/Edith/Sources/EdithCLI/Commands/CaptureCommands.swift @@ -5,7 +5,8 @@ struct CaptureCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "capture", abstract: "Offline screen recognition and quick screenshots.", - subcommands: [CaptureReadCommand.self, CaptureScreenshotCommand.self]) + subcommands: [CaptureReadCommand.self, CaptureScreenshotCommand.self], + defaultSubcommand: CaptureReadCommand.self) } enum CaptureCommandBridge { diff --git a/Packages/Edith/Sources/EdithCLI/Guide.swift b/Packages/Edith/Sources/EdithCLI/Guide.swift index 274d55b4d..f2debc928 100644 --- a/Packages/Edith/Sources/EdithCLI/Guide.swift +++ b/Packages/Edith/Sources/EdithCLI/Guide.swift @@ -38,6 +38,8 @@ public enum Guide { ed permissions ls every macOS permission Edith uses ed color pick open Edith's system colour sampler ed color copy 1 --format hex + ed capture read copy text or codes from a selected screen region + ed capture screenshot open a quick preview of a selected screen region ed usage sources the agents that produced your usage history ed schema JSON Schema for the config document ed version the CLI version, and whether the app is up diff --git a/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift b/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift index 51ade5dd4..41928e899 100644 --- a/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLICaptureTests.swift @@ -5,6 +5,7 @@ import Testing @Suite struct CLICaptureTests { @Test func parsesReadAndScreenshotLeaves() throws { + #expect(try EdRoot.parseAsRoot(["capture"]) is CaptureReadCommand) #expect(try EdRoot.parseAsRoot(["capture", "read"]) is CaptureReadCommand) #expect( try EdRoot.parseAsRoot(["capture", "screenshot", "--json"]) diff --git a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift index 9a362dc42..1c94f16c2 100644 --- a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift @@ -150,6 +150,10 @@ enum JSONContract { "ed color copy", ["color", "copy", "1", "--json"], mutatesTheMachine: true), JSONCase("ed color clear", ["color", "clear", "--json"]), + JSONCase("ed capture read", ["capture", "read", "--json"], mutatesTheMachine: true), + JSONCase( + "ed capture screenshot", ["capture", "screenshot", "--json"], + mutatesTheMachine: true), JSONCase("ed shelf ls", ["shelf", "ls", "--json"]), JSONCase("ed shelf path", ["shelf", "path", "1", "--json"]), JSONCase("ed shelf open", ["shelf", "open", "999999", "--json"]), diff --git a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift index b69181212..40cc861d8 100644 --- a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift @@ -230,7 +230,7 @@ enum CommandCrawler { "ed machines terminal", "ed lid-awake", "ed attention", "ed attention categories", "ed attention focus", - "ed quinjet", + "ed quinjet", "ed capture", ] for walk in Self.commands where !exempt.contains(walk.label) { #expect( diff --git a/docs/cli/capture/README.md b/docs/cli/capture/README.md index 8109c4c6d..6035e4cb4 100644 --- a/docs/cli/capture/README.md +++ b/docs/cli/capture/README.md @@ -20,6 +20,8 @@ reads can be copied again or cleared from Capture Tools settings. | `ed capture read` | Selects screen content, recognizes text and codes, and copies the configured result. | | `ed capture screenshot` | Selects screen content and opens the lightweight screenshot preview. | +Bare `ed capture` runs `ed capture read`. + - [`ed capture read`](./read.md) - [`ed capture screenshot`](./screenshot.md) From 157c561e76ec159559a48565aa732076b09b3120 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Thu, 27 Aug 2026 23:36:20 +0530 Subject: [PATCH 05/11] fix: bound capture process lifecycle --- .../ViewModels/CaptureToolsStore.swift | 13 +- .../Views/CapturePreviewController.swift | 5 +- .../Models/CaptureRecognition.swift | 3 +- .../Services/CaptureScreenshotSession.swift | 155 +++++++++++++----- 4 files changed, 128 insertions(+), 48 deletions(-) diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift index 972c72dae..ee72370dd 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/ViewModels/CaptureToolsStore.swift @@ -123,13 +123,15 @@ final class CaptureToolsStore: FeatureModule { let url = try await session.capture() temporaryURL = url guard !Task.isCancelled, generation == token else { return } - let data = try Data(contentsOf: url) - let image = try CaptureScreenshotImage.load(url) let detectsCodes = SharedDefaults.store.object(forKey: AppStorageKeys.Capture.detectCodes) as? Bool ?? true - let recognition = try await Task.detached(priority: .userInitiated) { - try CaptureRecognizer.recognize(image, detectCodes: detectsCodes) + let (data, recognition) = try await Task.detached(priority: .userInitiated) { + let data = try Data(contentsOf: url) + let image = try CaptureScreenshotImage.load(url) + let recognition = try CaptureRecognizer.recognize( + image, detectCodes: detectsCodes) + return (data, recognition) }.value guard !Task.isCancelled, generation == token else { return } let result = try finalize(recognition, data: data, operation: operation) @@ -166,7 +168,8 @@ final class CaptureToolsStore: FeatureModule { codes: recognition.codes, imagePath: savedPath) if operation == .read { let raw = - SharedDefaults.store.object(forKey: AppStorageKeys.Capture.historySize) as? Int ?? 10 + SharedDefaults.store.object(forKey: AppStorageKeys.Capture.historySize) as? Int + ?? 10 CaptureHistoryStore.add(result, limit: min(max(raw, 1), 25)) history = CaptureHistoryStore.load() IPC.post(IPC.Name.settingsChanged) diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift index 569d42513..2d8bb5bed 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift @@ -125,8 +125,9 @@ private struct CapturePreviewView: View { HStack { Label( operation == .read ? "Screen read" : "Screenshot", - systemImage: operation == .read ? "text.viewfinder" : "camera.viewfinder") - .font(.headline) + systemImage: operation == .read ? "text.viewfinder" : "camera.viewfinder" + ) + .font(.headline) Spacer() if copiedResult { Label("Copied", systemImage: "checkmark.circle.fill") diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift index dee5c0e57..e5dad3908 100644 --- a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Models/CaptureRecognition.swift @@ -93,7 +93,8 @@ public enum CaptureRecognizer { let accurateRequest = textRequest(level: .accurate, detectsLanguage: true) let codeRequest = VNDetectBarcodesRequest() codeRequest.symbologies = [.qr, .microQR, .aztec, .dataMatrix, .pdf417] - let requests: [VNRequest] = detectCodes ? [codeRequest, accurateRequest] : [accurateRequest] + let requests: [VNRequest] = + detectCodes ? [codeRequest, accurateRequest] : [accurateRequest] try VNImageRequestHandler(cgImage: image, options: [:]).perform(requests) var lines = sortedText(accurateRequest.results ?? []) diff --git a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift index b85d25c98..b2ab777c0 100644 --- a/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift +++ b/Packages/Edith/Sources/EdithKit/Features/CaptureTools/Services/CaptureScreenshotSession.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Foundation public enum CaptureScreenshotError: LocalizedError, Equatable { @@ -19,7 +20,9 @@ public enum CaptureScreenshotError: LocalizedError, Equatable { public final class CaptureScreenshotSession: @unchecked Sendable { private let lock = NSLock() - private var process: Process? + private var active = false + private var processID: pid_t? + private var cancellationRequested = false public init() {} @@ -27,59 +30,130 @@ public final class CaptureScreenshotSession: @unchecked Sendable { let url = FileManager.default.temporaryDirectory .appendingPathComponent("edith-capture-\(UUID().uuidString)") .appendingPathExtension("png") + try reserve() + let worker = Task.detached(priority: .userInitiated) { [self] in + try captureSynchronously(at: url) + } return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") - process.arguments = ["-i", "-x", url.path] - process.terminationHandler = { [weak self] completed in - self?.release(completed) - if completed.terminationStatus == 0, - FileManager.default.fileExists(atPath: url.path) - { - continuation.resume(returning: url) - } else { - try? FileManager.default.removeItem(at: url) - let error: CaptureScreenshotError = - completed.terminationStatus == 1 - ? .cancelled : .captureFailed(completed.terminationStatus) - continuation.resume(throwing: error) - } - } - do { - try prepare(process) - guard !Task.isCancelled else { - release(process) - throw CaptureScreenshotError.cancelled - } - try process.run() - } catch { - release(process) - try? FileManager.default.removeItem(at: url) - continuation.resume(throwing: error) - } - } + try await worker.value } onCancel: { + worker.cancel() cancel() } } public func cancel() { - let active = lock.withLock { process } - if active?.isRunning == true { active?.terminate() } + let processID = lock.withLock { + cancellationRequested = true + return self.processID + } + if let processID { _ = kill(-processID, SIGTERM) } } - private func prepare(_ process: Process) throws { + private func reserve() throws { try lock.withLock { - guard self.process == nil else { throw CaptureScreenshotError.busy } - self.process = process + guard !active else { throw CaptureScreenshotError.busy } + active = true + cancellationRequested = false } } - private func release(_ process: Process) { + private func release(_ processID: pid_t?) { lock.withLock { - if self.process === process { self.process = nil } + if processID == nil || self.processID == processID { + active = false + self.processID = nil + cancellationRequested = false + } + } + } + + private func captureSynchronously(at url: URL) throws -> URL { + var launchedProcessID: pid_t? + var completed = false + defer { + release(launchedProcessID) + if !completed { try? FileManager.default.removeItem(at: url) } + } + let processID = try spawn( + executable: "/usr/sbin/screencapture", arguments: ["-i", "-x", url.path]) + launchedProcessID = processID + let shouldCancel = lock.withLock { + self.processID = processID + return cancellationRequested + } + if shouldCancel || Task.isCancelled { _ = kill(-processID, SIGTERM) } + let status = try waitForExit(processID) + let cancelled = lock.withLock { cancellationRequested } || Task.isCancelled + if cancelled { throw CaptureScreenshotError.cancelled } + guard status == 0 else { + throw status == 1 + ? CaptureScreenshotError.cancelled : CaptureScreenshotError.captureFailed(status) + } + guard FileManager.default.fileExists(atPath: url.path) else { + throw CaptureScreenshotError.saveFailed + } + completed = true + return url + } + + private func waitForExit(_ processID: pid_t) throws -> Int32 { + var waitStatus: Int32 = 0 + var cancellationDeadline: Date? + while true { + let result = waitpid(processID, &waitStatus, WNOHANG) + if result == processID { return terminationStatus(waitStatus) } + if result == -1, errno != EINTR { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + let cancelled = lock.withLock { cancellationRequested } || Task.isCancelled + if cancelled { + if cancellationDeadline == nil { + _ = kill(-processID, SIGTERM) + cancellationDeadline = Date().addingTimeInterval(0.5) + } else if let cancellationDeadline, Date() >= cancellationDeadline { + _ = kill(-processID, SIGKILL) + } + } + Thread.sleep(forTimeInterval: 0.01) + } + } + + private func spawn(executable: String, arguments: [String]) throws -> pid_t { + var attributes: posix_spawnattr_t? + var processID: pid_t = 0 + let initStatus = posix_spawnattr_init(&attributes) + guard initStatus == 0 else { throw posixError(initStatus) } + defer { posix_spawnattr_destroy(&attributes) } + let flagsStatus = posix_spawnattr_setflags( + &attributes, Int16(POSIX_SPAWN_SETPGROUP)) + guard flagsStatus == 0 else { throw posixError(flagsStatus) } + let groupStatus = posix_spawnattr_setpgroup(&attributes, 0) + guard groupStatus == 0 else { throw posixError(groupStatus) } + let storage = ([executable] + arguments).map { strdup($0) } + guard storage.allSatisfy({ $0 != nil }) else { + storage.compactMap { $0 }.forEach { free($0) } + throw CocoaError(.fileWriteOutOfSpace) + } + defer { storage.compactMap { $0 }.forEach { free($0) } } + var pointers = storage + [nil] + let spawnStatus = executable.withCString { executablePath in + pointers.withUnsafeMutableBufferPointer { buffer in + posix_spawn( + &processID, executablePath, nil, &attributes, buffer.baseAddress, environ) + } } + guard spawnStatus == 0 else { throw posixError(spawnStatus) } + return processID + } + + private func terminationStatus(_ waitStatus: Int32) -> Int32 { + let signal = waitStatus & 0x7f + return signal == 0 ? (waitStatus >> 8) & 0xff : signal + } + + private func posixError(_ status: Int32) -> NSError { + NSError(domain: NSPOSIXErrorDomain, code: Int(status)) } } @@ -109,7 +183,8 @@ public enum CaptureScreenshotArchive { var destination = directory.appendingPathComponent(name).appendingPathExtension("png") var suffix = 2 while FileManager.default.fileExists(atPath: destination.path) { - destination = directory + destination = + directory .appendingPathComponent("\(name) \(suffix)") .appendingPathExtension("png") suffix += 1 From b4bae5802f64927752dfc1f58223df4e2c41adf8 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Thu, 27 Aug 2026 23:50:22 +0530 Subject: [PATCH 06/11] docs: sync extension and settings catalogs --- docs/cli/config/README.md | 4 +++- docs/cli/extensions/README.md | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/cli/config/README.md b/docs/cli/config/README.md index 46ae193a6..7ce6a9c11 100644 --- a/docs/cli/config/README.md +++ b/docs/cli/config/README.md @@ -1,7 +1,7 @@ # `ed config` Every preference the Edith UI writes is a key in the same defaults the app -reads, and `ed config` is that surface from the command line: 261 settings in 31 +reads, and `ed config` is that surface from the command line: 263 settings in 31 groups, each with a type, a default, a scope and a one-line summary. Reach for it when you want to know what a switch in Settings is actually called, flip one without opening the window, or move a whole setup to another Mac. @@ -227,6 +227,8 @@ not here cannot be set, and `import` skips it. | `SUScheduledCheckInterval` | number | `86400` | standard | Seconds between scheduled update checks. | | `SUAutomaticallyUpdate` | bool | `true` | standard | Download and install updates automatically. | | `tabSystemEnabled` | bool | `false` | shared | System extension: running apps, prevent sleep and the cleaning lock. | +| `appMaintenanceEnabled` | bool | `false` | shared | App Maintenance extension: inventory, installation and safe removal. | +| `appMaintenanceInstallDestination` | string: `user`, `system` | `user` | shared | Default disk image installation destination. | | `preventSleep` | bool | `false` | shared | Keep the Mac awake (Keep Awake). | | `lidAwakeEnabled` | bool | `false` | shared | Enable the Lid Awake extension. | | `lidAwakeRestoreOnQuit` | bool | `true` | shared | Restore normal lid-close sleep when Edith quits. | diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index 905f9dfd9..4f942b327 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-one | +| `ed extensions status [id]` | Summarises readiness for one extension or all twenty-two | | `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-one, with recovery commands | +| `ed extensions doctor [id]` | Diagnoses one extension or all twenty-two, 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 EdithKit is the single list every command here -walks, and its order is the order `ls` prints. Twenty-one entries, in this order: +walks, and its order is the order `ls` prints. Twenty-two entries, in this order: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -61,6 +61,7 @@ walks, and its order is the order `ls` prints. Twenty-one entries, in this order | `herdr` | Herdr | Agent | Live Herdr sessions on this Mac and your SSH machines | | `quinjet` | Quinjet | Agent | Pull request and live workspace review in a native terminal | | `system` | System | System | Running apps, prevent sleep, and the keyboard-cleaning lock | +| `appMaintenance` | App Maintenance | System | Verified disk image installs, app updates, and review-first removal | | `machines` | Machines | System | Your other computers over SSH: stats, files, Docker, and a terminal | | `companion` | Companion | Agent | Your notes, voice memos and activity, remembered and searchable | | `systemStats` | CPU & Memory in menu bar | System | Live CPU and memory readout as a menu bar item | @@ -78,9 +79,9 @@ walks, and its order is the order `ls` prints. Twenty-one entries, in this order | `windowTools` | Window Tools | Utilities | Snap, center, restore, and maximize windows without another Space | | `captureTools` | Capture Tools | Utilities | Offline screen OCR, QR recognition, and quick screenshots | -The same twenty-one, with what each one is made of. `Key` is the preference the app +The same twenty-two, 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 -eight the welcome tour shows before you ask it for all of them. +nine the welcome tour shows before you ask it for all of them. | ID | Key | Featured | Required permissions | Optional permissions | Required tools | Optional tools | | --- | --- | --- | --- | --- | --- | --- | @@ -89,6 +90,7 @@ eight the welcome tour shows before you ask it for all of them. | `herdr` | `tabHerdrEnabled` | yes | none | none | none | none | | `quinjet` | `tabQuinjetEnabled` | yes | none | none | `quinjet` | none | | `system` | `tabSystemEnabled` | yes | none | `accessibility`, `inputMonitoring` | none | none | +| `appMaintenance` | `appMaintenanceEnabled` | yes | none | none | none | none | | `machines` | `tabMachinesEnabled` | yes | none | `notifications` | none | none | | `companion` | `tabCompanionEnabled` | no | none | none | none | none | | `systemStats` | `menuBarSystemStats` | no | none | none | none | none | @@ -117,6 +119,7 @@ the current platform, and which missing implementations merely degrade it: | `herdr` | `herdrSessions` | none | | `quinjet` | `localTerminal` | none | | `system` | `runningApplications` | `preventSleep`, `inputSuppression` | +| `appMaintenance` | `runningApplications` | none | | `machines` | `machineManagement` | `notifications` | | `companion` | `companionService` | none | | `systemStats` | `systemMetrics` | none | From f43781b872ca94cb9e5cc95180f5cd08f208b876 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Fri, 28 Aug 2026 23:49:13 +0530 Subject: [PATCH 07/11] fix: integrate capture tools with current extensions --- .../EdithTests/HotKeyDefaultsTests.swift | 17 ++++++++++ docs/cli/config/README.md | 33 +------------------ docs/cli/extensions/README.md | 19 +++-------- docs/cli/extensions/disable.md | 2 +- docs/cli/extensions/doctor.md | 4 +-- docs/cli/extensions/enable.md | 4 +-- docs/cli/extensions/info.md | 2 +- docs/cli/extensions/status.md | 2 +- 8 files changed, 30 insertions(+), 53 deletions(-) diff --git a/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift b/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift index dde9df954..adf705761 100644 --- a/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift +++ b/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift @@ -69,4 +69,21 @@ import Testing #expect(ClipboardHotKey.label == "⌃⌥V") } } + + @Test func globalHotKeyIdentifiersAreUnique() { + let identifiers = [ + GlobalHotKey.ID.panel, + GlobalHotKey.ID.clipboard, + GlobalHotKey.ID.notchShelf, + GlobalHotKey.ID.focusDim, + GlobalHotKey.ID.colorPicker, + GlobalHotKey.ID.micMute, + GlobalHotKey.ID.presenterToggle, + GlobalHotKey.ID.emoji, + GlobalHotKey.ID.captureRead, + GlobalHotKey.ID.captureScreenshot, + ] + + #expect(Set(identifiers).count == identifiers.count) + } } diff --git a/docs/cli/config/README.md b/docs/cli/config/README.md index 7ce6a9c11..140fc6a87 100644 --- a/docs/cli/config/README.md +++ b/docs/cli/config/README.md @@ -1,7 +1,7 @@ # `ed config` Every preference the Edith UI writes is a key in the same defaults the app -reads, and `ed config` is that surface from the command line: 263 settings in 31 +reads, and `ed config` is that surface from the command line: 237 settings in 29 groups, each with a type, a default, a scope and a one-line summary. Reach for it when you want to know what a switch in Settings is actually called, flip one without opening the window, or move a whole setup to another Mac. @@ -227,8 +227,6 @@ not here cannot be set, and `import` skips it. | `SUScheduledCheckInterval` | number | `86400` | standard | Seconds between scheduled update checks. | | `SUAutomaticallyUpdate` | bool | `true` | standard | Download and install updates automatically. | | `tabSystemEnabled` | bool | `false` | shared | System extension: running apps, prevent sleep and the cleaning lock. | -| `appMaintenanceEnabled` | bool | `false` | shared | App Maintenance extension: inventory, installation and safe removal. | -| `appMaintenanceInstallDestination` | string: `user`, `system` | `user` | shared | Default disk image installation destination. | | `preventSleep` | bool | `false` | shared | Keep the Mac awake (Keep Awake). | | `lidAwakeEnabled` | bool | `false` | shared | Enable the Lid Awake extension. | | `lidAwakeRestoreOnQuit` | bool | `true` | shared | Restore normal lid-close sleep when Edith quits. | @@ -298,16 +296,6 @@ not here cannot be set, and `import` skips it. | `clipboardWindowPositionX` | number | none | shared | Last clipboard panel x position. | | `clipboardWindowPositionY` | number | none | shared | Last clipboard panel y position. | -### `findertools` - -| Key | Type | Default | Scope | What it controls | -| --- | --- | --- | --- | --- | -| `finderToolsEnabled` | bool | `false` | shared | Finder Tools extension: file shortcuts and disk image installs. | -| `finderToolsCutPaste` | bool | `true` | shared | Move Finder selections with Command-X and Command-V. | -| `finderToolsRename` | bool | `true` | shared | Rename the Finder selection with F2. | -| `finderToolsPasteImages` | bool | `true` | shared | Save copied images as PNG files with Command-V in Finder. | -| `finderToolsDiskImageInstaller` | bool | `true` | shared | Offer to install the single app found on a mounted disk image. | - ### `notch` | Key | Type | Default | Scope | What it controls | @@ -341,25 +329,6 @@ 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. | -### `windowtools` - -| Key | Type | Default | Scope | What it controls | -| --- | --- | --- | --- | --- | -| `windowToolsEnabled` | bool | `false` | shared | Window Tools extension: arrange the active window. | -| `windowToolsGreenButtonMaximizes` | bool | `true` | shared | Make the green window button maximize without another Space. | -| `windowToolsLeftHotKeyCode` | int | `123` | shared | Virtual key code of the left-half shortcut. | -| `windowToolsLeftHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the left-half shortcut. | -| `windowToolsLeftHotKeyLabel` | string | `⌃⌥←` | shared | Printable label for the left-half shortcut. | -| `windowToolsRightHotKeyCode` | int | `124` | shared | Virtual key code of the right-half shortcut. | -| `windowToolsRightHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the right-half shortcut. | -| `windowToolsRightHotKeyLabel` | string | `⌃⌥→` | shared | Printable label for the right-half shortcut. | -| `windowToolsMaximizeHotKeyCode` | int | `46` | shared | Virtual key code of the maximize shortcut. | -| `windowToolsMaximizeHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the maximize shortcut. | -| `windowToolsMaximizeHotKeyLabel` | string | `⌃⌥M` | shared | Printable label for the maximize shortcut. | -| `windowToolsRestoreHotKeyCode` | int | `15` | shared | Virtual key code of the restore shortcut. | -| `windowToolsRestoreHotKeyMods` | int | `6144` | shared | Carbon modifier mask of the restore shortcut. | -| `windowToolsRestoreHotKeyLabel` | string | `⌃⌥R` | shared | Printable label for the restore shortcut. | - ### `presenter` | Key | Type | Default | Scope | What it controls | diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index 4f942b327..fb2986ba5 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 nineteen | | `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 nineteen, 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 EdithKit 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. Nineteen entries, in this order: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -61,7 +61,6 @@ walks, and its order is the order `ls` prints. Twenty-two entries, in this order | `herdr` | Herdr | Agent | Live Herdr sessions on this Mac and your SSH machines | | `quinjet` | Quinjet | Agent | Pull request and live workspace review in a native terminal | | `system` | System | System | Running apps, prevent sleep, and the keyboard-cleaning lock | -| `appMaintenance` | App Maintenance | System | Verified disk image installs, app updates, and review-first removal | | `machines` | Machines | System | Your other computers over SSH: stats, files, Docker, and a terminal | | `companion` | Companion | Agent | Your notes, voice memos and activity, remembered and searchable | | `systemStats` | CPU & Memory in menu bar | System | Live CPU and memory readout as a menu bar item | @@ -71,17 +70,15 @@ walks, and its order is the order `ls` prints. Twenty-two entries, in this order | `calendar` | Calendar | Media | Shows your schedule in the panel and the app | | `notchShelf` | Notch Shelf | Media | File shelf, now playing, camera, and alerts around the notch | | `clipboard` | Clipboard | Utilities | Clipboard history with instant paste | -| `finderTools` | Finder Tools | Utilities | Cut and paste, F2 rename, image paste, and safe DMG installs | | `focusDim` | Focus Dim | Utilities | Dims everything behind your active app | | `presenter` | Presenter | Utilities | Blurs sensitive numbers while sharing your screen | | `emoji` | Emoji Picker | Utilities | Every macOS emoji on a hotkey, straight into the app you are typing in | | `colorPicker` | Color Picker | Utilities | System loupe on a hotkey, sampled color to your clipboard | -| `windowTools` | Window Tools | Utilities | Snap, center, restore, and maximize windows without another Space | | `captureTools` | Capture Tools | Utilities | Offline screen OCR, QR recognition, and quick screenshots | -The same twenty-two, with what each one is made of. `Key` is the preference the app +The same nineteen, 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 -nine the welcome tour shows before you ask it for all of them. +eight the welcome tour shows before you ask it for all of them. | ID | Key | Featured | Required permissions | Optional permissions | Required tools | Optional tools | | --- | --- | --- | --- | --- | --- | --- | @@ -90,7 +87,6 @@ nine the welcome tour shows before you ask it for all of them. | `herdr` | `tabHerdrEnabled` | yes | none | none | none | none | | `quinjet` | `tabQuinjetEnabled` | yes | none | none | `quinjet` | none | | `system` | `tabSystemEnabled` | yes | none | `accessibility`, `inputMonitoring` | none | none | -| `appMaintenance` | `appMaintenanceEnabled` | yes | none | none | none | none | | `machines` | `tabMachinesEnabled` | yes | none | `notifications` | none | none | | `companion` | `tabCompanionEnabled` | no | none | none | none | none | | `systemStats` | `menuBarSystemStats` | no | none | none | none | none | @@ -100,12 +96,10 @@ nine the welcome tour shows before you ask it for all of them. | `calendar` | `tabCalendarEnabled` | no | `calendar` | none | none | none | | `notchShelf` | `notchShelfEnabled` | yes | none | `applicationAudio`, `bluetooth`, `camera`, `automation` | none | none | | `clipboard` | `clipboardEnabled` | yes | none | `accessibility` | none | none | -| `finderTools` | `finderToolsEnabled` | no | `accessibility` | `automation` | none | none | | `focusDim` | `focusDimEnabled` | no | `screenRecording` | none | none | none | | `presenter` | `presenterEnabled` | no | `screenRecording` | none | none | none | | `emoji` | `emojiEnabled` | no | none | `accessibility` | none | none | | `colorPicker` | `colorPickerEnabled` | no | `screenRecording` | none | none | none | -| `windowTools` | `windowToolsEnabled` | no | `accessibility` | none | none | none | | `captureTools` | `captureToolsEnabled` | no | `screenRecording` | none | none | none | The JSON form also exposes the platform capability registry. Capabilities are @@ -119,7 +113,6 @@ the current platform, and which missing implementations merely degrade it: | `herdr` | `herdrSessions` | none | | `quinjet` | `localTerminal` | none | | `system` | `runningApplications` | `preventSleep`, `inputSuppression` | -| `appMaintenance` | `runningApplications` | none | | `machines` | `machineManagement` | `notifications` | | `companion` | `companionService` | none | | `systemStats` | `systemMetrics` | none | @@ -129,12 +122,10 @@ the current platform, and which missing implementations merely degrade it: | `calendar` | `calendarEvents` | none | | `notchShelf` | `fileShelf` | `applicationAudio`, `bluetoothMonitoring`, `cameraPreview`, `externalMediaControl` | | `clipboard` | `clipboardHistory` | `globalPaste`, `globalShortcuts` | -| `finderTools` | `globalShortcuts`, `runningApplications` | none | | `focusDim` | `windowDimming` | none | | `presenter` | `screenShareDetection` | none | | `emoji` | `emojiInsertion` | `globalShortcuts` | | `colorPicker` | `screenColorSampling` | `globalShortcuts` | -| `windowTools` | `windowManagement` | `globalShortcuts` | | `captureTools` | `screenContentRecognition` | `globalShortcuts` | An id is matched exactly and case-insensitively against the `ID` column first, diff --git a/docs/cli/extensions/disable.md b/docs/cli/extensions/disable.md index 24b8fa760..734b704c1 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-one ids, or a defaults key | required | The extension to turn off | +| `id` | one of the nineteen ids, 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 25018771a..f88053007 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-one. +Diagnoses setup and runtime problems for one extension or all nineteen. ``` 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-one extensions have an explicit live adapter. A missing adapter is +All nineteen 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 a3aeb2cd5..457c933a4 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-one ids, or a defaults key | required | The extension to turn on | +| `id` | one of the nineteen ids, or a defaults key | required | The extension to turn on | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | @@ -75,7 +75,7 @@ known id as the hint: ``` $ ed extensions enable clipbored error: no extension named clipbored -hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, finderTools, focusDim, presenter, emoji, colorPicker, windowTools, captureTools +hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, focusDim, presenter, emoji, colorPicker, captureTools ``` Enabling never asks for a permission and never installs a tool. Agent Usage diff --git a/docs/cli/extensions/info.md b/docs/cli/extensions/info.md index e5b8d83f7..b53012c95 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-one ids, or a defaults key | required | The extension to describe | +| `id` | one of the nineteen ids, or a defaults key | required | The extension to describe | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/status.md b/docs/cli/extensions/status.md index 4b077b4fd..67560a6ba 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-one ids, or a defaults key | all extensions | Limit the report to one extension | +| `id` | one of the nineteen ids, or a defaults key | all extensions | Limit the report to one extension | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | From a87d72cfd2780e03a67d47b474e2edbed3c1e798 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Sat, 29 Aug 2026 00:15:47 +0530 Subject: [PATCH 08/11] fix: integrate capture tools after emoji removal --- .../Features/Settings/Views/ShortcutsPane.swift | 3 ++- .../EdithCoreTests/ExtensionRegistryTests.swift | 2 +- .../Tests/EdithTests/HotKeyDefaultsTests.swift | 1 - docs/cli/config/README.md | 14 +------------- docs/cli/extensions/README.md | 11 ++++------- docs/cli/extensions/disable.md | 2 +- docs/cli/extensions/doctor.md | 4 ++-- docs/cli/extensions/enable.md | 4 ++-- docs/cli/extensions/info.md | 2 +- docs/cli/extensions/status.md | 2 +- 10 files changed, 15 insertions(+), 30 deletions(-) diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift index e8b3bc747..136a1c23a 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift @@ -23,7 +23,8 @@ struct ShortcutsSettingsPane: View { private var extensionShortcuts: [ExtensionShortcut] { ExtensionShortcutVisibility.visible( clipboard: clipboardEnabled, micMute: micMuteEnabled, focusDim: focusDimEnabled, - colorPicker: colorPickerEnabled, captureTools: captureToolsEnabled) + presenter: presenterEnabled, colorPicker: colorPickerEnabled, + captureTools: captureToolsEnabled) } var body: some View { diff --git a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift index e4dc45d68..1f2c1b55b 100644 --- a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift @@ -15,7 +15,7 @@ import Testing ExtensionRegistry.entries.map(\.id) == [ "attention", "usage", "herdr", "quinjet", "system", "machines", "companion", "systemStats", "micMute", "lidAwake", "music", "calendar", "notchShelf", - "clipboard", "focusDim", "presenter", "colorPicker", + "clipboard", "focusDim", "presenter", "colorPicker", "captureTools", ]) } diff --git a/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift b/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift index adf705761..6b79fb033 100644 --- a/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift +++ b/Packages/Edith/Tests/EdithTests/HotKeyDefaultsTests.swift @@ -79,7 +79,6 @@ import Testing GlobalHotKey.ID.colorPicker, GlobalHotKey.ID.micMute, GlobalHotKey.ID.presenterToggle, - GlobalHotKey.ID.emoji, GlobalHotKey.ID.captureRead, GlobalHotKey.ID.captureScreenshot, ] diff --git a/docs/cli/config/README.md b/docs/cli/config/README.md index 140fc6a87..3268e3472 100644 --- a/docs/cli/config/README.md +++ b/docs/cli/config/README.md @@ -1,7 +1,7 @@ # `ed config` Every preference the Edith UI writes is a key in the same defaults the app -reads, and `ed config` is that surface from the command line: 237 settings in 29 +reads, and `ed config` is that surface from the command line: 230 settings in 28 groups, each with a type, a default, a scope and a one-line summary. Reach for it when you want to know what a switch in Settings is actually called, flip one without opening the window, or move a whole setup to another Mac. @@ -364,18 +364,6 @@ not here cannot be set, and `import` skips it. | `colorPickerHotKeyMods` | int | none | shared | Carbon modifier mask of the colour picker shortcut. | | `colorPickerHotKeyLabel` | string | none | shared | Printable label for the colour picker shortcut. | -### `emoji` - -| Key | Type | Default | Scope | What it controls | -| --- | --- | --- | --- | --- | -| `emojiEnabled` | bool | `false` | shared | Emoji Picker extension: every macOS emoji on a hotkey. | -| `emojiPopupAt` | string: `cursor`, `statusItem`, `window`, `center`, `lastPosition` | `cursor` | shared | Where the emoji picker opens. | -| `emojiSkinTone` | int: 0 through 5 | `0` | shared | Default skin tone index applied to emoji that support one. | -| `emojiFrequentCount` | int: 0 through 24 | `10` | shared | How many frequently used emoji pin to the top of the picker. | -| `emojiHotKeyCode` | int | none | shared | Virtual key code of the emoji picker shortcut. | -| `emojiHotKeyMods` | int | none | shared | Carbon modifier mask of the emoji picker shortcut. | -| `emojiHotKeyLabel` | string | none | shared | Printable label for the emoji picker shortcut. | - ### `capture` | Key | Type | Default | Scope | What it controls | diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index fb2986ba5..14be8bc1a 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 nineteen | +| `ed extensions status [id]` | Summarises readiness for one extension or all eighteen | | `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 nineteen, with recovery commands | +| `ed extensions doctor [id]` | Diagnoses one extension or all eighteen, 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 EdithKit is the single list every command here -walks, and its order is the order `ls` prints. Nineteen entries, in this order: +walks, and its order is the order `ls` prints. Eighteen entries, in this order: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -72,11 +72,10 @@ walks, and its order is the order `ls` prints. Nineteen entries, in this order: | `clipboard` | Clipboard | Utilities | Clipboard history with instant paste | | `focusDim` | Focus Dim | Utilities | Dims everything behind your active app | | `presenter` | Presenter | Utilities | Blurs sensitive numbers while sharing your screen | -| `emoji` | Emoji Picker | Utilities | Every macOS emoji on a hotkey, straight into the app you are typing in | | `colorPicker` | Color Picker | Utilities | System loupe on a hotkey, sampled color to your clipboard | | `captureTools` | Capture Tools | Utilities | Offline screen OCR, QR recognition, and quick screenshots | -The same nineteen, with what each one is made of. `Key` is the preference the app +The same eighteen, 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 eight the welcome tour shows before you ask it for all of them. @@ -98,7 +97,6 @@ eight the welcome tour shows before you ask it for all of them. | `clipboard` | `clipboardEnabled` | yes | none | `accessibility` | none | none | | `focusDim` | `focusDimEnabled` | no | `screenRecording` | none | none | none | | `presenter` | `presenterEnabled` | no | `screenRecording` | none | none | none | -| `emoji` | `emojiEnabled` | no | none | `accessibility` | none | none | | `colorPicker` | `colorPickerEnabled` | no | `screenRecording` | none | none | none | | `captureTools` | `captureToolsEnabled` | no | `screenRecording` | none | none | none | @@ -124,7 +122,6 @@ the current platform, and which missing implementations merely degrade it: | `clipboard` | `clipboardHistory` | `globalPaste`, `globalShortcuts` | | `focusDim` | `windowDimming` | none | | `presenter` | `screenShareDetection` | none | -| `emoji` | `emojiInsertion` | `globalShortcuts` | | `colorPicker` | `screenColorSampling` | `globalShortcuts` | | `captureTools` | `screenContentRecognition` | `globalShortcuts` | diff --git a/docs/cli/extensions/disable.md b/docs/cli/extensions/disable.md index 734b704c1..d065ca17e 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 nineteen ids, or a defaults key | required | The extension to turn off | +| `id` | one of the eighteen ids, 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 f88053007..981439cb5 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 nineteen. +Diagnoses setup and runtime problems for one extension or all eighteen. ``` 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 nineteen extensions have an explicit live adapter. A missing adapter is +All eighteen 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 457c933a4..74043dc18 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 nineteen ids, or a defaults key | required | The extension to turn on | +| `id` | one of the eighteen ids, or a defaults key | required | The extension to turn on | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | @@ -75,7 +75,7 @@ known id as the hint: ``` $ ed extensions enable clipbored error: no extension named clipbored -hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, focusDim, presenter, emoji, colorPicker, captureTools +hint: known ids: attention, usage, herdr, quinjet, system, machines, companion, systemStats, micMute, lidAwake, music, calendar, notchShelf, clipboard, focusDim, presenter, colorPicker, captureTools ``` Enabling never asks for a permission and never installs a tool. Agent Usage diff --git a/docs/cli/extensions/info.md b/docs/cli/extensions/info.md index b53012c95..fea168e37 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 nineteen ids, or a defaults key | required | The extension to describe | +| `id` | one of the eighteen ids, or a defaults key | required | The extension to describe | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | diff --git a/docs/cli/extensions/status.md b/docs/cli/extensions/status.md index 67560a6ba..01a209a41 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 nineteen ids, or a defaults key | all extensions | Limit the report to one extension | +| `id` | one of the eighteen ids, or a defaults key | all extensions | Limit the report to one extension | | Option | Type / values | Default | What it does | | --- | --- | --- | --- | From ffe3344e65e18f5491f067a1d1b5ccf3dbcd783d Mon Sep 17 00:00:00 2001 From: Pulkit Date: Tue, 8 Sep 2026 02:13:12 +0530 Subject: [PATCH 09/11] Verify capture completion metadata, hotkeys, and synthetic OCR preview --- .../Settings/Views/ShortcutsPane.swift | 3 +- .../Edith/Sources/EdithCLI/CommandTree.swift | 3 ++ .../Core/Application/AppServices.swift | 2 +- .../Views/CapturePreviewController.swift | 2 +- .../Operations/UserOperationCatalog.swift | 38 ++++++++------- .../Tests/EdithTests/CLIContractTests.swift | 3 +- .../Tests/EdithTests/CLIShapeTests.swift | 3 +- .../EdithTests/CaptureToolsRenderTests.swift | 47 +++++++++++++++++++ .../EdithTests/ExtensionRegistryTests.swift | 2 +- .../Tests/EdithTests/FeatureGatesTests.swift | 2 +- .../Tests/EdithTests/HotKeyCatalogTests.swift | 2 +- 11 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 Packages/Edith/Tests/EdithTests/CaptureToolsRenderTests.swift diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift index 9a140c1fd..5dfd48ce9 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift @@ -29,7 +29,8 @@ struct ShortcutsSettingsPane: View { ExtensionShortcutVisibility.visible( clipboard: clipboardEnabled, emoji: emojiEnabled, micMute: micMuteEnabled, focusDim: focusDimEnabled, presenter: presenterEnabled, - colorPicker: colorPickerEnabled, keystrokeHighlight: keystrokeHighlightEnabled, captureTools: captureToolsEnabled) + colorPicker: colorPickerEnabled, keystrokeHighlight: keystrokeHighlightEnabled, + captureTools: captureToolsEnabled) } var body: some View { diff --git a/Packages/Edith/Sources/EdithCLI/CommandTree.swift b/Packages/Edith/Sources/EdithCLI/CommandTree.swift index 24a69be77..9095f5456 100644 --- a/Packages/Edith/Sources/EdithCLI/CommandTree.swift +++ b/Packages/Edith/Sources/EdithCLI/CommandTree.swift @@ -135,6 +135,9 @@ public enum CommandTree { typealias Spec = CommandSpec static let specs: [String: Spec] = [ + "ed capture read": Spec(options: ["--json", "--help"]), + "ed capture screenshot": Spec(options: ["--json", "--help"]), + "ed capture": Spec(options: ["--json", "--help"]), "ed": Spec(options: ["--help", "--version"]), "ed guide": Spec(options: ["--json"], arguments: [.guideTopic]), "ed version": Spec(options: ["--json", "-h", "--help", "--version"]), diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift index 0c4a6fc18..a6248828c 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift @@ -290,7 +290,7 @@ final class AppServices { colorPicker?.registerHotKey() let captureToolsOn = - ExtensionRegistry.entry("captureTools")?.isEnabled() ?? false + ExtensionRegistry.entry("captureTools")?.isEnabled(in: SharedDefaults.store) ?? false if captureToolsOn, captureTools == nil { captureTools = CaptureToolsStore() } if !captureToolsOn, let store = captureTools { store.shutdown() diff --git a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift index 2d8bb5bed..d4839b65a 100644 --- a/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift +++ b/Packages/Edith/Sources/EdithHelper/Features/CaptureTools/Views/CapturePreviewController.swift @@ -104,7 +104,7 @@ final class CapturePreviewController: NSObject, NSWindowDelegate { } } -private struct CapturePreviewView: View { +struct CapturePreviewView: View { let image: NSImage let recognition: CaptureRecognition let operation: CaptureToolOperation diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift index 9acd724e7..39ede71ef 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift @@ -104,58 +104,60 @@ public enum UserOperationCatalog { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - private static let featureRegistrations: [RegisteredUserOperation] = - UsageProjectOperation.allCases.map { + private static let featureRegistrations: [RegisteredUserOperation] = { + var registrations = UsageProjectOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + ConfigurationOperation.allCases.map { + registrations += ConfigurationOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + TerminalToolingOperation.allCases.map { + registrations += TerminalToolingOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + PermissionOperation.allCases.map { + registrations += PermissionOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + RunningAppOperation.allCases.map { + registrations += RunningAppOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + ColorPickerOperation.allCases.map { + registrations += ColorPickerOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + ColorSwatchOperation.allCases.map { + registrations += ColorSwatchOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + CaptureToolOperation.allCases.map { + registrations += CaptureToolOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + EmojiOperation.allCases.map { + registrations += EmojiOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + CompanionSettingsOperation.allCases.map { + registrations += CompanionSettingsOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + ClipboardOperation.allCases.map { + registrations += ClipboardOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + AttentionFocusOperation.allCases.map { + registrations += AttentionFocusOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + CleanerOperation.allCases.map { + registrations += CleanerOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + HomebrewOperation.allCases.map { + registrations += HomebrewOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + AppMaintenanceOperation.allCases.map { + registrations += AppMaintenanceOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + DatabaseConnectionOperation.allCases.map { + registrations += DatabaseConnectionOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } - + WorkspaceOperation.allCases.map { + registrations += WorkspaceOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } + return registrations + }() private static let agentRegistrations: [RegisteredUserOperation] = AgentControlOperation.allCases.map { diff --git a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift index e6c42e72f..92304ce2b 100644 --- a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift @@ -366,7 +366,8 @@ enum JSONContract { "ed download reveal", ["download", "reveal", "1", "--json"], mutatesTheMachine: true), JSONCase("ed capture read", ["capture", "read", "--json"], mutatesTheMachine: true), - JSONCase("ed capture screenshot", ["capture", "screenshot", "--json"], mutatesTheMachine: true), + JSONCase( + "ed capture screenshot", ["capture", "screenshot", "--json"], mutatesTheMachine: true), JSONCase("ed color pick", ["color", "pick", "--json"], mutatesTheMachine: true), JSONCase("ed color ls", ["color", "ls", "--json"]), JSONCase( diff --git a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift index a1fe35fdd..c5fc50c55 100644 --- a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift @@ -221,7 +221,8 @@ enum CommandCrawler { "ed permissions", "ed usage", "ed system", "ed music", "ed calendar", "ed presenter", "ed herdr", "ed herdr bridge", - "ed machines", "ed __complete", "ed app", "ed clipboard", "ed color", "ed emoji", "ed capture", + "ed machines", "ed __complete", "ed app", "ed clipboard", "ed color", "ed emoji", + "ed capture", "ed shelf", "ed cleaner", "ed maintenance", "ed machines docker compose", "ed machines docker compose logs", "ed machines forwards", "ed machines snippets", "ed machines power", "ed machines thermal", diff --git a/Packages/Edith/Tests/EdithTests/CaptureToolsRenderTests.swift b/Packages/Edith/Tests/EdithTests/CaptureToolsRenderTests.swift new file mode 100644 index 000000000..e8a9ecd87 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/CaptureToolsRenderTests.swift @@ -0,0 +1,47 @@ +import AppKit +import Foundation +import SwiftUI +import Testing + +@testable import EdithHelper +@testable import EdithKit + +@Suite @MainActor struct CaptureToolsRenderTests { + @Test func recognizedSyntheticScreenRendersInPreview() throws { + let image = NSImage(size: NSSize(width: 640, height: 240)) + image.lockFocus() + NSColor.white.setFill() + NSRect(x: 0, y: 0, width: 640, height: 240).fill() + let style: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 32, weight: .medium), + .foregroundColor: NSColor.black, + ] + ("Project Atlas" as NSString).draw(at: NSPoint(x: 36, y: 156), withAttributes: style) + ("Design review at 10:30" as NSString).draw( + at: NSPoint(x: 36, y: 100), withAttributes: style) + ("Bring the launch checklist" as NSString).draw( + at: NSPoint(x: 36, y: 44), withAttributes: style) + image.unlockFocus() + let pixels = try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)) + let recognition = try CaptureRecognizer.recognize(pixels, detectCodes: false) + #expect(recognition.text.contains("Project Atlas")) + let view = CapturePreviewView( + image: image, recognition: recognition, operation: .read, copyMode: .smart, + copiedResult: true, copyImage: {}, saveImage: {}, copyResult: {}, openResult: {}, + discard: {}, hovering: { _ in } + ) + .frame(width: 440, height: 390) + .background(Color(nsColor: .windowBackgroundColor)) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: 440, height: 390) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + #expect(bitmap.pixelsWide >= 440) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("capture-tools.png")) + } + } +} diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 80cf87445..404894504 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -293,7 +293,7 @@ import Testing #expect(titleMatches.map(\.id) == ["audioMixer"]) #expect(subtitleMatches.map(\.id) == ["calendar"]) #expect(categoryMatches.allSatisfy { $0.suite == .desk }) - #expect(combinedMatches.map(\.id) == ["keystrokeHighlight", "presenter"]) + #expect(combinedMatches.map(\.id) == ["keystrokeHighlight", "presenter", "captureTools"]) #expect(attentionMatches.map(\.id) == ["attention"]) } diff --git a/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift b/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift index 5827a9d34..d745761e5 100644 --- a/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift +++ b/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift @@ -77,7 +77,7 @@ import Testing #expect( ExtensionShortcutVisibility.visible( clipboard: true, emoji: true, micMute: true, focusDim: true, presenter: true, - colorPicker: true, keystrokeHighlight: true) + colorPicker: true, keystrokeHighlight: true, captureTools: true) == ExtensionShortcut.allCases) } diff --git a/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift b/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift index 74f9de201..51e003fc9 100644 --- a/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift +++ b/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift @@ -18,7 +18,7 @@ import Testing let carbonIDs = HotKeyCatalog.bindings.map(\.carbonID) #expect(Set(ids).count == ids.count) #expect(Set(carbonIDs).count == carbonIDs.count) - #expect(ids.count == 8) + #expect(ids.count == 10) } @Test func everyBindingNamesItsOwnDefaultsKeys() { From 15bf77aab007d380b5683881598c8df882bbd4a1 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Tue, 8 Sep 2026 02:26:11 +0530 Subject: [PATCH 10/11] List capture commands in the CLI documentation index --- docs/cli/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli/README.md b/docs/cli/README.md index 03b326e08..8d9be42e1 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -61,6 +61,7 @@ report still exits 0, so read `verified`, `state.phase`, `state.runtimePhase`, | [`ed herdr`](./herdr/README.md) | Live Herdr sessions on this Mac and your SSH machines | | [`ed quinjet`](./quinjet/README.md) | Discover, inspect and launch Quinjet review workspaces locally or on a configured machine | | [`ed database`](./database/README.md) | Saved database connections and capability reports through the authenticated local broker | +| [`ed capture`](./capture/README.md) | Offline OCR and quick screenshots | | [`ed clipboard`](./clipboard/README.md) | Clipboard history: read, copy, pin and prune | | [`ed attention`](./attention/README.md) | Application, website, music, focus and distraction insight | | [`ed color`](./color/README.md) | Launching the colour picker and reading its swatch history | From f59b2e06b9f68a4603cc1b3074ef0bb28de5e3e8 Mon Sep 17 00:00:00 2001 From: Pulkit Date: Tue, 8 Sep 2026 03:08:22 +0530 Subject: [PATCH 11/11] test: include Capture Tools in disabled detail coverage --- Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index 41812fc37..379f62fd4 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -274,6 +274,7 @@ import Testing ("focusDim", "FocusDimRows", "enabled", "FocusDimRows.swift"), ("presenter", "PresenterRows", "presenterEnabled", "PresenterRows.swift"), ("colorPicker", "ColorPickerRows", "colorPickerEnabled", "ColorPickerRows.swift"), + ("captureTools", "CaptureToolsRows", "captureToolsEnabled", "CaptureToolsRows.swift"), ("emoji", "EmojiRows", "emojiEnabled", "EmojiRows.swift"), ("homebrew", "HomebrewRows", "enabled", "ExtensionsPane.swift"), ("cleaner", "CleanerRows", "enabled", "ExtensionsPane.swift"),