diff --git a/BetterShot.xcodeproj/project.pbxproj b/BetterShot.xcodeproj/project.pbxproj index 0408697..0c25b7b 100644 --- a/BetterShot.xcodeproj/project.pbxproj +++ b/BetterShot.xcodeproj/project.pbxproj @@ -51,6 +51,11 @@ F6A7B8C9D0E1F2A3B4C5D6E7 /* VideoEditorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7A8B9C0D1E2F3A4B5C6D7E /* VideoEditorWindowController.swift */; }; A7B8C9D0E1F2A3B4C5D6E7F8 /* VideoTrimTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B9C0D1E2F3A4B5C6D7E8F /* VideoTrimTimelineView.swift */; }; + 3A1AC46B479780A00184F954 /* BetterShotIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFE83018D77254765879687 /* BetterShotIntentError.swift */; }; + 920178D7D282D96DADE6C014 /* BetterShotShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6130A4277E056FA7A2C20DA1 /* BetterShotShortcuts.swift */; }; + 81CC71E889B51A44C66E0A11 /* OCRIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2DBF6CD975399F1CBFD72D /* OCRIntent.swift */; }; + 5F0749FC6B2FB5B592E90DB2 /* RecordingIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A4EEB3709E4D59CEFFC02D7 /* RecordingIntents.swift */; }; + EF44AAEBE1A592E8967D0BAF /* ScreenshotIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF98C9B0B7DE96D16131BB3B /* ScreenshotIntents.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -99,6 +104,11 @@ 6F7A8B9C0D1E2F3A4B5C6D7E /* VideoEditorWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEditorWindowController.swift; sourceTree = ""; }; 7A8B9C0D1E2F3A4B5C6D7E8F /* VideoTrimTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTrimTimelineView.swift; sourceTree = ""; }; + 5AFE83018D77254765879687 /* BetterShotIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterShotIntentError.swift; sourceTree = ""; }; + 6130A4277E056FA7A2C20DA1 /* BetterShotShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterShotShortcuts.swift; sourceTree = ""; }; + 6F2DBF6CD975399F1CBFD72D /* OCRIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRIntent.swift; sourceTree = ""; }; + 1A4EEB3709E4D59CEFFC02D7 /* RecordingIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingIntents.swift; sourceTree = ""; }; + BF98C9B0B7DE96D16131BB3B /* ScreenshotIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotIntents.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -232,6 +242,7 @@ 39D4566D88515C653CF4F4DB /* Models */, 99E8726305BC9AF4B98FA73B /* Preview */, D5E6F7A8B9C0D1E2F3A4B5C6 /* Recording */, + 5124CB9C3BB612D2FECA2047 /* Shortcuts */, 02E0E07267FD5201B631F82C /* Services */, E4007FFA622C92BA5142F86A /* Settings */, 4FE4E3ABA1756F22921DCB42 /* Views */, @@ -257,6 +268,18 @@ path = Settings; sourceTree = ""; }; + 5124CB9C3BB612D2FECA2047 /* Shortcuts */ = { + isa = PBXGroup; + children = ( + 5AFE83018D77254765879687 /* BetterShotIntentError.swift */, + 6130A4277E056FA7A2C20DA1 /* BetterShotShortcuts.swift */, + 6F2DBF6CD975399F1CBFD72D /* OCRIntent.swift */, + 1A4EEB3709E4D59CEFFC02D7 /* RecordingIntents.swift */, + BF98C9B0B7DE96D16131BB3B /* ScreenshotIntents.swift */, + ); + path = Shortcuts; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -364,6 +387,11 @@ E5F6A7B8C9D0E1F2A3B4C5D6 /* VideoEditorView.swift in Sources */, A7B8C9D0E1F2A3B4C5D6E7F8 /* VideoTrimTimelineView.swift in Sources */, F6A7B8C9D0E1F2A3B4C5D6E7 /* VideoEditorWindowController.swift in Sources */, + 3A1AC46B479780A00184F954 /* BetterShotIntentError.swift in Sources */, + 920178D7D282D96DADE6C014 /* BetterShotShortcuts.swift in Sources */, + 81CC71E889B51A44C66E0A11 /* OCRIntent.swift in Sources */, + 5F0749FC6B2FB5B592E90DB2 /* RecordingIntents.swift in Sources */, + EF44AAEBE1A592E8967D0BAF /* ScreenshotIntents.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d1fec..028f080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Better Shot will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Apple Shortcuts support**: Region/Fullscreen/Window screenshot capture, OCR text scan, and Start/Stop screen recording are now available as App Intents in the macOS Shortcuts app and Siri. Screenshot and recording results are returned as files (and OCR as text) so they can be chained into other Shortcuts steps + ## [0.3.7] - 2026-06-07 ### Added diff --git a/README.md b/README.md index 079f978..eefe870 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ Rectangles, filled rectangles, ellipses, lines, curved arrows, freehand, text, n - **In-app updates** — Check, download, and install updates without leaving the app - **Configurable overlay** — Choose preview position and auto-dismiss timing +### Shortcuts + +BetterShot's capture, OCR, and recording actions are available as App Intents in the macOS **Shortcuts** app and Siri — region/fullscreen/window screenshot, scan text, and start/stop screen recording. Screenshots and recordings are returned as files so you can chain them into other Shortcuts steps, e.g. *"Capture Region Screenshot" → "Save to Photos"* or *"Scan Text" → "Show Result"*. + ## Install ### Homebrew diff --git a/Sources/Capture/CaptureOrchestrator.swift b/Sources/Capture/CaptureOrchestrator.swift index 43e327c..4e8e195 100644 --- a/Sources/Capture/CaptureOrchestrator.swift +++ b/Sources/Capture/CaptureOrchestrator.swift @@ -1,6 +1,14 @@ import AppKit import SwiftUI +/// Result of a capture request, returned to programmatic callers (e.g. App Intents) +/// so they can chain the artifact (image file or extracted text) into other workflows. +enum CaptureResult { + case image(URL) + case text(String) + case none +} + /// Coordinates the full capture pipeline: hide window -> capture -> sound -> preview/editor. @MainActor @Observable @@ -9,55 +17,59 @@ final class CaptureOrchestrator { private(set) var lastCaptureURL: URL? private var captureInProgress = false - private var pendingCaptures: [(ShortcutService.Action, NSScreen?)] = [] + private var pendingCaptures: [(action: ShortcutService.Action, screen: NSScreen?, continuation: CheckedContinuation?)] = [] private var captureScreen: NSScreen? private init() {} - func performCapture(_ action: ShortcutService.Action, on screen: NSScreen? = nil) async { + @discardableResult + func performCapture(_ action: ShortcutService.Action, on screen: NSScreen? = nil) async -> CaptureResult { if captureInProgress { - pendingCaptures.append((action, screen)) - return + return await withCheckedContinuation { continuation in + pendingCaptures.append((action, screen, continuation)) + } } captureInProgress = true captureScreen = screen - await executeCapture(action) - while let (next, nextScreen) = pendingCaptures.first { + let result = await executeCapture(action) + while let next = pendingCaptures.first { pendingCaptures.removeFirst() - captureScreen = nextScreen - await executeCapture(next) + captureScreen = next.screen + let nextResult = await executeCapture(next.action) + next.continuation?.resume(returning: nextResult) } captureScreen = nil captureInProgress = false + return result } - private func executeCapture(_ action: ShortcutService.Action) async { + private func executeCapture(_ action: ShortcutService.Action) async -> CaptureResult { switch action { case .region: - await captureAndProcess { try await ScreenCapture.shared.captureRegion() } + return await captureAndProcess { try await ScreenCapture.shared.captureRegion() } case .fullscreen: - await captureAndProcess { try await ScreenCapture.shared.captureFullscreen() } + return await captureAndProcess { try await ScreenCapture.shared.captureFullscreen() } case .window: - await captureAndProcess { try await ScreenCapture.shared.captureWindow() } + return await captureAndProcess { try await ScreenCapture.shared.captureWindow() } case .ocr: - await performOCR() + return await performOCR() case .colorPicker: - await performColorPick() + return await performColorPick() case .recording: - break + return .none } } // MARK: - Private - private func captureAndProcess(_ capture: () async throws -> URL?) async { + private func captureAndProcess(_ capture: () async throws -> URL?) async -> CaptureResult { let delay = AppPreferences.selfTimerDelay if delay != .off { await CountdownOverlay.shared.showCountdown(seconds: delay.rawValue) } do { - guard let url = try await capture() else { return } + guard let url = try await capture() else { return .none } ScreenCapture.shared.playShutterSound() @@ -66,18 +78,20 @@ final class CaptureOrchestrator { lastCaptureURL = HistoryStore.shared.urlForRecord(record) } - guard let capturedURL = lastCaptureURL else { return } + guard let capturedURL = lastCaptureURL else { return .none } - await galleryApplyAndSave(capturedURL, recordID: record?.id) + let displayURL = await galleryApplyAndSave(capturedURL, recordID: record?.id) + return .image(displayURL ?? capturedURL) } catch { print("Capture failed: \(error.localizedDescription)") + return .none } } - private func performColorPick() async { + private func performColorPick() async -> CaptureResult { let overlay = ColorPickerOverlay() - guard let hex = await overlay.pickColor() else { return } + guard let hex = await overlay.pickColor() else { return .none } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(hex, forType: .string) @@ -88,11 +102,12 @@ final class CaptureOrchestrator { systemIcon: "eyedropper", on: captureScreen ) + return .text(hex) } - private func performOCR() async { + private func performOCR() async -> CaptureResult { do { - guard let text = try await ScreenCapture.shared.captureAndOCR() else { return } + guard let text = try await ScreenCapture.shared.captureAndOCR() else { return .none } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) @@ -103,19 +118,22 @@ final class CaptureOrchestrator { systemIcon: "doc.text.viewfinder", on: captureScreen ) + return .text(text) } catch { print("OCR failed: \(error.localizedDescription)") + return .none } } - private func galleryApplyAndSave(_ url: URL, recordID: UUID? = nil) async { + @discardableResult + private func galleryApplyAndSave(_ url: URL, recordID: UUID? = nil) async -> URL? { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return } + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return nil } let config = AppPreferences.defaultBeautifierConfig let rendered = BeautifierRenderer.render(image: cgImage, config: config) - guard let rendered else { return } + guard let rendered else { return nil } let savedURL = saveImage(rendered) @@ -143,6 +161,7 @@ final class CaptureOrchestrator { } PreviewOverlay.shared.show(url: displayURL, on: captureScreen) + return displayURL } private func saveImage(_ cgImage: CGImage) -> URL? { diff --git a/Sources/Shortcuts/BetterShotIntentError.swift b/Sources/Shortcuts/BetterShotIntentError.swift new file mode 100644 index 0000000..02b226b --- /dev/null +++ b/Sources/Shortcuts/BetterShotIntentError.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Errors surfaced to the Shortcuts app / Siri when an intent can't complete. +enum BetterShotIntentError: LocalizedError { + case captureFailed + case ocrFailed + case alreadyRecording + case notRecording + case recordingFailed + + var errorDescription: String? { + switch self { + case .captureFailed: + return "BetterShot couldn't capture the screenshot." + case .ocrFailed: + return "BetterShot couldn't extract any text from the selected region." + case .alreadyRecording: + return "BetterShot is already recording the screen." + case .notRecording: + return "BetterShot isn't currently recording the screen." + case .recordingFailed: + return "BetterShot couldn't start screen recording." + } + } +} diff --git a/Sources/Shortcuts/BetterShotShortcuts.swift b/Sources/Shortcuts/BetterShotShortcuts.swift new file mode 100644 index 0000000..8c7f4f3 --- /dev/null +++ b/Sources/Shortcuts/BetterShotShortcuts.swift @@ -0,0 +1,63 @@ +import AppIntents + +/// Surfaces BetterShot's capture, OCR, and recording actions to the Shortcuts app, +/// Spotlight, and Siri. The system discovers this provider automatically once the +/// app has launched — no Info.plist or entitlement changes are required. +struct BetterShotShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RegionScreenshotIntent(), + phrases: [ + "Capture a region screenshot with \(.applicationName)", + "Take a region screenshot in \(.applicationName)" + ], + shortTitle: "Region Screenshot", + systemImageName: "camera.viewfinder" + ) + AppShortcut( + intent: FullscreenScreenshotIntent(), + phrases: [ + "Capture a fullscreen screenshot with \(.applicationName)", + "Take a fullscreen screenshot in \(.applicationName)" + ], + shortTitle: "Fullscreen Screenshot", + systemImageName: "macwindow" + ) + AppShortcut( + intent: WindowScreenshotIntent(), + phrases: [ + "Capture a window screenshot with \(.applicationName)", + "Take a window screenshot in \(.applicationName)" + ], + shortTitle: "Window Screenshot", + systemImageName: "macwindow.on.rectangle" + ) + AppShortcut( + intent: ScanTextIntent(), + phrases: [ + "Scan text with \(.applicationName)", + "Extract text from screen with \(.applicationName)" + ], + shortTitle: "Scan Text", + systemImageName: "doc.text.viewfinder" + ) + AppShortcut( + intent: StartScreenRecordingIntent(), + phrases: [ + "Start a screen recording with \(.applicationName)", + "Start recording my screen with \(.applicationName)" + ], + shortTitle: "Start Recording", + systemImageName: "record.circle" + ) + AppShortcut( + intent: StopScreenRecordingIntent(), + phrases: [ + "Stop the screen recording in \(.applicationName)", + "Stop recording my screen with \(.applicationName)" + ], + shortTitle: "Stop Recording", + systemImageName: "stop.circle" + ) + } +} diff --git a/Sources/Shortcuts/OCRIntent.swift b/Sources/Shortcuts/OCRIntent.swift new file mode 100644 index 0000000..ef98708 --- /dev/null +++ b/Sources/Shortcuts/OCRIntent.swift @@ -0,0 +1,18 @@ +import AppIntents + +/// Lets you draw a region, runs OCR on it, and returns the extracted text so it can be +/// chained into other Shortcuts steps (e.g. "Show Result", "Add to Notes"). +struct ScanTextIntent: AppIntent { + static var title: LocalizedStringResource = "Scan Text from Screen" + static var description = IntentDescription( + "Lets you draw a region and extracts any text found inside it via OCR." + ) + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard case let .text(text) = await CaptureOrchestrator.shared.performCapture(.ocr) else { + throw BetterShotIntentError.ocrFailed + } + return .result(value: text) + } +} diff --git a/Sources/Shortcuts/RecordingIntents.swift b/Sources/Shortcuts/RecordingIntents.swift new file mode 100644 index 0000000..23bb116 --- /dev/null +++ b/Sources/Shortcuts/RecordingIntents.swift @@ -0,0 +1,51 @@ +import AppIntents +import UniformTypeIdentifiers + +/// Starts a fullscreen recording the same way the `⌘⇧2` shortcut does — including the +/// floating status bar and whatever audio settings are configured in Settings > Recording. +struct StartScreenRecordingIntent: AppIntent { + static var title: LocalizedStringResource = "Start Screen Recording" + static var description = IntentDescription( + "Starts recording your screen with BetterShot's current recording settings." + ) + + @MainActor + func perform() async throws -> some IntentResult { + guard !ScreenRecordingManager.shared.isRecording else { + throw BetterShotIntentError.alreadyRecording + } + + let started = (try? await ScreenRecordingManager.shared.startFullScreenRecording()) ?? false + guard started else { + throw BetterShotIntentError.recordingFailed + } + + RecordingStatusBarController.shared.show() + return .result() + } +} + +/// Stops the active recording and returns the resulting MP4 so it can be chained into +/// other Shortcuts steps (e.g. saved, shared, or sent for transcription). +struct StopScreenRecordingIntent: AppIntent { + static var title: LocalizedStringResource = "Stop Screen Recording" + static var description = IntentDescription( + "Stops the active BetterShot screen recording and returns the recorded video." + ) + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard ScreenRecordingManager.shared.isRecording else { + throw BetterShotIntentError.notRecording + } + + guard let url = await ScreenRecordingManager.shared.stopRecording() else { + throw BetterShotIntentError.recordingFailed + } + + let data = try Data(contentsOf: url) + let type = UTType(filenameExtension: url.pathExtension) ?? .mpeg4Movie + let file = IntentFile(data: data, filename: url.lastPathComponent, type: type) + return .result(value: file) + } +} diff --git a/Sources/Shortcuts/ScreenshotIntents.swift b/Sources/Shortcuts/ScreenshotIntents.swift new file mode 100644 index 0000000..b5d4337 --- /dev/null +++ b/Sources/Shortcuts/ScreenshotIntents.swift @@ -0,0 +1,55 @@ +import AppIntents +import UniformTypeIdentifiers + +/// Shared plumbing for the screenshot intents below: run a capture through the +/// orchestrator (same pipeline as the keyboard shortcuts) and hand back the saved +/// (beautified) image as an `IntentFile` the Shortcuts app can chain into other actions. +@MainActor +private func performScreenshotIntent(_ action: ShortcutService.Action) async throws -> IntentFile { + guard case let .image(url) = await CaptureOrchestrator.shared.performCapture(action) else { + throw BetterShotIntentError.captureFailed + } + + let data = try Data(contentsOf: url) + let type = UTType(filenameExtension: url.pathExtension) ?? .png + return IntentFile(data: data, filename: url.lastPathComponent, type: type) +} + +struct RegionScreenshotIntent: AppIntent { + static var title: LocalizedStringResource = "Capture Region Screenshot" + static var description = IntentDescription( + "Lets you draw a region and captures it with your default BetterShot effects applied." + ) + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let file = try await performScreenshotIntent(.region) + return .result(value: file) + } +} + +struct FullscreenScreenshotIntent: AppIntent { + static var title: LocalizedStringResource = "Capture Fullscreen Screenshot" + static var description = IntentDescription( + "Captures the entire screen with your default BetterShot effects applied." + ) + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let file = try await performScreenshotIntent(.fullscreen) + return .result(value: file) + } +} + +struct WindowScreenshotIntent: AppIntent { + static var title: LocalizedStringResource = "Capture Window Screenshot" + static var description = IntentDescription( + "Lets you pick a window and captures it with your default BetterShot effects applied." + ) + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let file = try await performScreenshotIntent(.window) + return .result(value: file) + } +}