diff --git a/Packages/TRexCore/Sources/TRexCore/ShortcutsManager.swift b/Packages/TRexCore/Sources/TRexCore/ShortcutsManager.swift index ac1f0c4..d5f8766 100644 --- a/Packages/TRexCore/Sources/TRexCore/ShortcutsManager.swift +++ b/Packages/TRexCore/Sources/TRexCore/ShortcutsManager.swift @@ -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) } } diff --git a/Packages/TRexCore/Sources/TRexCore/TRexCore.swift b/Packages/TRexCore/Sources/TRexCore/TRexCore.swift index 2fa7820..17fc9b0 100644 --- a/Packages/TRexCore/Sources/TRexCore/TRexCore.swift +++ b/Packages/TRexCore/Sources/TRexCore/TRexCore.swift @@ -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 { @@ -400,6 +407,7 @@ public class TRex: NSObject { } processDetectedText(text, ocrResult: ocrResult) + return true } /// Capture multiple screen regions in a loop, OCR each one, and combine results. @@ -407,10 +415,10 @@ public class TRex: NSObject { /// 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 @@ -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) } } @@ -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 @@ -445,6 +455,7 @@ public class TRex: NSObject { } processDetectedText(combined) + return true } /// Run OCR on a CGImage and apply table detection if enabled. diff --git a/Packages/TRexCore/Tests/TRexCoreTests/BugRegressionTests.swift b/Packages/TRexCore/Tests/TRexCoreTests/BugRegressionTests.swift index 3f8bf72..1076118 100644 --- a/Packages/TRexCore/Tests/TRexCoreTests/BugRegressionTests.swift +++ b/Packages/TRexCore/Tests/TRexCoreTests/BugRegressionTests.swift @@ -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 diff --git a/TRex CMD/main.swift b/TRex CMD/main.swift index 4a2cbc3..1c98daf 100644 --- a/TRex CMD/main.swift +++ b/TRex CMD/main.swift @@ -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") }