Skip to content
Merged
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
37 changes: 18 additions & 19 deletions Packages/TRexCore/Sources/TRexCore/ShortcutsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,27 +71,26 @@ public class ShortcutsManager: ObservableObject {
let inputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("trex-shortcut-\(UUID().uuidString).txt")

Task.detached(priority: .utility) { [weak self] in
guard let self = self else { return }
defer { try? FileManager.default.removeItem(at: inputURL) }

do {
try inputText.write(to: inputURL, atomically: true, encoding: .utf8)
} catch {
return
}

let process = Process()
process.executableURL = self.shortcutsURL
process.arguments = ["run", shortcut, "-i", inputURL.path]
// Write the input and spawn synchronously: the CLI exits as soon as
// capture completes, so the child process must exist before this
// method returns or the shortcut never runs.
do {
try inputText.write(to: inputURL, atomically: true, encoding: .utf8)
} catch {
return
}

do {
try process.run()
} catch {
return
}
let process = Process()
process.executableURL = shortcutsURL
process.arguments = ["run", shortcut, "-i", inputURL.path]
process.terminationHandler = { _ in
try? FileManager.default.removeItem(at: inputURL)
}

process.waitUntilExit()
do {
try process.run()
} catch {
try? FileManager.default.removeItem(at: inputURL)
}
}

Expand Down
33 changes: 22 additions & 11 deletions Packages/TRexCore/Sources/TRexCore/TRexCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,23 +370,30 @@ public class TRex: NSObject {
return result
}

public func capture(_ mode: InvocationMode, imagePath: String? = nil) async {
/// Returns true if text was captured and processed, false otherwise
/// (e.g. the user cancelled, no text was recognized, or a capture is already in progress).
@discardableResult
public func capture(_ mode: InvocationMode, imagePath: String? = nil) async -> Bool {
switch mode {
case .captureMultiRegion, .captureMultiRegionAndTriggerAutomation:
await captureMultiRegion(mode)
return await captureMultiRegion(mode)
default:
await captureSingle(mode, imagePath: imagePath)
return await captureSingle(mode, imagePath: imagePath)
}
}

private func captureSingle(_ mode: InvocationMode, imagePath: String? = nil) async {
guard beginCaptureTransaction() else { return }
private func captureSingle(_ mode: InvocationMode, imagePath: String? = nil) async -> Bool {
guard beginCaptureTransaction() else { return false }
defer { endCaptureTransaction() }

currentInvocationMode = mode

guard let ocrResult = await getText(imagePath) else { return }
guard var text = await recognizeAndProcessOCR(from: ocrResult) else { return }
guard let ocrResult = await getText(imagePath) else { return false }
guard var text = await recognizeAndProcessOCR(from: ocrResult) else { return false }
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
logger.info("⚠️ No text recognized in capture")
return false
}

// Apply LLM post-processing if enabled (runs after table detection)
if preferences.llmEnablePostProcessing, let postProcessor = llmPostProcessor {
Expand All @@ -400,17 +407,18 @@ public class TRex: NSObject {
}

processDetectedText(text, ocrResult: ocrResult)
return true
}

/// Capture multiple screen regions in a loop, OCR each one, and combine results.
/// The loop continues until the user cancels (presses Escape) in the screencapture UI,
/// or the maximum region limit is reached.
private static let maxMultiRegionCaptures = 50

private func captureMultiRegion(_ mode: InvocationMode) async {
private func captureMultiRegion(_ mode: InvocationMode) async -> Bool {
var allTexts: [String] = []

guard beginCaptureTransaction() else { return }
guard beginCaptureTransaction() else { return false }
defer { endCaptureTransaction() }

currentInvocationMode = mode
Expand All @@ -423,7 +431,9 @@ public class TRex: NSObject {
continue
}

if let text = await recognizeAndProcessOCR(cgImage) {
if let text = await recognizeAndProcessOCR(cgImage),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
allTexts.append(text)
}
}
Expand All @@ -432,7 +442,7 @@ public class TRex: NSObject {
logger.warning("⚠️ Multi-region capture reached maximum of \(Self.maxMultiRegionCaptures, privacy: .public) regions")
}

guard !allTexts.isEmpty else { return }
guard !allTexts.isEmpty else { return false }
var combined = allTexts.joined(separator: "\n\n")

// Apply LLM post-processing once on the combined text
Expand All @@ -445,6 +455,7 @@ public class TRex: NSObject {
}

processDetectedText(combined)
return true
}

/// Run OCR on a CGImage and apply table detection if enabled.
Expand Down
67 changes: 67 additions & 0 deletions Packages/TRexCore/Tests/TRexCoreTests/BugRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,73 @@ final class BugRegressionTests: XCTestCase {
XCTAssertEqual(urls.map(\.absoluteString), ["https://example.com/account"])
}

// capture() must report completion status so the CLI can exit
// with a proper exit code instead of hanging (issue #93).
@MainActor
func testCaptureReturnsFalseWhenImageFileIsMissing() async {
let trex = TRex()
let missingPath = FileManager.default.temporaryDirectory
.appendingPathComponent("trex-missing-\(UUID().uuidString).png").path

let success = await trex.capture(.captureFromFile, imagePath: missingPath)

XCTAssertFalse(success)
}

@MainActor
func testCaptureReturnsFalseWhenCaptureAlreadyInProgress() async throws {
let trex = TRex()
// A readable image with real text ensures this test fails only if the
// in-progress guard is broken, not because the image can't be loaded.
let imagePath = try Self.writeTemporaryImage(text: "Hello TRex")
defer { try? FileManager.default.removeItem(atPath: imagePath) }
XCTAssertTrue(trex.beginCaptureTransaction())
defer { trex.endCaptureTransaction() }

let success = await trex.capture(.captureFromFile, imagePath: imagePath)

XCTAssertFalse(success)
}

@MainActor
func testCaptureReturnsFalseWhenNoTextRecognized() async throws {
let trex = TRex()
let imagePath = try Self.writeTemporaryImage(text: nil)
defer { try? FileManager.default.removeItem(atPath: imagePath) }

let success = await trex.capture(.captureFromFile, imagePath: imagePath)

XCTAssertFalse(success)
}

@MainActor
private static func writeTemporaryImage(text: String?) throws -> String {
let size = NSSize(width: 240, height: 80)
let image = NSImage(size: size)
image.lockFocus()
NSColor.white.setFill()
NSRect(origin: .zero, size: size).fill()
if let text {
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 32),
.foregroundColor: NSColor.black,
]
(text as NSString).draw(at: NSPoint(x: 10, y: 20), withAttributes: attributes)
}
image.unlockFocus()

guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
throw NSError(domain: "BugRegressionTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to render test image"])
}
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("trex-test-\(UUID().uuidString).png")
try png.write(to: url)
return url.path
}

@MainActor
func testWatchOutputRejectsSiblingPathWithHomePrefix() {
let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL
Expand Down
12 changes: 10 additions & 2 deletions TRex CMD/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,23 @@ struct trex: AsyncParsableCommand {
mode = automation ? .captureScreenAndTriggerAutomation : .captureScreen
}

await trexInstance.capture(mode, imagePath: imagePath)
let success = await trexInstance.capture(mode, imagePath: imagePath)
guard success else {
throw ExitCode.failure
}
}
}

if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) {
Task {
// main() exits the process itself on failure via exit(withError:);
// on success it returns here, so terminate explicitly.
await trex.main()
Darwin.exit(EXIT_SUCCESS)
}
dispatchMain()
// Keep the main thread alive and servicing the main run loop so
// @MainActor work runs until the capture task terminates the process.
RunLoop.main.run()
} else {
fatalError("This tool requires macOS 10.15 or later")
}
Loading