diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f418e9..3fc0921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Each version's section is shown in the in-app update window and on the GitHub release. +## 1.100.1 + +### Fixed +- **Menew quitting by itself every few minutes.** With automatic Memory on, reading the text of a screenshot that macOS's text recognition can't handle made Menew quit, and it tried the same image again after relaunching. Those images are now saved without text instead. + ## 1.100.0 ### New diff --git a/Sources/Menew/TextRecognizer.swift b/Sources/Menew/TextRecognizer.swift index b12df1e..fd382e0 100644 --- a/Sources/Menew/TextRecognizer.swift +++ b/Sources/Menew/TextRecognizer.swift @@ -6,10 +6,12 @@ enum TextRecognizer { /// Linhas reconhecidas, de cima para baixo (vazio se não achou texto) static func recognize(_ image: CGImage) async -> String { await withCheckedContinuation { cont in + // Quando falha, o Vision chama o bloco com o erro E ainda lança a exceção: responder duas vezes derruba o app + let once = ResumeOnce(cont) let request = VNRecognizeTextRequest { req, _ in let lines = (req.results as? [VNRecognizedTextObservation] ?? []) .compactMap { $0.topCandidates(1).first?.string } - cont.resume(returning: lines.joined(separator: "\n")) + once.resume(lines.joined(separator: "\n")) } request.recognitionLevel = .accurate request.usesLanguageCorrection = true @@ -17,8 +19,10 @@ enum TextRecognizer { do { try VNImageRequestHandler(cgImage: image).perform([request]) } catch { - cont.resume(returning: "") + once.resume("") } + // Sem erro e sem chamar o bloco (não deveria acontecer): não deixa quem espera parado para sempre + once.resume("") } } @@ -77,3 +81,19 @@ enum TextRecognizer { text: lines == 1 ? L("Texto copiado") : L("Texto copiado (%d linhas)", lines)) } } + +/// Responde uma continuação uma vez só; as outras chamadas não fazem nada +final class ResumeOnce: @unchecked Sendable { + private var cont: CheckedContinuation? + private let lock = NSLock() + + init(_ cont: CheckedContinuation) { self.cont = cont } + + func resume(_ value: T) { + lock.lock() + let c = cont + cont = nil + lock.unlock() + c?.resume(returning: value) + } +} diff --git a/Tests/MenewTests/AgentSourcesTests.swift b/Tests/MenewTests/AgentSourcesTests.swift index 7fffec0..e2a8f43 100644 --- a/Tests/MenewTests/AgentSourcesTests.swift +++ b/Tests/MenewTests/AgentSourcesTests.swift @@ -252,3 +252,22 @@ final class OtherAgentEventsTests: XCTestCase { hub.handle(["agent": "cursor", "event": "sessionEnd", "session": "c1"], now: t0) } } + +final class ResumeOnceTests: XCTestCase { + func testOnlyTheFirstResumeCounts() async { + let v: String = await withCheckedContinuation { c in + let once = ResumeOnce(c) + once.resume("primeiro") + once.resume("segundo") // antes, isto derrubava o app + } + XCTAssertEqual(v, "primeiro") + } + + func testRecognizeSurvivesAnImageVisionRejects() async { + // Imagem de 1×1: o Vision recusa, e a leitura devolve vazio em vez de derrubar o app + let ctx = CGContext(data: nil, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let text = await TextRecognizer.recognize(ctx.makeImage()!) + XCTAssertEqual(text, "") + } +}