Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions BetterShot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -99,6 +104,11 @@
6F7A8B9C0D1E2F3A4B5C6D7E /* VideoEditorWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEditorWindowController.swift; sourceTree = "<group>"; };
7A8B9C0D1E2F3A4B5C6D7E8F /* VideoTrimTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTrimTimelineView.swift; sourceTree = "<group>"; };

5AFE83018D77254765879687 /* BetterShotIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterShotIntentError.swift; sourceTree = "<group>"; };
6130A4277E056FA7A2C20DA1 /* BetterShotShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterShotShortcuts.swift; sourceTree = "<group>"; };
6F2DBF6CD975399F1CBFD72D /* OCRIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRIntent.swift; sourceTree = "<group>"; };
1A4EEB3709E4D59CEFFC02D7 /* RecordingIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingIntents.swift; sourceTree = "<group>"; };
BF98C9B0B7DE96D16131BB3B /* ScreenshotIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotIntents.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXGroup section */
Expand Down Expand Up @@ -232,6 +242,7 @@
39D4566D88515C653CF4F4DB /* Models */,
99E8726305BC9AF4B98FA73B /* Preview */,
D5E6F7A8B9C0D1E2F3A4B5C6 /* Recording */,
5124CB9C3BB612D2FECA2047 /* Shortcuts */,
02E0E07267FD5201B631F82C /* Services */,
E4007FFA622C92BA5142F86A /* Settings */,
4FE4E3ABA1756F22921DCB42 /* Views */,
Expand All @@ -257,6 +268,18 @@
path = Settings;
sourceTree = "<group>";
};
5124CB9C3BB612D2FECA2047 /* Shortcuts */ = {
isa = PBXGroup;
children = (
5AFE83018D77254765879687 /* BetterShotIntentError.swift */,
6130A4277E056FA7A2C20DA1 /* BetterShotShortcuts.swift */,
6F2DBF6CD975399F1CBFD72D /* OCRIntent.swift */,
1A4EEB3709E4D59CEFFC02D7 /* RecordingIntents.swift */,
BF98C9B0B7DE96D16131BB3B /* ScreenshotIntents.swift */,
);
path = Shortcuts;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 45 additions & 26 deletions Sources/Capture/CaptureOrchestrator.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<CaptureResult, Never>?)] = []
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()

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -143,6 +161,7 @@ final class CaptureOrchestrator {
}

PreviewOverlay.shared.show(url: displayURL, on: captureScreen)
return displayURL
}

private func saveImage(_ cgImage: CGImage) -> URL? {
Expand Down
25 changes: 25 additions & 0 deletions Sources/Shortcuts/BetterShotIntentError.swift
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
63 changes: 63 additions & 0 deletions Sources/Shortcuts/BetterShotShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
18 changes: 18 additions & 0 deletions Sources/Shortcuts/OCRIntent.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
guard case let .text(text) = await CaptureOrchestrator.shared.performCapture(.ocr) else {
throw BetterShotIntentError.ocrFailed
}
return .result(value: text)
}
}
Loading