diff --git a/src/IMessage/Sources/IMDatabaseTestBench/TestBench.swift b/src/IMessage/Sources/IMDatabaseTestBench/TestBench.swift index ea1efc64..7817244a 100644 --- a/src/IMessage/Sources/IMDatabaseTestBench/TestBench.swift +++ b/src/IMessage/Sources/IMDatabaseTestBench/TestBench.swift @@ -22,7 +22,7 @@ struct TestBench: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Exercise functionality in IMDatabase.", - subcommands: [Watch.self, Messages.self, Chats.self, UnreadBenchmark.self, FSEventsCommand.self, TestIdleAware.self, ClosestSelectable.self], + subcommands: [Watch.self, Messages.self, Chats.self, UnreadBenchmark.self, FSEventsCommand.self, ClosestSelectable.self], ) mutating func run() async throws {} @@ -430,54 +430,3 @@ extension TestBench { } } } - -// MARK: - Idle Aware - -extension TestBench { - struct TestIdleAware: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "test-idle-aware", - abstract: "Tests the idle aware queue." - ) - @OptionGroup var options: TestBench.Options - - mutating func run() async throws { - bootstrap(logLevel: options.logLevel) - - let queue = PassivelyAwareDispatchQueue(label: "test", idleDelay: 0.1) - - queue.setIdleCallback { info in - print("*** IDLE! *** [0.1s] <\(info)>") - Thread.sleep(forTimeInterval: 0.1) - } - - queue.async { - print("1. [1s]") - Thread.sleep(forTimeInterval: 1) - } - queue.async { - print("2. [0.5s]") - Thread.sleep(forTimeInterval: 0.5) - } - queue.async { - print("3. [0.25s]") - Thread.sleep(forTimeInterval: 0.25) - } - - Task { - while true { - let ms = Int.random(in: 500...4000) - try! await Task.sleep(nanoseconds: UInt64(1_000_000 * ms)) - - queue.async { - let cost = Double.random(in: 0.5...1) - print("r. [\(cost)s]") - Thread.sleep(forTimeInterval: cost) - } - } - } - - await Task.never() - } - } -} diff --git a/src/IMessage/Sources/IMessage/Defaults.swift b/src/IMessage/Sources/IMessage/Defaults.swift index 14b5d962..be040008 100644 --- a/src/IMessage/Sources/IMessage/Defaults.swift +++ b/src/IMessage/Sources/IMessage/Defaults.swift @@ -21,7 +21,7 @@ enum DefaultsKeys { /** forces a specific coordinator (`eclipsing` or `spaces`), only checked once */ static let coordinator = "BEEPWindowCoordinator" - /** whether to respect calls to `onThreadSelected`/`watchThreadActivity` */ + /** whether to respect calls to `onThreadSelected` for thread activity observation */ static let watchThreadActivity = "BEEPWatchThreadActivity" /** whether to continuously poll "activity status" (typing indicator, dnd banner) */ static let pollActivityStatus = "BEEPPollActivityStatus" diff --git a/src/IMessage/Sources/IMessage/Extensions.swift b/src/IMessage/Sources/IMessage/Extensions.swift index 04fd6feb..24acc9fa 100644 --- a/src/IMessage/Sources/IMessage/Extensions.swift +++ b/src/IMessage/Sources/IMessage/Extensions.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import IMessageCore extension NSApplication { @@ -14,20 +15,29 @@ extension NSApplication { } extension NSRunningApplication { - func waitForLaunch(interval: TimeInterval = 0.05, timeout seconds: TimeInterval = 5) throws { - let start = Date() - while !self.isFinishedLaunching { - Log.default.notice("sleeping \(interval)s for \(String(describing: self.localizedName)) to finish launching") - Thread.sleep(forTimeInterval: interval) - if self.isTerminated { - throw ErrorMessage("\(String(describing: self.localizedName)) terminated") - } - if start.timeIntervalSinceNow < -seconds { - Log.default.notice("assuming \(String(describing: self.localizedName)) has launched") // sometimes this gets stuck in an infinite loop - break + func waitForLaunch(timeout seconds: TimeInterval = 5) async throws { + if isFinishedLaunching, !isTerminated { + return + } + + try await Task.withTimeout(seconds) { + try await withThrowingTaskGroup(of: Void.self) { group in + defer { + group.cancelAll() + } + + group.addTask { [self] in + try await self.waitForValue(\.isFinishedLaunching, true) + } + + group.addTask { [self] in + try await self.waitForValue(\.isTerminated, true) + throw ErrorMessage("Application terminated while waiting for launch") + } + + try await group.next() } } - Thread.sleep(forTimeInterval: 0.01) } } @@ -60,6 +70,7 @@ extension NSRect { /// primary display, the space used by Accessibility window positions and /// CGWindow bounds). The flip is about the primary display's height, so it is /// its own inverse and is correct regardless of which display the rect is on. + @MainActor func flippedBetweenCocoaAndScreenSpace() -> NSRect { guard let primaryHeight = NSScreen.screens.first?.frame.height else { return self } return NSRect(x: minX, y: primaryHeight - maxY, width: width, height: height) diff --git a/src/IMessage/Sources/IMessage/IMessageHost.swift b/src/IMessage/Sources/IMessage/IMessageHost.swift index b15f9a1c..8438e076 100644 --- a/src/IMessage/Sources/IMessage/IMessageHost.swift +++ b/src/IMessage/Sources/IMessage/IMessageHost.swift @@ -127,7 +127,7 @@ public enum IMessageHost { public static func disableMessagesNotifications() async throws { try await Task.detached(priority: .background) { - _ = try PromptAutomation.disableNotificationsForApp(named: "Messages") + _ = try await PromptAutomation.disableNotificationsForApp(named: "Messages") Defaults.playSoundEffects = false }.value } diff --git a/src/IMessage/Sources/IMessage/KeyPresser.swift b/src/IMessage/Sources/IMessage/KeyPresser.swift index 2141b131..17911504 100644 --- a/src/IMessage/Sources/IMessage/KeyPresser.swift +++ b/src/IMessage/Sources/IMessage/KeyPresser.swift @@ -6,29 +6,36 @@ import Logging private let log = Logger(imessageLabel: "key-presser") -// TODO: refactor class KeyPresser { let pid: pid_t + private let postKeyEvents: (CGKeyCode, CGEventFlags?) throws -> Void + private let keyCodeForCharacter: (Character) -> UInt16? - init(pid: pid_t) { + init( + pid: pid_t, + postKeyEvents: ((CGKeyCode, CGEventFlags?) throws -> Void)? = nil, + keyCodeForCharacter: ((Character) -> UInt16?)? = nil + ) { self.pid = pid + self.postKeyEvents = postKeyEvents ?? { key, flags in + try Self.post(key: key, flags: flags, to: pid) + } + self.keyCodeForCharacter = keyCodeForCharacter ?? { KeyMap.shared[$0] } } static let src = CGEventSource(stateID: .hidSystemState) - private func perform(onMainThread: Bool, _ action: () throws -> Void) rethrows { + private func perform(onMainThread: Bool, _ action: () throws -> T) rethrows -> T { guard onMainThread, !Thread.isMainThread else { - try action() - return + return try action() } log.debug("dispatching simulated keypress to main thread (queueName=\(__dispatch_queue_get_label(nil)))") - try DispatchQueue.main.sync { + return try DispatchQueue.main.sync { try action() } - return } - private func post(key: CGKeyCode, flags: CGEventFlags? = nil) throws { + private static func post(key: CGKeyCode, flags: CGEventFlags? = nil, to pid: pid_t) throws { log.debug("sending simulated keypress (code=\(key))") for keyDown in [true, false] { log.debug("simulated keypress phase (code=\(key), down=\(keyDown))") @@ -36,75 +43,73 @@ class KeyPresser { let ev = try CGEvent(keyboardEventSource: Self.src, virtualKey: key, keyDown: keyDown) .orThrow(ErrorMessage("key \(key) event empty")) if let flags { ev.flags = flags } - ev.postToPid(self.pid) + ev.postToPid(pid) if isSequoiaOrUp, !keyDown { // workaround courtesy https://github.com/pmanot ev.flags = [] - ev.postToPid(self.pid) + ev.postToPid(pid) } } } - private func press(key: CGKeyCode, flags: CGEventFlags? = nil, onMainThread: Bool = true) throws { + private func press(key: CGKeyCode, flags: CGEventFlags? = nil, onMainThread: Bool) throws { try perform(onMainThread: onMainThread) { - try post(key: key, flags: flags) + try postKeyEvents(key, flags) } } - private func pressMappedKey(_ key: Character, flags: CGEventFlags? = nil, onMainThread: Bool = true) throws { - try perform(onMainThread: onMainThread) { - guard let keyCode = KeyMap.shared[key] else { return } - try post(key: CGKeyCode(keyCode), flags: flags) - } + private func pressMappedKey(_ key: Character, flags: CGEventFlags? = nil, onMainThread: Bool) throws { + guard let keyCode = perform(onMainThread: true, { keyCodeForCharacter(key) }) else { return } + try press(key: CGKeyCode(keyCode), flags: flags, onMainThread: onMainThread) } - func `return`(onMainThread: Bool = true) throws { + func `return`(onMainThread: Bool = false) throws { try press(key: CGKeyCode(kVK_Return), onMainThread: onMainThread) } - func downArrow(onMainThread: Bool = true) throws { + func downArrow(onMainThread: Bool = false) throws { try press(key: CGKeyCode(kVK_DownArrow), onMainThread: onMainThread) } - func rightArrow(onMainThread: Bool = true) throws { + func rightArrow(onMainThread: Bool = false) throws { try press(key: CGKeyCode(kVK_RightArrow), onMainThread: onMainThread) } - func commandV(onMainThread: Bool = true) throws { + func commandV(onMainThread: Bool = false) throws { // sending CGKeyCode(kVK_ANSI_V) won't work on non-qwerty layouts where V key is in a different place try pressMappedKey("v", flags: .maskCommand, onMainThread: onMainThread) } /// marks as read/unread on ventura - func commandShiftU(onMainThread: Bool = true) throws { + func commandShiftU(onMainThread: Bool = false) throws { try pressMappedKey("u", flags: [.maskCommand, .maskShift], onMainThread: onMainThread) } /// selects next thread, both keys aren't the same in practice - func commandRightBracket(onMainThread: Bool = true) throws { + func commandRightBracket(onMainThread: Bool = false) throws { try pressMappedKey("]", flags: .maskCommand, onMainThread: onMainThread) } #if false /// selects first thread - func command1(onMainThread: Bool = true) throws { + func command1(onMainThread: Bool = false) throws { try pressMappedKey("1", flags: .maskCommand, onMainThread: onMainThread) } /// edits selected message - func commandE(onMainThread: Bool = true) throws { + func commandE(onMainThread: Bool = false) throws { try pressMappedKey("e", flags: .maskCommand, onMainThread: onMainThread) } /// selects prev thread, both keys aren't the same in practice - func commandLeftBracket(onMainThread: Bool = true) throws { + func commandLeftBracket(onMainThread: Bool = false) throws { try pressMappedKey("[", flags: .maskCommand, onMainThread: onMainThread) } /// selects first non-pinned thread - func commandOption1(onMainThread: Bool = true) throws { + func commandOption1(onMainThread: Bool = false) throws { try pressMappedKey("1", flags: [.maskCommand, .maskAlternate], onMainThread: onMainThread) } - func ctrlShiftTab(onMainThread: Bool = true) throws { + func ctrlShiftTab(onMainThread: Bool = false) throws { try press(key: CGKeyCode(kVK_Tab), flags: [.maskControl, .maskShift], onMainThread: onMainThread) } - func ctrlTab(onMainThread: Bool = true) throws { + func ctrlTab(onMainThread: Bool = false) throws { try press(key: CGKeyCode(kVK_Tab), flags: .maskControl, onMainThread: onMainThread) } #endif diff --git a/src/IMessage/Sources/IMessage/MacPermissions.swift b/src/IMessage/Sources/IMessage/MacPermissions.swift index b9f284df..6fe5fa93 100644 --- a/src/IMessage/Sources/IMessage/MacPermissions.swift +++ b/src/IMessage/Sources/IMessage/MacPermissions.swift @@ -80,6 +80,7 @@ public enum MacPermissions { } public static func askForAutomationAccess() async throws { + // Automation's TCC prompt is unreliable when OSA runs off the main actor. try await MainActor.run { try OSA.promptAutomationAccess() } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 936ae9fc..94066402 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -135,7 +135,7 @@ final class MessagesAppElements { } private let runningApp: NSRunningApplication - private let openDeepLink: (URL) throws -> Void + private let openDeepLink: (MessagesDeepLink) async throws -> Void let app: Accessibility.Element @@ -145,7 +145,7 @@ final class MessagesAppElements { private var cachedMainWindow: Accessibility.Element? - init(runningApp: NSRunningApplication, openDeepLink: @escaping (URL) throws -> Void = { try MessagesController.openDeepLink($0) }) { + init(runningApp: NSRunningApplication, openDeepLink: @escaping (MessagesDeepLink) async throws -> Void) { self.runningApp = runningApp self.openDeepLink = openDeepLink app = Accessibility.Element(pid: runningApp.processIdentifier) @@ -162,8 +162,8 @@ final class MessagesAppElements { logTime: Bool = false, dumpOnError: Bool = false, in root: Accessibility.Element? = nil, - _ search: () throws -> Accessibility.Element? - ) throws -> Accessibility.Element { + _ search: () async throws -> Accessibility.Element? + ) async throws -> Accessibility.Element { let startTime = logTime ? Date() : nil defer { @@ -174,7 +174,7 @@ final class MessagesAppElements { var errors: [Error] = [] do { - if let result = try search() { + if let result = try await search() { return result } } catch { @@ -232,6 +232,13 @@ final class MessagesAppElements { return allWindows.first(where: isMainWindow) } + var currentMainWindow: Accessibility.Element? { + if let cached = cachedMainWindow, cached.isFrameValid { + return cached + } + return getMainWindow() + } + private func isPromptVisibleInMessagesApp() -> Bool { allWindows.contains(where: { (try? $0.windowCloseButton().isEnabled()) == false }) } @@ -264,16 +271,18 @@ final class MessagesAppElements { } var _mainWindowReally: Accessibility.Element { - get throws { + get async throws { if let cached = cachedMainWindow, cached.isFrameValid { return cached } - let mainWindow = try retry(withTimeout: 5, interval: 0.2) { () throws -> Accessibility.Element in + let mainWindow = try await retry(withTimeout: 5, interval: 0.2) { () async throws -> Accessibility.Element in try getMainWindow().orThrow(ErrorMessage("Could not get main Messages window")) } onError: { attempt, _ in if attempt == 0 { log.notice("mainWindow: using compose deep link to try to get main window") - try self.openDeepLink(MessagesDeepLink.compose.url()) + // await the open so the retry waits for it to complete (rather than + // racing a fire-and-forget open against the next getMainWindow attempt). + try await self.openDeepLink(MessagesDeepLink.compose) } else if attempt == 1 { if self.isPromptVisibleInMessagesApp() { log.notice("mainWindow: some prompts are visible, attempting to reset") @@ -323,9 +332,9 @@ final class MessagesAppElements { } var mainWindow: Accessibility.Element { - get throws { + get async throws { do { - return try _mainWindowReally + return try await _mainWindowReally } catch { do { try dumpAndLogApplicationTreeIfNeeded() @@ -338,17 +347,18 @@ final class MessagesAppElements { } var conversationsList: Accessibility.Element { // takes ~34ms - get throws { + get async throws { // if let cached = cachedConversationsList { // return cached // } let startTime = Date() defer { log.debug("conversationsList took \(startTime.timeIntervalSinceNow * -1000)ms") } // cachedConversationsList = cl - return try retry(withTimeout: 1, interval: 0.1) { - try Self.getConversationList(window: mainWindow, useFastPath: true).orThrow(ErrorMessage("ConversationList not found")) + return try await retry(withTimeout: 1, interval: 0.1) { + let window = try await mainWindow + return try Self.getConversationList(window: window, useFastPath: true).orThrow(ErrorMessage("ConversationList not found")) } onError: { _, _ in - let searchField = try self.searchField + let searchField = try await self.searchField log.error("fetching ConversationList errored, calling searchField.cancel") // this will close the search results if active try searchField.cancel() @@ -358,18 +368,19 @@ final class MessagesAppElements { // this return type was copied from compiler error var mainWindowSections: LazyMapCollection.Elements, Accessibility.Element?>>, Accessibility.Element> { - get throws { - try Self.getSectionObjects(window: mainWindow) + get async throws { + let window = try await mainWindow + return try Self.getSectionObjects(window: window) } } var selectedThreadCell: Accessibility.Element? { - get { - try? conversationsList.selectedChildren[0] + get async { + try? await conversationsList.selectedChildren[0] } } - private func getTranscriptView(replyTranscript: Bool) throws -> Accessibility.Element { + private func getTranscriptView(replyTranscript: Bool) async throws -> Accessibility.Element { let startTime = Date() defer { log.debug("getTranscriptView(replyTranscript: \(replyTranscript)) took \(startTime.timeIntervalSinceNow * -1000)ms") } @@ -385,38 +396,41 @@ final class MessagesAppElements { let predicate = { (el: Accessibility.Element) -> Bool in (try? el.identifier()) == "TranscriptCollectionView" && isReplyTranscriptView(el) == replyTranscript } - // takes ~8ms - if let transcriptView = try? mainWindowSections.first(where: predicate) { return transcriptView } // takes ~19ms - if let transcriptView = try? mainWindow.recursiveChildren().lazy.first(where: predicate) { return transcriptView } + let window = try await mainWindow + // takes ~8ms + if let transcriptView = try? Self.getSectionObjects(window: window).first(where: predicate) { return transcriptView } + if let transcriptView = window.recursiveChildren().lazy.first(where: predicate) { return transcriptView } throw ErrorMessage("TranscriptCollectionView(replyTranscript: \(replyTranscript)) not found") } var transcriptView: Accessibility.Element { - get throws { - try getTranscriptView(replyTranscript: false) + get async throws { + try await getTranscriptView(replyTranscript: false) } } var replyTranscriptView: Accessibility.Element { - get throws { + get async throws { // if let cached = cachedReplyTranscriptView, cached.isInViewport { // return cached // } // cachedReplyTranscriptView = tcv - return try getTranscriptView(replyTranscript: true) + return try await getTranscriptView(replyTranscript: true) } } var messageBodyField: Accessibility.Element { - get throws { + get async throws { let startTime = Date() defer { log.debug("messageBodyField took \(startTime.timeIntervalSinceNow * -1000)ms") } var alternate = false - return try retry(withTimeout: 1.5, interval: 0.1) { - alternate - ? try mainWindow.recursivelyFindChild(withID: "messageBodyField").orThrow(ErrorMessage("messageBodyField not found")) - : try mainWindowSections.first { (try? $0.identifier()) == "messageBodyField" }.orThrow(ErrorMessage("messageBodyField not found")) // not present when compose cell is selected + return try await retry(withTimeout: 1.5, interval: 0.1) { + if alternate { + let window = try await mainWindow + return try window.recursivelyFindChild(withID: "messageBodyField").orThrow(ErrorMessage("messageBodyField not found")) + } + return try await mainWindowSections.first { (try? $0.identifier()) == "messageBodyField" }.orThrow(ErrorMessage("messageBodyField not found")) // not present when compose cell is selected } onError: { attempt, _ in alternate = attempt % 2 == 0 } @@ -424,11 +438,12 @@ final class MessagesAppElements { } var searchField: Accessibility.Element { - get throws { + get async throws { let startTime = Date() defer { log.debug("searchField took \(startTime.timeIntervalSinceNow * -1000)ms") } - return try retry(withTimeout: 1, interval: 0.1) { - let CKConversationListCollectionView = try Self.getCKConversationListCollectionView(window: mainWindow) + return try await retry(withTimeout: 1, interval: 0.1) { + let window = try await mainWindow + let CKConversationListCollectionView = try Self.getCKConversationListCollectionView(window: window) .orThrow(ErrorMessage("CKConversationListCollectionView not found")) return try CKConversationListCollectionView.children().first { (try? $0.subrole()) == Accessibility.Subrole.searchField } .orThrow(ErrorMessage("searchField not found")) @@ -437,24 +452,26 @@ final class MessagesAppElements { } var iOSContentGroup: Accessibility.Element { // className=UINSSceneView - get throws { - try find("iOSContentGroup") { - try mainWindow.children() + get async throws { + try await find("iOSContentGroup") { + let window = try await mainWindow + return try window.children() .first(where: { (try? $0.subrole()) == "iOSContentGroup" && (try? $0.role()) == NSAccessibility.Role.group.rawValue }) } } } var iOSContentGroupFirstChild: Accessibility.Element { // className= CKUIWindow_60754894 or CKPresentationControllerWindow (when reactions are open) - get throws { - try find("iOSContentGroupFirstChild", logTime: true) { - try iOSContentGroup.children[0] + get async throws { + try await find("iOSContentGroupFirstChild", logTime: true) { + let group = try await iOSContentGroup + return try group.children[0] } } } var addCustomEmojiReactionButton: Accessibility.Element { - get throws { + get async throws { // identifiers of the _children of_ iOSContentGroupFirstChild as of 15.3: // ([String?]) 5 values { // [0] = "TapbackPickerCollectionView" @@ -466,7 +483,7 @@ final class MessagesAppElements { // find element with class name `ChatKit.TapbackPickerEmojiTailView` // its localizedDescription is "Add custom emoji reaction", but it's likely different for non-en_US locales - let elem = try (try? iOSContentGroupFirstChild)?.children().first { + let elem = try (try? await iOSContentGroupFirstChild)?.children().first { (try? $0.identifier()) == nil && (try? $0.role()) == "AXButton" } return try elem.orThrow(ErrorMessage("couldn't find button to add custom emoji reaction")) @@ -487,9 +504,10 @@ final class MessagesAppElements { /// CharacterPalette's popover is detached from Messages' AX tree on macOS Tahoe+. var characterPickerPopover: Accessibility.Element { - get throws { - try find("characterPickerPopover") { - if let attachedPopover = try? mainWindow.recursiveChildren().lazy.first(where: CharacterPickerPopover.isPopover) { + get async throws { + try await find("characterPickerPopover") { + let window = try await mainWindow + if let attachedPopover = window.recursiveChildren().lazy.first(where: CharacterPickerPopover.isPopover) { return attachedPopover } @@ -500,27 +518,29 @@ final class MessagesAppElements { /// The search field within the emoji picker popover. var characterPickerSearchField: Accessibility.Element { - get throws { - try find("characterPickerSearchField") { - try characterPickerPopoverLocator.searchField(in: characterPickerPopover) + get async throws { + try await find("characterPickerSearchField") { + let popover = try await characterPickerPopover + return characterPickerPopoverLocator.searchField(in: popover) } } } var splitter: Accessibility.Element { - get throws { - try find("splitter", logTime: true) { - try iOSContentGroupFirstChild.children().first(where: { (try? $0.role()) == Accessibility.Role.splitter }) + get async throws { + try await find("splitter", logTime: true) { + let content = try await iOSContentGroupFirstChild + return try content.children().first(where: { (try? $0.role()) == Accessibility.Role.splitter }) } } } var reactionsView: Accessibility.Element { - get throws { + get async throws { let startTime = Date() defer { log.debug("reactionsView took \(startTime.timeIntervalSinceNow * -1000)ms") } - return try retry(withTimeout: 1.5, interval: 0.1) { - let view = try iOSContentGroupFirstChild + return try await retry(withTimeout: 1.5, interval: 0.1) { + let view = try await iOSContentGroupFirstChild guard (try? view.children.count()) ?? 0 > 0 else { throw ErrorMessage("reactionsView not found") } @@ -530,7 +550,7 @@ final class MessagesAppElements { } var reactButtons: [Accessibility.Element] { - get throws { + get async throws { let startTime = Date() defer { log.debug("reactButtons took \(startTime.timeIntervalSinceNow * -1000)ms") } /* @@ -544,7 +564,7 @@ final class MessagesAppElements { Reply -- only shows up when not in overlay mode Pin -- only shows up for links/tweets in Monterey or above */ - guard let buttons = try? reactionsView.children().filter({ (try? $0.role()) == Accessibility.Role.button }) else { + guard let buttons = try? await reactionsView.children().filter({ (try? $0.role()) == Accessibility.Role.button }) else { throw ErrorMessage("reactButtons not found") } return buttons @@ -552,10 +572,10 @@ final class MessagesAppElements { } var tapbackPickerCollectionView: Accessibility.Element { - get throws { + get async throws { let startTime = Date() defer { log.debug("tapbackPickerCollectionView took \(startTime.timeIntervalSinceNow * -1000)ms") } - guard let element = try? reactionsView.children().first(where: { (try? $0.identifier()) == "TapbackPickerCollectionView" }) else { + guard let element = try? await reactionsView.children().first(where: { (try? $0.identifier()) == "TapbackPickerCollectionView" }) else { throw ErrorMessage("tapbackPickerCollectionView not found") } return element @@ -563,26 +583,28 @@ final class MessagesAppElements { } var alertSheet: Accessibility.Element { - get throws { - try find("alertSheet") { - try mainWindow.children().first(where: { try $0.role() == Accessibility.Role.sheet }) + get async throws { + try await find("alertSheet") { + let window = try await mainWindow + return try window.children().first(where: { try $0.role() == Accessibility.Role.sheet }) } } } var alertSheetDeleteButton: Accessibility.Element { - get throws { - try find("alertSheetDeleteButton") { - try alertSheet.children().first(where: { try $0.role() == Accessibility.Role.button }) + get async throws { + try await find("alertSheetDeleteButton") { + let sheet = try await alertSheet + return try sheet.children().first(where: { try $0.role() == Accessibility.Role.button }) } } } var notifyAnywayButton: Accessibility.Element { - get throws { + get async throws { let startTime = Date() defer { log.debug("notifyAnywayButton took \(startTime.timeIntervalSinceNow * -1000)ms") } - let transcriptView = try self.transcriptView + let transcriptView = try await self.transcriptView let cells = try Self.threadActivityCells(in: transcriptView) return try cells.lazy.reversed().compactMap { guard let child = try? $0.children[0], @@ -596,8 +618,9 @@ final class MessagesAppElements { } var editableMessageField: Accessibility.Element { - get throws { - let editingConfirmButton = try iOSContentGroup.recursiveChildren().lazy.first(where: { + get async throws { + let group = try await iOSContentGroup + let editingConfirmButton = try group.recursiveChildren().lazy.first(where: { (try? $0.localizedDescription()) == LocalizedStrings.editingConfirm }).orThrow(ErrorMessage("editingConfirmButton not found")) return try editingConfirmButton.parent().recursiveChildren().lazy.first(where: { @@ -607,27 +630,30 @@ final class MessagesAppElements { } var menu: Accessibility.Element { - get throws { - try retry(withTimeout: 2, interval: 0.1) { - try iOSContentGroup.children().first { try $0.role() == Accessibility.Role.menu } + get async throws { + try await retry(withTimeout: 2, interval: 0.1) { + let group = try await iOSContentGroup + return try group.children().first { try $0.role() == Accessibility.Role.menu } .orThrow(ErrorMessage("menu not found")) } } } var menuEditItem: Accessibility.Element { - get throws { - try retry(withTimeout: 1, interval: 0.05) { - try menu.children().first { (try? $0.identifier()) == "edit" } + get async throws { + try await retry(withTimeout: 1, interval: 0.05) { + let contextMenu = try await self.menu + return try contextMenu.children().first { (try? $0.identifier()) == "edit" } .orThrow(ErrorMessage("Couldn't find \"Edit\" menu item; messages are only editable for 15 minutes after sending")) } } } var cancelEditButton: Accessibility.Element { - get throws { - try find("cancelEditButton") { - try iOSContentGroupFirstChild.recursiveChildren() + get async throws { + try await find("cancelEditButton") { + let content = try await iOSContentGroupFirstChild + return content.recursiveChildren() .first(where: { (try? $0.localizedDescription()) == LocalizedStrings.editingReject }) @@ -637,9 +663,10 @@ final class MessagesAppElements { // only works when there's an address in the field, not for empty compose threads var toFieldPopupButton: Accessibility.Element { - get throws { - try find("toFieldPopupButton") { - try iOSContentGroup.children[0].children().first { try $0.role() == Accessibility.Role.popUpButton } + get async throws { + try await find("toFieldPopupButton") { + let group = try await iOSContentGroup + return try group.children[0].children().first { try $0.role() == Accessibility.Role.popUpButton } } } } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController+PlatformOperations.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController+PlatformOperations.swift index 63128ba7..5c522e48 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController+PlatformOperations.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController+PlatformOperations.swift @@ -4,7 +4,7 @@ import IMessageCore private let platformOperationsLog = Logger(imessageLabel: "messages-controller-platform-operations") extension MessagesController { - func setReaction(threadID: String, messageID: String, reactionName: String, on: Bool) throws { + func setReaction(threadID: String, messageID: String, reactionName: String, on: Bool) async throws { let reaction = if let reaction = Reaction(platformSDKReactionKey: reactionName) { // try the "legacy" reactions first (keyed by `supported` in platform info) reaction @@ -19,32 +19,32 @@ extension MessagesController { } let messageCell = try resolveMessageCell(threadID: threadID, platformMessageID: messageID) - try setReaction(threadID: threadID, messageCell: messageCell, reaction: reaction, on: on) + try await setReaction(threadID: threadID, messageCell: messageCell, reaction: reaction, on: on) } - func undoSend(threadID: String, messageID: String) throws { + func undoSend(threadID: String, messageID: String) async throws { let messageCell = try resolveMessageCell(threadID: threadID, platformMessageID: messageID) - try undoSend(threadID: threadID, messageCell: messageCell) + try await undoSend(threadID: threadID, messageCell: messageCell) } - func editMessage(threadID: String, messageID: String, newText: String) throws { + func editMessage(threadID: String, messageID: String, newText: String) async throws { let messageCell = try resolveMessageCell(threadID: threadID, platformMessageID: messageID, allowOverlay: false) - try editMessage(threadID: threadID, messageCell: messageCell, newText: newText) + try await editMessage(threadID: threadID, messageCell: messageCell, newText: newText) } - func loadAttachment(threadID: String, messageID: String) throws { + func loadAttachment(threadID: String, messageID: String) async throws { let messageCell = try resolveMessageCell(threadID: threadID, platformMessageID: messageID) - try loadAttachment(threadID: threadID, messageCell: messageCell) + try await loadAttachment(threadID: threadID, messageCell: messageCell) } - func sendMessage(threadID: String, text: String?, filePath: String?, quotedMessageID: String?) throws { + func sendMessage(threadID: String, text: String?, filePath: String?, quotedMessageID: String?) async throws { let quotedMessage: MessageCell? = if let quotedMessageID { try resolveMessageCell(threadID: threadID, platformMessageID: quotedMessageID) } else { nil } - try sendMessage(threadID: threadID, addresses: nil, text: text, filePath: filePath, quotedMessage: quotedMessage) + try await sendMessage(threadID: threadID, addresses: nil, text: text, filePath: filePath, quotedMessage: quotedMessage) } private func resolveMessageCell(threadID: String, platformMessageID messageID: String, allowOverlay: Bool = true) throws -> MessageCell { diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 89d404cf..e05e58ac 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -77,7 +77,7 @@ final class MessagesController { var cachedDatabase: IMDatabase? private var lifecycleObserver: LifecycleObserver - private var lastThreadIDOpenedForObservation = Protected() + private var lastOpenedThreadID: String? private var lastSentActivityObservation: ThreadActivityObservation? private var lastSentActivityObservationTime: Date? @@ -89,16 +89,16 @@ final class MessagesController { let occlusionMonitor = OcclusionMonitor() // without expanding splitter, thread cells will not have custom ax actions (on monterey at least) - private func expandSplitter() throws { - if try elements.conversationsList.size().width < 99 { // width is 94 when in compact mode - try elements.splitter.increment() + private func expandSplitter() async throws { + if try await elements.conversationsList.size().width < 99 { // width is 94 when in compact mode + try await elements.splitter.increment() } } - private func resetWindow() { - try? elements.searchField.cancel() - try? expandSplitter() - try? closeReplyTranscriptView(wait: false) + private func resetWindow() async { + try? await elements.searchField.cancel() + try? await expandSplitter() + _ = try? await closeReplyTranscriptView() } private static func terminateApp(_ app: NSRunningApplication) throws { @@ -113,14 +113,21 @@ final class MessagesController { } } - @discardableResult - static func openDeepLink( + private enum DeepLinkOpenPlan { + /// The request was already satisfied by routing to a secondary Messages instance. + case handledBySecondaryInstance(NSRunningApplication) + /// The request still needs to be opened via NSWorkspace with these options. + case open(NSWorkspace.OpenConfiguration) + } + + private static func planDeepLinkOpen( _ url: URL, - activating: Bool = false, - hiding: Bool = true, - targeting app: NSRunningApplication? = nil - ) throws -> NSRunningApplication { + activating: Bool, + hiding: Bool, + targeting app: NSRunningApplication? + ) throws -> DeepLinkOpenPlan { let shouldHide = hiding && Defaults.shouldCoordinateWindow + logDeepLinkOpen(url, activating: activating, hiding: shouldHide) if Preferences.useSecondaryMessagesInstance, let app { try MessagesInstanceTarget.sendDeepLink(url, to: app) if activating { @@ -129,42 +136,60 @@ final class MessagesController { if shouldHide { app.hide() } - return app + return .handledBySecondaryInstance(app) } let openOptions = NSWorkspace.OpenConfiguration() openOptions.activates = activating openOptions.hides = shouldHide + return .open(openOptions) + } - let horribleWaiter = DispatchSemaphore(value: 0) - var result: Result? - NSWorkspace.shared.open(url, configuration: openOptions) { running, error in - #if DEBUG - let builtForDebugging = true - #else - let builtForDebugging = false - #endif - if Defaults.deepLinkTracingPII || builtForDebugging { - log.debug("🚀 OPENING DEEP LINK: \(url) (activating? \(activating), hiding? \(shouldHide))") - } else { - log.debug("🚀 OPENING DEEP LINK (activating? \(activating), hiding? \(shouldHide))") - } - - if let error { - result = .failure(error) - } else { - result = .success(running!) + @discardableResult + static func openDeepLink( + _ deepLink: MessagesDeepLink, + activating: Bool = false, + hiding: Bool = true, + targeting app: NSRunningApplication? = nil, + timeout: TimeInterval = 5 + ) async throws -> NSRunningApplication { + try Task.checkCancellation() + let url = try deepLink.url() + switch try planDeepLinkOpen(url, activating: activating, hiding: hiding, targeting: app) { + case .handledBySecondaryInstance(let app): + return app + case .open(let openOptions): + return try await Task.withTimeout(timeout) { + try await NSWorkspace.shared.open(url, configuration: openOptions) } - horribleWaiter.signal() } - horribleWaiter.wait() + } - return try result!.get() + private static func logDeepLinkOpen(_ url: URL, activating: Bool, hiding: Bool) { + #if DEBUG + let builtForDebugging = true + #else + let builtForDebugging = false + #endif + if Defaults.deepLinkTracingPII || builtForDebugging { + log.debug("🚀 OPENING DEEP LINK: \(url) (activating? \(activating), hiding? \(hiding))") + } else { + log.debug("🚀 OPENING DEEP LINK (activating? \(activating), hiding? \(hiding))") + } } @discardableResult - private func openDeepLink(_ url: URL, activating: Bool = false, hiding: Bool = true) throws -> NSRunningApplication { - try Self.openDeepLink(url, activating: activating, hiding: hiding, targeting: app) + private func openDeepLink( + _ deepLink: MessagesDeepLink, + activating: Bool = false, + hiding: Bool = true + ) async throws -> NSRunningApplication { + lastOpenedThreadID = nil + let app = try await Self.openDeepLink(deepLink, activating: activating, hiding: hiding, targeting: app) + if let targetThreadID = deepLink.targetThreadID { + lastOpenedThreadID = targetThreadID + } + return app } func isSameContact(_ a: String?, _ b: String?) -> Bool { @@ -172,18 +197,18 @@ final class MessagesController { return contacts.fetchID(for: a) == contacts.fetchID(for: b) } - private func getToFieldAddresses() -> LazyMapSequence>, String>? { - let desc = try? elements.toFieldPopupButton.localizedDescription() + private func getToFieldAddresses() async -> [String]? { + let desc = try? await elements.toFieldPopupButton.localizedDescription() // unknown if other locales also use , as a separator - return desc?.split(separator: ",").lazy.reversed().map { String($0).trimmingCharacters(in: .whitespaces) } + return desc?.split(separator: ",").reversed().map { String($0).trimmingCharacters(in: .whitespaces) } } - private func isComposeThreadSelected() -> Bool { - (try? elements.toFieldPopupButton) != nil + private func isComposeThreadSelected() async -> Bool { + (try? await elements.toFieldPopupButton) != nil } // ignores the service (SMS or iMessage) and matches contact identifiers since it's merged in the UI - private func assertSelectedThread(threadID: String) throws { + private func assertSelectedThread(threadID: String) async throws { let hashedThreadID = Hasher.thread.tokenizeRemembering(pii: threadID) guard Defaults.imessage.bool(forKey: DefaultsKeys.misfirePrevention) else { log.debug("NOT ensuring selected thread, misfire prevention is off: \(hashedThreadID)") @@ -213,7 +238,7 @@ final class MessagesController { let beganEnsuringThreadSelection = Date() var hasLoggedAboutStrategy = false - try retry(withTimeout: 1.2, interval: 0.05) { + try await retry(withTimeout: 1.2, interval: 0.05) { attempt += 1 do { let strategy = Defaults.imessage.string(forKey: DefaultsKeys.misfirePreventionFallbackStrategy) @@ -226,7 +251,7 @@ final class MessagesController { case "title-prediction": // TODO: when contacts details change, iMessage might not update the window title immediately. // TODO: to resolve this, perhaps try jiggling the selection around if the title doesn't match - guard let windowTitle = try? elements.mainWindow.title() else { + guard let windowTitle = try? await elements.mainWindow.title() else { throw ErrorMessage("misfire prevention: couldn't read window title") } @@ -243,11 +268,11 @@ final class MessagesController { var sleepInterval = Defaults.imessage.double(forKey: DefaultsKeys.misfirePreventionSleepInterval) if sleepInterval <= 0.0 { sleepInterval = 0.5 } log.warning("misfire prevention: no fallback strategy specified; sleeping for \(sleepInterval)s instead") - Thread.sleep(forTimeInterval: sleepInterval) + try await Task.sleep(forTimeInterval: sleepInterval) } } catch { if attempt > 5 { // 250ms - if let addresses = getToFieldAddresses(), addresses.contains(where: { isSameContact($0, addressToMatch) }) { + if let addresses = await getToFieldAddresses(), addresses.contains(where: { isSameContact($0, addressToMatch) }) { log.error("assertSelectedThread: resorted to fallback in order to assert selection") return } @@ -258,32 +283,36 @@ final class MessagesController { } } - private func openThread(_ threadID: String) throws { - try? self.clearTypingStatus() - try openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil).url()) - try assertSelectedThread(threadID: threadID) + private func openThread(_ threadID: String) async throws { + try? await self.clearTypingStatus() + try await openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil)) + try await assertSelectedThread(threadID: threadID) } - init(reportErrorMessage: @escaping (_ txt: String) -> Void) throws { + init(reportErrorMessage: @escaping (_ txt: String) -> Void) async throws { self.reportErrorMessage = reportErrorMessage guard Accessibility.isTrusted() else { throw ErrorMessage("Beeper does not have Accessibility permissions") } - windowCoordinator = try getBestWindowCoordinator() + let coordinator = try getBestWindowCoordinator() + windowCoordinator = coordinator - let launchMessages = { [windowCoordinator] (withoutActivation: Bool) throws -> NSRunningApplication in + let launchMessages = { [coordinator] (withoutActivation: Bool) async throws -> NSRunningApplication in // waiting reduces the likelihood that messages.app shows up visible (requiring us to restart it) - if !windowCoordinator.canReuseExtantInstance && Defaults.shouldCoordinateWindow { - Thread.sleep(forTimeInterval: 0.1) + if !coordinator.canReuseExtantInstance && Defaults.shouldCoordinateWindow { + try await Task.sleep(forTimeInterval: 0.1) } log.info("launching messages... (without activation? \(withoutActivation))") - return try Self.openDeepLink(MessagesDeepLink.compose.url(), activating: !withoutActivation) + // Cold launch can be much slower than a routine in-session open (the app may + // need to start from scratch), so give it a generous timeout rather than the + // 5s default used by in-session openDeepLink calls. + return try await Self.openDeepLink(MessagesDeepLink.compose, activating: !withoutActivation, timeout: 30) } if Preferences.useSecondaryMessagesInstance { log.info("launching secondary messages instance...") - app = try MessagesInstanceTarget.launchSecondaryInstance( + app = try await MessagesInstanceTarget.launchSecondaryInstance( initialDeepLink: MessagesDeepLink.compose.url(), activating: false, hiding: false @@ -297,7 +326,7 @@ final class MessagesController { } if let existingApp = messagesApps.first { // if coordination is disabled, avoid unnecessarily terminating the app - if windowCoordinator.canReuseExtantInstance || !Defaults.shouldCoordinateWindow { + if coordinator.canReuseExtantInstance || !Defaults.shouldCoordinateWindow { log.info("reusing existing messages...") app = existingApp } else { @@ -305,24 +334,26 @@ final class MessagesController { try Self.terminateApp(existingApp) // this is for markAsReadWithPressHack (monterey or lower) // launch with activation because the hack doesn't work until the app is activated at least once - app = try launchMessages(!isVenturaOrUp) + app = try await launchMessages(!isVenturaOrUp) } } else { - app = try launchMessages(false) + app = try await launchMessages(false) } } - windowCoordinator.app = app + let selectedApp = app + await MainActor.run { + coordinator.app = selectedApp + } // without sleeping, appElement.observe applicationActivated/applicationDeactivated doesn't fire - try app.waitForLaunch() - let selectedApp = app + try await selectedApp.waitForLaunch() - elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in - try Self.openDeepLink(url, targeting: selectedApp) + elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { deepLink in + try await Self.openDeepLink(deepLink, targeting: selectedApp) }) - keyPresser = KeyPresser(pid: app.processIdentifier) + keyPresser = KeyPresser(pid: selectedApp.processIdentifier) // if app.isHidden { // debugLog("Unhiding Messages...") @@ -337,16 +368,17 @@ final class MessagesController { lifecycleObserver = observer setUpLifecycleConveyor(with: lifecycleObserver) - guard isValid else { + let mainWindowFrameValid = await Result { try await elements.mainWindow.isFrameValid } + guard !app.isTerminated, (try? mainWindowFrameValid.get()) != nil, isMessagesAppResponsive else { dispose() // since deinit isn't called when init throws throw ErrorMessage(""" Initialized MessagesController in an invalid state: appTerminated=\(app.isTerminated) -mwFrameValid=\(Result { try elements.mainWindow.isFrameValid }) +mwFrameValid=\(mainWindowFrameValid) isMessagesAppResponsive=\(isMessagesAppResponsive) """) } - resetWindow() + await resetWindow() } func setUpLifecycleConveyor(with observer: LifecycleObserver) { @@ -357,18 +389,26 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) log.error("unable to perform initial observation of app: \(error)") } do { - try observer.beginObserving(window: try self.elements.mainWindow) + let window = try self.elements.currentMainWindow.orThrow(ErrorMessage("main window not found")) + try observer.beginObserving(window: window) } catch { log.error("unable to perform initial observation of main window: \(error)") } - // this task doesn't run on the thread with the run loop - self.lifecycleEventsTask = Task.detached { + // this task doesn't run on the thread with the run loop. + // `[weak self]` (re-checked each iteration) avoids a retain cycle: the task + // is stored on `self` but kept alive by `observer`'s stream, not `self`. + self.lifecycleEventsTask = Task.detached { [weak self] in func debuggingStatus() -> String { + guard let self else { return "" } let app = self.app do { - let window = try self.elements.mainWindow + // Use getMainWindow() (not currentMainWindow): this runs off the + // automation lane, and currentMainWindow reads/validates the shared + // `cachedMainWindow` that a lane op writes in `_mainWindowReally`, + // which would race. getMainWindow() never touches the cache. + let window = try self.elements.getMainWindow().orThrow(ErrorMessage("main window not found")) let frame = try window.frame() let position = try window.position() return "finishedLaunching=\(app.isFinishedLaunching), active=\(app.isActive), hidden=\(app.isHidden), terminated=\(app.isTerminated), AXframe=\(frame), AXpos=\(position)" @@ -378,6 +418,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } for await event in observer.events.subscribe() { + guard let self else { return } func printLifecycle(event: String) { lifecycleLog.info("@@ AX: \(event) [\(debuggingStatus())]") } @@ -385,10 +426,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) switch event { case .appActivated: printLifecycle(event: "APP activated") - self.activateMessages() + await self.activateMessages() case .appDeactivated: printLifecycle(event: "APP deactivated") - self.deactivateMessages() + await self.deactivateMessages() case .appHidden: printLifecycle(event: "APP hidden") case .appShown: printLifecycle(event: "APP shown") case .anyObservedWindowMoved: printLifecycle(event: "WINDOW moved") @@ -406,7 +447,8 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // for now, reset our window-local observations whenever we // see that a window was created (even if it was just e.g. // the settings window). - try rlt.enqueue(.observeWindow(window: self.elements.mainWindow)) + let window = try await self.elements.mainWindow + rlt.enqueue(.observeWindow(window: window)) } catch { log.error("fetching mainWindow failed", error: error) } @@ -435,49 +477,68 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } var isValid: Bool { - !app.isTerminated && (try? elements.mainWindow.isFrameValid) != nil && isMessagesAppResponsive + !app.isTerminated && (elements.currentMainWindow?.isFrameValid) != nil && isMessagesAppResponsive } - @inlinable func prepareForAutomation() throws { - log.info("prepareForAutomation") - afterAutomationTask?.cancel() - log.debug("prepareForAutomation: making the app automatable") - - defer { - activityLock.lock() - } - - if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { - try windowCoordinator.makeAutomatable(mainWindow) - } - } + private func withAutomation(_ operation: () async throws -> T) async throws -> T { + log.info("withAutomation: preparing") + cancelReplyTranscriptViewTask?.cancel() + log.debug("withAutomation: making the app automatable") - @inlinable func finishedAutomation() { - log.info("finishedAutomation") - activityLock.unlock() - // this isn't propagated to make finishedAutomation callable inside of defer { … } + // If makeAutomatable partially succeeds then throws, run the window-restore + // cleanup on the throw path before rethrowing so the app isn't left eclipsed. + // (Can't use `defer`: the cleanup is `async`.) if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { do { - try windowCoordinator.automationDidComplete(mainWindow) + try await windowCoordinator.makeAutomatable(mainWindow) } catch { - log.error("failed to call automationDidComplete on window coordinator: \(String(reflecting: error))") + await automationDidComplete() + scheduleCancelReplyTranscriptView() + throw error } } + + let result = await Result(catching: operation) + + log.info("withAutomation: finished") + await automationDidComplete() // todo: this can be optimized by scheduling only after we trigger open the rtv instead of after each automation scheduleCancelReplyTranscriptView() + + return try result.get() } - private var afterAutomationTask: DispatchWorkItem? + /// Window-restore step, shared by the success and makeAutomatable-failed paths. + private func automationDidComplete() async { + // No AX element is passed across the actor boundary — the coordinators don't use it. + guard Defaults.shouldCoordinateWindow, elements.getMainWindow() != nil else { return } + do { + try await windowCoordinator.automationDidComplete() + } catch { + log.error("failed to call automationDidComplete on window coordinator: \(String(reflecting: error))") + } + } - private static let queue = DispatchQueue(label: "messages-controller-queue") + private var cancelReplyTranscriptViewTask: Task? private func scheduleCancelReplyTranscriptView() { - afterAutomationTask = DispatchWorkItem { [self] in - activityLock.lock() - defer { activityLock.unlock() } - try? closeReplyTranscriptView(wait: false) + // This may be called while already executing on the messages-controller + // lane. A plain Task would inherit that task-local state, then trip the + // re-entrancy assertion when this delayed cleanup enqueues fresh lane work. + cancelReplyTranscriptViewTask = Task.detached { [weak self] in + do { + try await Task.sleep(forTimeInterval: 1.5) + try await PlatformAPI.runOnMessagesControllerLane { [weak self] in + try Task.checkCancellation() + guard let self else { return } + try await closeReplyTranscriptView() + } + } catch is CancellationError { + return + } catch { + log.error("failed to close reply transcript view after automation: \(String(reflecting: error))") + } } - afterAutomationTask.map { Self.queue.asyncAfter(deadline: .now() + 1.5, execute: $0) } } private func messageAction(messageCell: Accessibility.Element, action: MessageAction) throws -> Accessibility.Action { @@ -501,23 +562,23 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - private func openReactionPicker(messageCell: Accessibility.Element) throws { + private func openReactionPicker(messageCell: Accessibility.Element) async throws { let reactAction = try messageAction(messageCell: messageCell, action: .react) try reactAction() // performing this 2x will close reaction view if isSequoiaOrUp { // wait for animation - Thread.sleep(forTimeInterval: 0.75) + try await Task.sleep(forTimeInterval: 0.75) } } - private func openCustomEmojiReactionPicker(messageCell: Accessibility.Element) throws { + private func openCustomEmojiReactionPicker(messageCell: Accessibility.Element) async throws { if let action = try customMessageAction(messageCell: messageCell, named: LocalizedStrings.addEmojiTapback) { try action() return } - try openReactionPicker(messageCell: messageCell) - try elements.addCustomEmojiReactionButton.press() + try await openReactionPicker(messageCell: messageCell) + try await elements.addCustomEmojiReactionButton.press() } private func threadCellAction(threadCell: Accessibility.Element, namePrefix: String) throws -> Accessibility.Action? { @@ -534,17 +595,17 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try action() } - private func triggerThreadCellAction(threadID: String, action: ThreadAction) throws { - let threadCell = try scrollAndGetSelectedThreadCell(threadID: threadID) + private func triggerThreadCellAction(threadID: String, action: ThreadAction) async throws { + let threadCell = try await scrollAndGetSelectedThreadCell(threadID: threadID) try triggerThreadCellAction(threadCell: threadCell, action: action) } - private func selectNextThreadAndScroll() throws { - let selectedThreadCell = elements.selectedThreadCell + private func selectNextThreadAndScroll() async throws { + let selectedThreadCell = await elements.selectedThreadCell // ctrlTab() acts differently, has no effect? try keyPresser.commandRightBracket() // scrolls to next thread cell, rare edge case: won't work for the last item - try retry(withTimeout: 0.5, interval: 0.05) { // wait for hotkey to switch threads - let nextThreadCell = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) + try await retry(withTimeout: 0.5, interval: 0.05) { () async throws in // wait for hotkey to switch threads + let nextThreadCell = try await elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if let selectedThreadCell, nextThreadCell == selectedThreadCell { throw ErrorMessage("diff thread not selected") } @@ -570,7 +631,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 3. open target thread 4. triggerThreadCellAction(threadCell: composeCell, action: .delete) // scrolls to wanted thread */ - private func scrollAndGetSelectedThreadCell(threadID: String) throws -> Accessibility.Element { + private func scrollAndGetSelectedThreadCell(threadID: String) async throws -> Accessibility.Element { #if DEBUG let startTime = Date() defer { log.debug("scrollAndGetSelectedThreadCell took \(startTime.timeIntervalSinceNow * -1000)ms") } @@ -578,136 +639,87 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // we assume thread is already selected - let selectedCell = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) + let selectedCell = try await elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if selectedCell.isInViewport { return selectedCell } - try selectNextThreadAndScroll() - try openThread(threadID) + try await selectNextThreadAndScroll() + try await openThread(threadID) - let selectedCellAfterScroll = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) + let selectedCellAfterScroll = try await elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if selectedCellAfterScroll.isInViewport { return selectedCellAfterScroll } throw ErrorMessage("threadCell not found") } // performs `perform` while the Messages window is unhidden private func withActivation( - openBefore: URL?, openAfter: URL? = nil, - perform: () throws -> Void - ) throws { + openBefore: MessagesDeepLink?, + openAfter: MessagesDeepLink? = nil, + perform: () async throws -> Void + ) async throws { + let openBeforeURL = try openBefore?.url() if let openBefore { #if DEBUG - log.debug("withActivation: opening before performing: \(openBefore)") + log.debug("withActivation: opening before performing: \(openBeforeURL?.absoluteString ?? "")") #endif - try openDeepLink(openBefore) + try await openDeepLink(openBefore) } - try perform() + try await perform() if let openAfter { - if openAfter != openBefore { + let openAfterURL = try openAfter.url() + if openAfterURL != openBeforeURL { #if DEBUG - debugLog("withActivation: opening after performing: \(openAfter)") + debugLog("withActivation: opening after performing: \(openAfterURL)") #endif - try openDeepLink(openAfter) + try await openDeepLink(openAfter) } } } - private func revealReplyTranscriptViaMenu() throws { - do { - let window = NSApp.largestElectronWindow - let previousLevel = window?.level - if let window { - DispatchQueue.main.sync { - let higherLevel = NSWindow.Level(Int(CGWindowLevelForKey(.draggingWindow))) - log.debug("reveal: elevating window to level \(higherLevel) (currently: \(window.level))") - window.level = higherLevel - } - } - defer { - if let window, let previousLevel { - DispatchQueue.main.sync { - log.debug("reveal: lowering window to previous level \(previousLevel)") - window.level = previousLevel - } - } - } - - try Self.queue.sync { - guard let cell = try? MessagesAppElements.firstSelectedMessageCell(in: elements.transcriptView) else { - throw ErrorMessage("reveal: couldn't find selected message cell to show overlay with") - } - - Thread.sleep(forTimeInterval: 1.0) - log.debug("reveal: 1/5 showing the cell's menu") - try cell.showMenu() - Thread.sleep(forTimeInterval: 0.1) - - let targetTitle = LocalizedStrings.inlineReplyMenu - log.debug("reveal: 2/5 locating reply menu item (with title \"\(targetTitle)\")") - - guard let menuItems = try? elements.menu.children() else { - throw ErrorMessage("reveal: couldn't query menu item children") - } - guard let replyMenuItem = menuItems.first(where: { menuItem in - guard let title = try? menuItem.title() else { - return false - } - - let idIfPossible = ((try? menuItem.identifier()).map { " [ID: \"\($0)\"]" }) ?? "" - log.debug("reveal: 2/5 witnessed: \"\(title)\"\(idIfPossible)") - return title == targetTitle - }) else { - throw ErrorMessage("reveal: couldn't find reply menu item") - } - - log.debug("reveal: 3/5 found, pressing") - try replyMenuItem.press() - } - } - - log.debug("reveal: 4/5 sleeping for a bit") - Thread.sleep(forTimeInterval: 0.4) - - log.debug("reveal: 5/5 done, proceeding with grabbing the cell") - } - - private func withMessageCell(threadID: String, messageCell: MessageCell, action: (_ cell: Accessibility.Element) throws -> Void) throws { + private func withMessageCell(threadID: String, messageCell: MessageCell, action: (_ cell: Accessibility.Element) async throws -> Void) async throws { log.debug("withMessageCell (messageCell=\(messageCell))") - let url = try MessagesDeepLink.message( + let deepLink = MessagesDeepLink.message( guid: messageCell.messageGUID, partIndex: messageCell.partIndex, - overlay: messageCell.overlay - ).url() + overlay: messageCell.overlay, + threadID: threadID + ) // without closing reply transcript, non-overlay deep link won't select the message if !messageCell.overlay { - try? closeReplyTranscriptView(wait: false) + _ = try? await closeReplyTranscriptView() } - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + try await withActivation(openBefore: deepLink) { + try await assertSelectedThread(threadID: threadID) // we don't close transcript view here because when reacting, closing it will undo the reaction // defer { // if messageCell.overlay { // // alt: try? sendKeyPress(key: CGKeyCode(kVK_Escape)) - // closeReplyTranscriptView(wait: true) + // try await closeReplyTranscriptViewAndWait() // } // } if messageCell.overlay { - try waitUntilReplyTranscriptVisible() + try await waitUntilReplyTranscriptVisible() } - guard let selected = (try retry(withTimeout: 1, interval: 0.2) { () -> Accessibility.Element? in - guard let cell = try messageCell.overlay - ? MessagesAppElements.firstMessageCell(in: elements.replyTranscriptView) - : MessagesAppElements.firstSelectedMessageCell(in: elements.transcriptView) - else { + guard let (selected, transcript) = (try await retry(withTimeout: 1, interval: 0.2) { () async throws -> (cell: Accessibility.Element, transcript: Accessibility.Element)? in + let transcript: Accessibility.Element + let cell: Accessibility.Element? + if messageCell.overlay { + transcript = try await elements.replyTranscriptView + cell = try MessagesAppElements.firstMessageCell(in: transcript) + } else { + transcript = try await elements.transcriptView + cell = try MessagesAppElements.firstSelectedMessageCell(in: transcript) + } + guard let cell else { throw ErrorMessage("message cell nil") } guard cell.isInViewport else { throw ErrorMessage("message cell not in viewport") } - return cell + return (cell, transcript) }) else { throw ErrorMessage("Could not find message cell") } @@ -718,7 +730,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let containerCell = try selected.parent() let containerFrame = try containerCell.frame() let containerCells = try MessagesAppElements.messageContainerCells( - in: messageCell.overlay ? elements.replyTranscriptView : elements.transcriptView + in: transcript ) guard let idx: Int = containerCells.firstIndex(where: { (try? $0.frame()) == containerFrame }) else { @@ -746,90 +758,89 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) throw ErrorMessage("Cell id mismatch") } } - try action(targetCell) + try await action(targetCell) } } - func setReaction(threadID: String, messageCell: MessageCell, reaction: Reaction, on: Bool) throws { + func setReaction(threadID: String, messageCell: MessageCell, reaction: Reaction, on: Bool) async throws { let startTime = Date() defer { log.debug("setReaction took \(startTime.timeIntervalSinceNow * -1000)ms") } - try prepareForAutomation() - defer { finishedAutomation() } - - try withMessageCell(threadID: threadID, messageCell: messageCell) { - if let directAction = try directReactionAction(messageCell: $0, reaction: reaction) { - try directAction() - return - } - - if case let .custom(emoji) = reaction, on { - guard isSequoiaOrUp else { throw ErrorMessage("Custom emoji reactions are only supported on macOS 15 or later") } - try openCustomEmojiReactionPicker(messageCell: $0) - // TODO: support being able to pick a skin tone - let search: CharacterPickerSearch - do { - search = try CharacterPickerSearch(finding: emoji) - } catch { - throw ErrorMessage("Can't react with \"\(emoji)\": \(String(describing: error))") - } - let searchField = try retry(withTimeout: 1.0, interval: 0.05) { - try elements.characterPickerSearchField - } - try searchField.value(assign: search.query) - Thread.sleep(forTimeInterval: 0.75) // wait for search - // focus the matrix (tab also seems to work for this? full keyboard access needed maybe?) - try keyPresser.downArrow() - // 6 columns in the character picker matrix - let (downArrows, rightArrows) = search.position.quotientAndRemainder(dividingBy: 6) - // navigate to the emoji - for _ in 0.. idx else { - throw ErrorMessage("reactButtons count=\(buttons.count)") - } + try await openReactionPicker(messageCell: $0) + + let btn = try await { () async throws -> Accessibility.Element in + if isSequoiaOrUp { + return try await elements.tapbackPickerCollectionView.children() + .first { + // standard: "ha", "thumbsUp", etc. custom: emoji string + let identifier = try? $0.identifier() + return identifier == reaction.idOrEmoji + } + .orThrow(ErrorMessage("Could not find \(on ? "react" : "unreact") button")) + } + + let idx = reaction.index! + let buttons = try await elements.reactButtons + guard buttons.count > idx else { + throw ErrorMessage("reactButtons count=\(buttons.count)") + } - return buttons[idx] - }() + return buttons[idx] + }() - try retry(withTimeout: 1.2, interval: 0.1) { - let isSelected = try btn.isSelected() - if isSelected != on { - try btn.press() - log.debug("Reaction: \(Result { try btn.localizedDescription() }) \(Result { try btn.isSelected() })") - guard try btn.isSelected() == on else { - throw ErrorMessage("Could not react") + try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in + let isSelected = try btn.isSelected() + if isSelected != on { + try btn.press() + log.debug("Reaction: \(Result { try btn.localizedDescription() }) \(Result { try btn.isSelected() })") + guard try btn.isSelected() == on else { + throw ErrorMessage("Could not react") + } } } } } } - func undoSend(threadID: String, messageCell: MessageCell) throws { + func undoSend(threadID: String, messageCell: MessageCell) async throws { guard isVenturaOrUp else { throw ErrorMessage("!isVenturaOrUp") } @@ -837,29 +848,27 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let startTime = Date() defer { log.debug("undoSend took \(startTime.timeIntervalSinceNow * -1000)ms") } - try prepareForAutomation() - defer { finishedAutomation() } - - try withMessageCell(threadID: threadID, messageCell: messageCell) { - let undoSendAction = try messageAction(messageCell: $0, action: .undoSend) - try undoSendAction() + try await withAutomation { + try await withMessageCell(threadID: threadID, messageCell: messageCell) { + let undoSendAction = try messageAction(messageCell: $0, action: .undoSend) + try undoSendAction() + } } } - func loadAttachment(threadID: String, messageCell: MessageCell) throws { + func loadAttachment(threadID: String, messageCell: MessageCell) async throws { let startTime = Date() defer { log.debug("loadAttachment took \(startTime.timeIntervalSinceNow * -1000)ms") } - try prepareForAutomation() - defer { finishedAutomation() } - - try withMessageCell(threadID: threadID, messageCell: messageCell) { messageCell in - try messageCell.press() + try await withAutomation { + try await withMessageCell(threadID: threadID, messageCell: messageCell) { messageCell in + try messageCell.press() + } } } // NOTE: message editing works even when the window is ordered out - func editMessage(threadID: String, messageCell: MessageCell, newText: String) throws { + func editMessage(threadID: String, messageCell: MessageCell, newText: String) async throws { guard isVenturaOrUp else { throw ErrorMessage("!isVenturaOrUp") } @@ -867,121 +876,87 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let startTime = Date() defer { log.debug("editMessage took \(startTime.timeIntervalSinceNow * -1000)ms") } - try prepareForAutomation() - defer { finishedAutomation() } - - func tryPressingCancelEditButton() { - if let cancelEditButton = try? elements.cancelEditButton { + func tryPressingCancelEditButton() async throws { + if let cancelEditButton = try? await elements.cancelEditButton { // this is seemingly always available, even when you're not editing log.debug("pressing cancel edit button") do { try cancelEditButton.press() - Thread.sleep(forTimeInterval: 0.5) } catch { log.error("failed to press cancel edit button, continuing anyway: \(error)") + return } + try await Task.sleep(forTimeInterval: 0.5) } else { log.debug("(no cancel edit button was found)") } } - func assignAndCommitEdit() throws { - Thread.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeReplacing)) - let editableMessageField = try elements.editableMessageField - try assignToMessageField(editableMessageField, text: newText) + func assignAndCommitEdit() async throws { + try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeReplacing)) + let editableMessageField = try await elements.editableMessageField + try await assignToMessageField(editableMessageField, text: newText) - Thread.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeFocusing)) - focusMessageField(editableMessageField) + try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeFocusing)) + try await focusMessageField(editableMessageField) - Thread.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) + try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) try keyPresser.return() // elements.editConfirmButton.press() works only after a 0.2s+ delay // todo: wait for it to disappear } - let onError = { (attempt: Int, error: (any Error)?) in + let onError = { (attempt: Int, error: (any Error)?) async throws in let errorDescription = String(describing: error) log.warning("failed to edit (attempt \(attempt)), pressing cancel edit button and retrying: \(errorDescription)") - tryPressingCancelEditButton() + try await tryPressingCancelEditButton() } - tryPressingCancelEditButton() + try await withAutomation { + try await tryPressingCancelEditButton() - try withMessageCell(threadID: threadID, messageCell: messageCell) { messageCell in - if let editAction = try? messageAction(messageCell: messageCell, action: .edit) { - log.debug("found \"Edit\" message action") + try await withMessageCell(threadID: threadID, messageCell: messageCell) { messageCell in + if let editAction = try? messageAction(messageCell: messageCell, action: .edit) { + log.debug("found \"Edit\" message action") - try retry(withTimeout: 6.0, interval: 2.0, { - try editAction() - try assignAndCommitEdit() - }, onError: onError) + try await retry(withTimeout: 6.0, interval: 2.0, { + try editAction() + try await assignAndCommitEdit() + }, onError: onError) - return - } + return + } + + // this doesn't work reliably: + // try $0.press(); $0.isFocused(assign: true); $0.isSelected(assign: true); keyPresser.commandE() + try messageCell.showMenu() + // retrying this too rapidly can cause the floating editor to appear more than once? + try await retry(withTimeout: 6.0, interval: 2.0, { + try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) + try await elements.menuEditItem.press() - // this doesn't work reliably: - // try $0.press(); $0.isFocused(assign: true); $0.isSelected(assign: true); keyPresser.commandE() - try messageCell.showMenu() - // retrying this too rapidly can cause the floating editor to appear more than once? - try retry(withTimeout: 6.0, interval: 2.0, { - Thread.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) - try elements.menuEditItem.press() - - try assignAndCommitEdit() - }, onError: onError) - } - } - - #if false - // this is unusable because showing menu makes it first responder - // keep this code as documentation - func markAsReadWithMenu(threadID: String) throws { - try prepareForAutomation() - defer { finishedAutomation() } - - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - - let threadCell = try scrollAndGetSelectedThreadCell(threadID: threadID) - try threadCell.showMenu() - - let menu = try elements.menu - /* - AXMenuItem unpin - AXMenuItem open_conversation_in_separate_window - AXMenuItem delete_conversation… - AXMenuItem - AXMenuItem details… - AXMenuItem hide_alerts - AXMenuItem mark_as_read - AXMenuItem - AXMenuItem - */ - guard let markAsReadMenuItem = (try retry(withTimeout: 0.5, interval: 0.1) { try menu.children().first(where: { (try? $0.identifier()) == "mark_as_read" }) }) else { - throw ErrorMessage("markAsReadMenuItem not found") + try await assignAndCommitEdit() + }, onError: onError) } - try markAsReadMenuItem.press() } } - #endif // this only works when the messages.app window has been activated at least once // can randomly stop working. a reactivation of messages.app may fix (unhandled) - private func markAsReadWithPressHack(threadID: String) throws { + private func markAsReadWithPressHack(threadID: String) async throws { #if DEBUG let startTime = Date() defer { log.debug("markAsReadWithPressHack took \(startTime.timeIntervalSinceNow * -1000)ms") } #endif - try openThread(threadID) - let threadCell = try scrollAndGetSelectedThreadCell(threadID: threadID) + try await openThread(threadID) + let threadCell = try await scrollAndGetSelectedThreadCell(threadID: threadID) // select any another cell and then come back - try selectNextThreadAndScroll() + try await selectNextThreadAndScroll() // scrollToVisible is needed since sometimes the thread cell can be behind the search input field causing .press() to focus the input field instead try threadCell.scrollToVisible() try threadCell.press() - try? assertSelectedThread(threadID: threadID) + try? await assertSelectedThread(threadID: threadID) } /* @@ -992,119 +967,130 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 3. when less than 9 pinned threads: pin thread, #2, unpin (reliable) 4. threadCell.press() action hack (unreliable) */ - func toggleThreadRead(threadID: String, read: Bool) throws { + func toggleThreadRead(threadID: String, read: Bool) async throws { let startTime = Date() defer { log.debug("toggleThreadRead took \(startTime.timeIntervalSinceNow * -1000)ms") } - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) - try prepareForAutomation() - defer { finishedAutomation() } - - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - if isVenturaOrUp { - return try keyPresser.commandShiftU() - } - let action = read ? ThreadAction.markAsRead : ThreadAction.markAsUnread - if Defaults.isSelectedThreadCellPinned() { - try triggerThreadCellAction(threadID: threadID, action: action) - } else if let pinnedCount = Defaults.pinnedThreadsCount(), pinnedCount < 9 { - defer { - if Defaults.pinnedThreadsCount() != pinnedCount { - try? retry(withTimeout: 0.3, interval: 0.05) { + try await withAutomation { + try await withActivation(openBefore: deepLink) { + try await assertSelectedThread(threadID: threadID) + if isVenturaOrUp { + return try keyPresser.commandShiftU() + } + let action = read ? ThreadAction.markAsRead : ThreadAction.markAsUnread + if Defaults.isSelectedThreadCellPinned() { + try await triggerThreadCellAction(threadID: threadID, action: action) + } else if let pinnedCount = Defaults.pinnedThreadsCount(), pinnedCount < 9 { + func restorePinsIfNecessary() async { + guard Defaults.pinnedThreadsCount() != pinnedCount else { return } + let deadline = Date().addingTimeInterval(0.3) + repeat { log.debug("retrying unpin") - try triggerThreadCellAction(threadID: threadID, action: .unpin) + if (try? await triggerThreadCellAction(threadID: threadID, action: .unpin)) != nil { + break + } + try? await Task.sleep(forTimeInterval: 0.05) + } while Date() < deadline + if Defaults.pinnedThreadsCount() != pinnedCount { + reportErrorMessage?("couldn't restore pins \(Defaults.pinnedThreadsCount() ?? -1) != \(pinnedCount)") } } - if Defaults.pinnedThreadsCount() != pinnedCount { - reportErrorMessage?("couldn't restore pins \(Defaults.pinnedThreadsCount() ?? -1) != \(pinnedCount)") + do { + try await triggerThreadCellAction(threadID: threadID, action: .pin) + // after pin/unpin elements.selectedThreadCell is nil because no cells are selected + // openThread ensures scroll logic isn't executed + try await openThread(threadID) + let threadCell = try await scrollAndGetSelectedThreadCell(threadID: threadID) + defer { try? triggerThreadCellAction(threadCell: threadCell, action: .unpin) } + try triggerThreadCellAction(threadCell: threadCell, action: action) + } catch { + await restorePinsIfNecessary() + throw error } + await restorePinsIfNecessary() + } else { + try await markAsReadWithPressHack(threadID: threadID) } - try triggerThreadCellAction(threadID: threadID, action: .pin) - // after pin/unpin elements.selectedThreadCell is nil because no cells are selected - // openThread ensures scroll logic isn't executed - try openThread(threadID) - let threadCell = try scrollAndGetSelectedThreadCell(threadID: threadID) - defer { try? triggerThreadCellAction(threadCell: threadCell, action: .unpin) } - try triggerThreadCellAction(threadCell: threadCell, action: action) - } else { - try markAsReadWithPressHack(threadID: threadID) } } } - func muteThread(threadID: String, muted: Bool) throws { + func muteThread(threadID: String, muted: Bool) async throws { #if DEBUG let startTime = Date() defer { log.debug("muteThread took \(startTime.timeIntervalSinceNow * -1000)ms") } #endif - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - - try prepareForAutomation() - defer { finishedAutomation() } + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - let selectedThreadCell = try scrollAndGetSelectedThreadCell(threadID: threadID) - if muted { - try triggerThreadCellAction(threadCell: selectedThreadCell, action: .hideAlerts) - } else if let showAlertsAction = try threadCellAction(threadCell: selectedThreadCell, action: .showAlerts) { - try showAlertsAction() - } else { - let hideAlertsOn = "\(ThreadAction.hideAlerts.localized), On" - let action = try threadCellAction(threadCell: selectedThreadCell, namePrefix: hideAlertsOn) - .orThrow(ErrorMessage("ThreadAction.showAlerts and \(hideAlertsOn) not found")) - try action() + try await withAutomation { + try await withActivation(openBefore: deepLink) { + try await assertSelectedThread(threadID: threadID) + let selectedThreadCell = try await scrollAndGetSelectedThreadCell(threadID: threadID) + if muted { + try triggerThreadCellAction(threadCell: selectedThreadCell, action: .hideAlerts) + } else if let showAlertsAction = try threadCellAction(threadCell: selectedThreadCell, action: .showAlerts) { + try showAlertsAction() + } else { + let hideAlertsOn = "\(ThreadAction.hideAlerts.localized), On" + let action = try threadCellAction(threadCell: selectedThreadCell, namePrefix: hideAlertsOn) + .orThrow(ErrorMessage("ThreadAction.showAlerts and \(hideAlertsOn) not found")) + try action() + } } } } - func deleteThread(threadID: String) throws { + func deleteThread(threadID: String) async throws { #if DEBUG let startTime = Date() defer { log.debug("deleteThread took \(startTime.timeIntervalSinceNow * -1000)ms") } #endif - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) - try prepareForAutomation() - defer { finishedAutomation() } - - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - try triggerThreadCellAction(threadID: threadID, action: .delete) - try elements.alertSheetDeleteButton.press() + try await withAutomation { + try await withActivation(openBefore: deepLink) { + try await assertSelectedThread(threadID: threadID) + try await triggerThreadCellAction(threadID: threadID, action: .delete) + try await elements.alertSheetDeleteButton.press() + } } } - func sendTypingStatus(threadID: String) throws { + func sendTypingStatus(threadID: String) async throws { // a space is enough to send a typing indicator, while ensuring that // users can't accidentally hit return to send a single-char message // (since Messages special-cases space-only messages). The NUL byte // is another option that doesn't get sent to the server, but it // shows up client-side as a ghost message. - let url = try MessagesDeepLink(threadID: threadID, body: " ").url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: " ") - try prepareForAutomation() - defer { finishedAutomation() } - - try openDeepLink(url) + try await withAutomation { + _ = try await openDeepLink(deepLink) + } } - func clearTypingStatus() throws { - try elements.messageBodyField.value(assign: "") + func clearTypingStatus() async throws { + try await elements.messageBodyField.value(assign: "") } - private func focusMessageField(_ messageField: Accessibility.Element) { - try? retry(withTimeout: 0.8, interval: 0.1) { - // this doesn't ever focus in compose thread for some reason - try messageField.isFocused(assign: true) - if isComposeThreadSelected() { return } - guard try messageField.isFocused() else { - throw ErrorMessage("Could not focus message field") + private func focusMessageField(_ messageField: Accessibility.Element) async throws { + do { + try await retry(withTimeout: 0.8, interval: 0.1) { () async throws in + // this doesn't ever focus in compose thread for some reason + try messageField.isFocused(assign: true) + if await isComposeThreadSelected() { return } + guard try messageField.isFocused() else { + throw ErrorMessage("Could not focus message field") + } } + } catch let error as CancellationError { + throw error + } catch { + return } } @@ -1123,8 +1109,8 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - private func assignToMessageField(_ messageField: Accessibility.Element, text: String) throws { - try retry(withTimeout: 1, interval: 0.1) { + private func assignToMessageField(_ messageField: Accessibility.Element, text: String) async throws { + try await retry(withTimeout: 1, interval: 0.1) { () async throws in try messageField.value(assign: text) // we don't test if messageFieldValue() == text here because a few ms later, messageFieldValue will likely change if text has @mentions let charCountResult = Result { try messageField.noOfChars() } @@ -1140,16 +1126,16 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - private func sendMessageInField(_ messageField: Accessibility.Element) throws { + private func sendMessageInField(_ messageField: Accessibility.Element) async throws { log.debug("\(#function): focusing field and pressing return") - focusMessageField(messageField) // focus is partially redundant, hitting enter without focus works too unless another text field is focused + try await focusMessageField(messageField) // focus is partially redundant, hitting enter without focus works too unless another text field is focused try keyPresser.return() // in some random cases hitting enter will not send the message (even without automation), until the message input is clicked/focused log.debug("\(#function): completed initial attempt") do { log.debug("\(#function): will now attempt to verify the send") - try retry(withTimeout: 1.5, interval: 0.1) { + try await retry(withTimeout: 1.5, interval: 0.1) { () async throws in let message = try messageFieldValue(messageField) if !message.isEmpty { let hasNewline = message.hasSuffix("\n") @@ -1163,7 +1149,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if attempt == 2 { log.debug("\(#function): focusing and pressing enter again") - self.focusMessageField(messageField) + try await self.focusMessageField(messageField) try? self.keyPresser.return() } else if attempt == 6 { log.debug("\(#function): focusing and pressing enter again (alt. strategy)") @@ -1174,6 +1160,8 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } log.debug("\(#function): successfully verified the send") + } catch let error as CancellationError { + throw error } catch { // if we can't verify the message send, then blindly swallow the error and assume that the send went through; don't let // it bubble to the TypeScript side, which will retry (since we specifically do that for failed message sends). this @@ -1186,48 +1174,51 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - private func closeReplyTranscriptView(wait: Bool) throws { - guard let rtv = try? elements.replyTranscriptView else { return } + @discardableResult + private func closeReplyTranscriptView() async throws -> Bool { + guard let rtv = try? await elements.replyTranscriptView else { return false } log.debug("calling replyTranscriptView.cancel()") try rtv.cancel() - func waitForReplyTranscriptsClose() throws { - try retry(withTimeout: 1.2, interval: 0.1) { - guard let pValue = try? elements.messageBodyField.placeholderValue(), - pValue == LocalizedStrings.imessage || pValue == LocalizedStrings.textMessage else { - throw ErrorMessage("replyTranscriptView visible") - } + return true + } + + private func closeReplyTranscriptViewAndWait() async throws { + guard try await closeReplyTranscriptView() else { return } + try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in + guard let pValue = try? await elements.messageBodyField.placeholderValue(), + pValue == LocalizedStrings.imessage || pValue == LocalizedStrings.textMessage else { + throw ErrorMessage("replyTranscriptView visible") } - Thread.sleep(forTimeInterval: 0.4) // wait for animation still } - if wait { try waitForReplyTranscriptsClose() } + try await Task.sleep(forTimeInterval: 0.4) // wait for animation still } - private func waitUntilReplyTranscriptVisible() throws { + private func waitUntilReplyTranscriptVisible() async throws { log.debug("waitUntilReplyTranscriptVisible") - try retry(withTimeout: 1.2, interval: 0.1) { - guard let pValue = try? elements.messageBodyField.placeholderValue(), + try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in + guard let pValue = try? await elements.messageBodyField.placeholderValue(), pValue != LocalizedStrings.imessage && pValue != LocalizedStrings.textMessage else { throw ErrorMessage("replyTranscriptView not visible") } } } - private func sendReplyWithoutOverlay(threadID: String, quotedMessage: MessageCell, text: String?, filePath: String?) throws { - try withMessageCell(threadID: threadID, messageCell: quotedMessage) { + private func sendReplyWithoutOverlay(threadID: String, quotedMessage: MessageCell, text: String?, filePath: String?) async throws { + try await withMessageCell(threadID: threadID, messageCell: quotedMessage) { let replyAction = try messageAction(messageCell: $0, action: .reply) try replyAction() - let messageField = try elements.messageBodyField + let messageField = try await elements.messageBodyField if let text { - try assignToMessageField(messageField, text: text) - try sendMessageInField(messageField) + try await assignToMessageField(messageField, text: text) + try await sendMessageInField(messageField) } else if let filePath { - try pasteFileInBodyFieldAndSend(messageField, filePath: filePath) + try await pasteFileInBodyFieldAndSend(messageField, filePath: filePath) } } } // this method has a lot of combinations, test carefully - func sendMessage(threadID: String?, addresses: [String]?, text: String?, filePath: String?, quotedMessage: MessageCell?) throws { + func sendMessage(threadID: String?, addresses: [String]?, text: String?, filePath: String?, quotedMessage: MessageCell?) async throws { let startTime = Date() defer { log.debug("sendMessage took \(startTime.timeIntervalSinceNow * -1000)ms") } @@ -1240,7 +1231,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // A. [expected] we're in the correct chat (same thread ID as the one we're sending a message to using OSA). we can safely clear the textfield since we've sent the message and we don't need to show a typing indicator. // B. [unexpected] we're in a different chat than the one we're sending the message in, in which case we should clear the textfield anyway since we don't want to send a typing indicator. // in both cases, it is necessary to clear the typing indicator. - try? clearTypingStatus() + try? await clearTypingStatus() try OSA.send(threadID: threadID, text: text) return } @@ -1248,7 +1239,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // we don't always use OSA for files bc send file is randomly unreliable if !isMontereyOrUp { // messages.app in big sur doesn't correctly paste the file // safe for the same reasons as the message above - try? clearTypingStatus() + try? await clearTypingStatus() try OSA.send(threadID: threadID, filePath: filePath) return } @@ -1259,58 +1250,64 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - let url: URL - if let quotedMessage { - url = try MessagesDeepLink.message( + let deepLink: MessagesDeepLink + if let quotedMessage, let threadID { + deepLink = MessagesDeepLink.message( + guid: quotedMessage.messageGUID, + partIndex: quotedMessage.partIndex, + overlay: quotedMessage.overlay, + threadID: threadID + ) + } else if let quotedMessage { // todo: this branch shouldn't exist, threadID can be fetched from message guid + deepLink = MessagesDeepLink.message( guid: quotedMessage.messageGUID, partIndex: quotedMessage.partIndex, overlay: quotedMessage.overlay - ).url() + ) } else if let threadID { - url = try MessagesDeepLink(threadID: threadID, body: text).url() + deepLink = try MessagesDeepLink(threadID: threadID, body: text) } else if let addresses { - url = try MessagesDeepLink.addresses(addresses, body: text).url() + deepLink = MessagesDeepLink.addresses(addresses, body: text) } else { throw ErrorMessage("not implemented") } - try prepareForAutomation() - defer { finishedAutomation() } - - // this isn't reliable so we use pasteFileInBodyFieldAndSend: - // if let filePath { - // guard let address = threadIDToAddress(threadID) else { throw ErrorMessage("invalid threadID") } - // try withAllWindowsClosed { - // try DraftsManager.saveDraft(address: String(address), filePath: filePath) - // } - // } - if let quotedMessage, !quotedMessage.overlay, let threadID { - try sendReplyWithoutOverlay(threadID: threadID, quotedMessage: quotedMessage, text: text, filePath: filePath) - return - } + try await withAutomation { + // this isn't reliable so we use pasteFileInBodyFieldAndSend: + // if let filePath { + // guard let address = threadIDToAddress(threadID) else { throw ErrorMessage("invalid threadID") } + // try withAllWindowsClosed { + // try DraftsManager.saveDraft(address: String(address), filePath: filePath) + // } + // } + if let quotedMessage, !quotedMessage.overlay, let threadID { + try await sendReplyWithoutOverlay(threadID: threadID, quotedMessage: quotedMessage, text: text, filePath: filePath) + return + } - if quotedMessage == nil { try? closeReplyTranscriptView(wait: true) } // needed even when opening deep link + if quotedMessage == nil { try? await closeReplyTranscriptViewAndWait() } // needed even when opening deep link - try withActivation(openBefore: url) { - if let threadID { try assertSelectedThread(threadID: threadID) } + try await withActivation(openBefore: deepLink) { + if let threadID { try await assertSelectedThread(threadID: threadID) } - if quotedMessage != nil { - try waitUntilReplyTranscriptVisible() - } - if isComposeThreadSelected() { - // since this is a new thread not in contacts, it may take a while for messages app to resolve that the address is imessage and not just sms - log.debug("waiting 3s for address to resolve") - Thread.sleep(forTimeInterval: 3) - } + if quotedMessage != nil { + try await waitUntilReplyTranscriptVisible() + } + if await isComposeThreadSelected() { + // since this is a new thread not in contacts, it may take a while for messages app to resolve that the address is imessage and not just sms + log.debug("waiting 3s for address to resolve") + try await Task.sleep(forTimeInterval: 3) + } - let messageField = try elements.messageBodyField - if let text { - if quotedMessage != nil { // text has to be manually assigned when quoted since ?body in deep link doesn't take any effect - try assignToMessageField(messageField, text: text) + let messageField = try await elements.messageBodyField + if let text { + if quotedMessage != nil { // text has to be manually assigned when quoted since ?body in deep link doesn't take any effect + try await assignToMessageField(messageField, text: text) + } + try await sendMessageInField(messageField) + } else if let filePath { + try await pasteFileInBodyFieldAndSend(messageField, filePath: filePath) } - try sendMessageInField(messageField) - } else if let filePath { - try pasteFileInBodyFieldAndSend(messageField, filePath: filePath) } } } @@ -1324,18 +1321,18 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } #if DEBUG - func closeAllWindows() throws { - try elements.mainWindow.closeWindow() + func closeAllWindows() async throws { + try await elements.mainWindow.closeWindow() try elements.app.appWindows().forEach { try $0.closeWindow() } } - func withAllWindowsClosed(perform: () throws -> Void) throws { - try closeAllWindows() + func withAllWindowsClosed(perform: () throws -> Void) async throws { + try await closeAllWindows() try perform() - _ = try elements.mainWindow // accessing will open it + _ = try await elements.mainWindow // accessing will open it } - func assignFileToBodyField(filePath: String) throws { + func assignFileToBodyField(filePath: String) async throws { let url = URL(fileURLWithPath: filePath) let data = try Data(contentsOf: url) print(data, url) @@ -1345,7 +1342,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let mas = NSMutableAttributedString() mas.append(myAttrString) - let messageField = try elements.messageBodyField + let messageField = try await elements.messageBodyField try messageField.value(assign: url) // no op try messageField.value(assign: mas) // illegalArgument try messageField.value(assign: data) // cannotComplete @@ -1355,78 +1352,123 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } #endif - func pasteFileInBodyFieldAndSend(_ messageField: Accessibility.Element, filePath: String) throws { + func pasteFileInBodyFieldAndSend(_ messageField: Accessibility.Element, filePath: String) async throws { let fileURL = URL(fileURLWithPath: filePath) var messageField = messageField try? messageField.value(assign: "") - focusMessageField(messageField) // focus is partially redundant, hitting ⌘ V without focus works too unless another text field is focused + try await focusMessageField(messageField) // focus is partially redundant, hitting ⌘ V without focus works too unless another text field is focused let pasteboard = NSPasteboard.general - try pasteboard.withRestoration { + try await pasteboard.withRestoration { pasteboard.setString(fileURL.relativeString, forType: .fileURL) try keyPresser.commandV() - try retry(withTimeout: 2, interval: 0.05) { + try await retry(withTimeout: 2, interval: 0.05) { () async throws in let charCountResult = Result { try messageField.noOfChars() } guard case let .success(charCount) = charCountResult else { - messageField = try elements.messageBodyField + messageField = try await elements.messageBodyField throw ErrorMessage("cannot get char count: \(charCountResult)") } // 2 for and \n guard charCount == 2 else { - messageField = try elements.messageBodyField + messageField = try await elements.messageBodyField throw ErrorMessage("file was not pasted: \(charCountResult)") } } - try sendMessageInField(messageField) + try await sendMessageInField(messageField) } } - var lastActivate: Date? - var messagesIsManuallyActivated = false + private struct ManualActivationState { + var lastActivate: Date? + var messagesIsManuallyActivated = false + } + + private let manualActivationState = Protected(ManualActivationState()) + private var messagesIsManuallyActivated: Bool { + manualActivationState.read().messagesIsManuallyActivated + } // when the user manually cmd+tab's or clicks the Messages dock icon, // we want to actually show the app - private func activateMessages() { + private func activateMessages() async { + // Set the activation flag immediately (off-lane): the idle observer reads + // `messagesIsManuallyActivated` to back off, and it should do so right away + // rather than waiting behind any in-flight automation queued on the lane. + manualActivationState.withLock { + $0.lastActivate = Date() + $0.messagesIsManuallyActivated = true + } + log.debug("activateMessages") + // `windowCoordinator` state (e.g. `EclipsingWindowCoordinator.windowFramePreEclipse`) + // is also mutated by `makeAutomatable` while running on the automation lane. Run the + // coordinator manipulation on the lane too so this user-activation path is serialized + // with automation instead of racing it off-main. + // + // Coalescing: the closure can run well behind in-flight automation (~seconds). If + // the user has since deactivated (messagesIsManuallyActivated flipped back to + // false), this activate is stale — skip it so we don't unhide a window the user + // already dismissed and so a queued activate/deactivate pair collapses. do { - lastActivate = Date() - messagesIsManuallyActivated = true - log.debug("activateMessages") - // we use getMainWindow() instead of mainWindow to not reopen the window if it's not present - if Defaults.shouldCoordinateWindow, let window = elements.getMainWindow() { - try windowCoordinator.reset(window) - try windowCoordinator.userManuallyActivated(app) + try await PlatformAPI.runOnMessagesControllerLane { [weak self] in + guard let self else { return } + self.lastOpenedThreadID = nil + guard self.messagesIsManuallyActivated else { + log.debug("activateMessages: skipping stale activation (user already deactivated)") + return + } + // we use getMainWindow() instead of mainWindow to not reopen the window if it's not present + if Defaults.shouldCoordinateWindow, let window = self.elements.getMainWindow() { + try await self.windowCoordinator.reset(window) + try await self.windowCoordinator.userManuallyActivated(self.app) + } } } catch { log.error("couldn't unhide messages window caused by user activation: \(error)") } } - private func deactivateMessages() { + private func deactivateMessages() async { + // Clear the activation flag immediately (off-lane), mirroring activateMessages. + let lastActivate = manualActivationState.withLock { state in + let lastActivate = state.lastActivate + state.messagesIsManuallyActivated = false + return lastActivate + } + lastActivate.map { log.debug("used messages.app for \($0.timeIntervalSinceNow * -1)s") } + log.debug("deactivateMessages") + // Serialize the window teardown with automation on the lane (same rationale as + // activateMessages): the coordinator, closeAllNonMainWindows, and resetWindow all + // touch window state that `makeAutomatable` mutates while running on the lane. + // + // Coalescing: mirror activateMessages. If the user re-activated before this + // closure ran (messagesIsManuallyActivated flipped back to true), this deactivate + // is stale — skip it so we don't tear down a window the user is now using. do { - lastActivate.map { log.debug("used messages.app for \($0.timeIntervalSinceNow * -1)s") } - messagesIsManuallyActivated = false - log.debug("deactivateMessages") - // we use getMainWindow() instead of mainWindow to not reopen the window if it's not present - let window = elements.getMainWindow() - if Defaults.shouldCoordinateWindow { - try windowCoordinator.userManuallyDeactivated(app) - } - try? closeAllNonMainWindows() - if window != nil { - resetWindow() + try await PlatformAPI.runOnMessagesControllerLane { [weak self] in + guard let self else { return } + guard !self.messagesIsManuallyActivated else { + log.debug("deactivateMessages: skipping stale deactivation (user re-activated)") + return + } + // we use getMainWindow() instead of mainWindow to not reopen the window if it's not present + let window = self.elements.getMainWindow() + if Defaults.shouldCoordinateWindow { + try await self.windowCoordinator.userManuallyDeactivated(self.app) + } + try? self.closeAllNonMainWindows() + if window != nil { + await self.resetWindow() + } } } catch { log.error("couldn't hide messages window caused by user activation: \(error)") } } - func activityObservation() -> ThreadActivityObservation { + func activityObservation() async -> ThreadActivityObservation { #if DEBUG let startTime = Date() defer { log.debug("activityObservation took \(startTime.timeIntervalSinceNow * -1000)ms") } #endif - func getTV() -> Accessibility.Element? { - return try? elements.transcriptView - } - guard let transcript = getTV(), + guard let transcript = try? await elements.transcriptView, let cellsToCheck = try? MessagesAppElements.threadActivityCells(in: transcript), !cellsToCheck.isEmpty else { return .unknown @@ -1468,119 +1510,108 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) ) } - func notifyAnyway(threadID: String) throws { - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + func notifyAnyway(threadID: String) async throws { + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) - try prepareForAutomation() - defer { finishedAutomation() } - - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - try elements.notifyAnywayButton.press() + try await withAutomation { + try await withActivation(openBefore: deepLink) { + try await assertSelectedThread(threadID: threadID) + try await elements.notifyAnywayButton.press() + } } } - func activityStatus(threadID: String) throws -> ThreadActivityObservation { - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - - try prepareForAutomation() - defer { finishedAutomation() } + func activityStatus(threadID: String) async throws -> ThreadActivityObservation { + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) var observation = ThreadActivityObservation.unknown - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - observation = activityObservation() + try await withAutomation { + try await withActivation(openBefore: deepLink) { + try await assertSelectedThread(threadID: threadID) + observation = await activityObservation() + } } return observation } - /* - activityLock.lock() called by: - MessagesController.observe() - MessagesController.sendMessage() - MessagesController.setReaction() - MessagesController.sendTypingStatus() - MessagesController.notifyAnyway() - MessagesController.toggleThreadRead() - MessagesController.muteThread() - MessagesController.deleteThread() - */ - private let activityLock = UnfairLock() - - private func waitForLayoutChange(timeout: TimeInterval) { + private func waitForLayoutChange(timeout: TimeInterval) async throws { let beganWaiting = Date() while Date().timeIntervalSince(beganWaiting) < timeout { - guard let lastLayoutChange = lifecycleObserver.lastLayoutChange.read() else { - continue - } - if lastLayoutChange > beganWaiting { + if let lastLayoutChange = lifecycleObserver.lastLayoutChange.read(), lastLayoutChange > beganWaiting { log.debug("observed layout change, exiting wait loop") return } - Thread.sleep(forTimeInterval: 0.05) // 50ms + try await Task.sleep(forTimeInterval: 0.05) // 50ms } log.error("didn't observe a layout change within \(timeout)s, continuing anyways") } - /// returns a callback meant to be assigned to a `PassivelyAwareDispatchQueue` that observes a single thread once - /// the passively aware dispatch queue should call the returned callback repeatedly - func idleCallback(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> ((Quiescence) throws -> Void) { - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - - return { [weak self] _ in - guard let self else { return } + /// Checks one thread while controller work is idle. + /// The platform-level idle observer calls it repeatedly after active automation drains. + func observeIdleActivity( + threadID: String, + readActivity: Bool, + statusSender: @escaping @Sendable (ThreadActivityObservation) -> Void + ) async throws { + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) - guard !Defaults.shouldCoordinateWindow && !messagesIsManuallyActivated else { - log.debug("not observing activity, Messages is manually activated") - return - } + guard isValid else { + #if DEBUG + log.debug("not observing activity, controller is invalid") + #endif + return + } - guard occlusionMonitor.visible else { - #if DEBUG - log.debug("not observing activity, window occluded") - #endif - return - } + guard !messagesIsManuallyActivated else { + log.debug("not observing activity, Messages is manually activated") + return + } - guard isValid else { - #if DEBUG - log.debug("not observing activity, controller is invalid") - #endif - return + if lastOpenedThreadID != threadID { + log.debug("activity: entered idle state or thread id changed, opening deep link") + try await withAutomation { + _ = try await self.openDeepLink(deepLink) + log.debug("activity: opened deep link, waiting for layout change") + try await self.waitForLayoutChange(timeout: 0.5) } + } - if lastThreadIDOpenedForObservation.read() != threadID { - log.debug("activity: entered idle state or thread id changed, opening deep link") - try prepareForAutomation() - defer { finishedAutomation() } + guard readActivity else { + #if DEBUG + log.debug("not observing activity, thread doesn't support activity observation") + #endif + return + } - try openDeepLink(url) - log.debug("activity: opened deep link, waiting for layout change") - lastThreadIDOpenedForObservation.withLock { $0 = threadID } - waitForLayoutChange(timeout: 0.5) - } + guard !Defaults.shouldCoordinateWindow else { + log.debug("not observing activity, Messages window is being coordinated") + return + } - guard activityLock.tryLock() else { return } - defer { activityLock.unlock() } + guard occlusionMonitor.visible else { + #if DEBUG + log.debug("not observing activity, window occluded") + #endif + return + } - let observationToSend = activityObservation() - guard lastSentActivityObservation != observationToSend || (observationToSend.activityType == .typing && lastSentActivityObservationTime.map { $0.timeIntervalSinceNow * -1 > 30 } == true) else { - #if DEBUG - log.debug("activity: same activity or too recent, skipping activity update") - #endif - return - } - defer { - lastSentActivityObservation = observationToSend - lastSentActivityObservationTime = Date() - } + let observationToSend = await activityObservation() + guard lastSentActivityObservation != observationToSend || (observationToSend.activityType == .typing && lastSentActivityObservationTime.map { $0.timeIntervalSinceNow * -1 > 30 } == true) else { #if DEBUG - log.debug("activity: sending: \(observationToSend)") + log.debug("activity: same activity or too recent, skipping activity update") #endif - statusSender(observationToSend) + return } + defer { + lastSentActivityObservation = observationToSend + lastSentActivityObservationTime = Date() + } + #if DEBUG + log.debug("activity: sending: \(observationToSend)") + #endif + statusSender(observationToSend) } private var isDisposed = false @@ -1590,6 +1621,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) guard !isDisposed else { return } NotificationCenter.default.removeObserver(self, name: .CNContactStoreDidChange, object: nil) isDisposed = true + cancelReplyTranscriptViewTask?.cancel() lifecycleConveyor?.cancel() lifecycleEventsTask?.cancel() diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesDeepLink.swift b/src/IMessage/Sources/IMessage/Messages/MessagesDeepLink.swift index 064632eb..2aab2583 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesDeepLink.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesDeepLink.swift @@ -1,30 +1,53 @@ import Foundation import IMessageCore -enum MessagesDeepLink { - case addresses([String], body: String?) - case group(chatID: String, body: String?) - case message(guid: String, partIndex: Int?, overlay: Bool?) +struct MessagesDeepLink { + private enum Destination { + case addresses([String], body: String?) + case group(chatID: String, body: String?) + case message(guid: String, partIndex: Int?, overlay: Bool?) + } + + private let destination: Destination + let targetThreadID: String? - static let compose: MessagesDeepLink = .addresses([], body: nil) + static let compose = MessagesDeepLink.addresses([], body: nil) init(threadID: String, body: String?) throws { let (_, type, id) = try splitThreadID(threadID).orThrow(ErrorMessage("invalid threadID: \(threadID)")) switch type { - case MessagesDeepLink.singleThreadType: - self = .addresses([String(id)], body: body) - case MessagesDeepLink.groupThreadType: - self = .group(chatID: String(id), body: body) + case Self.singleThreadType: + self.init(destination: .addresses([String(id)], body: body), targetThreadID: threadID) + case Self.groupThreadType: + self.init(destination: .group(chatID: String(id), body: body), targetThreadID: threadID) default: throw ErrorMessage("invalid threadID: \(threadID)") } } + static func addresses(_ addresses: [String], body: String?) -> Self { + Self(destination: .addresses(addresses, body: body), targetThreadID: nil) + } + + static func group(chatID: String, body: String?) -> Self { + Self(destination: .group(chatID: chatID, body: body), targetThreadID: nil) + } + + static func message(guid: String, partIndex: Int?, overlay: Bool?, threadID: String? = nil) -> Self { + Self(destination: .message(guid: guid, partIndex: partIndex, overlay: overlay), targetThreadID: threadID) + } + + private init(destination: Destination, targetThreadID: String?) { + self.destination = destination + self.targetThreadID = targetThreadID + } + func url() throws -> URL { var components = URLComponents() components.scheme = "imessage" components.path = "open" - switch self { + + switch destination { case let .addresses(addrs, body): components.queryItems = [ URLQueryItem( diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift index a5a1dc66..a5e9e571 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift @@ -38,7 +38,7 @@ enum MessagesInstanceTarget { activating: Bool = false, hiding: Bool = false, timeout: TimeInterval = 8 - ) throws -> NSRunningApplication { + ) async throws -> NSRunningApplication { guard let applicationURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: messagesBundleID) else { throw ErrorMessage("Could not find Messages.app via LaunchServices") } @@ -54,35 +54,20 @@ enum MessagesInstanceTarget { configuration.launchWithoutRestoringState = true configuration.waitForApplicationToCheckIn = true - let waiter = DispatchSemaphore(value: 0) - var result: Result? - - NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration) { app, error in - if let error { - result = .failure(error) - } else if let app { - result = .success(app) - } else { - result = .failure(ErrorMessage("LaunchServices completed without returning Messages.app")) - } - waiter.signal() - } - - guard waiter.wait(timeout: .now() + timeout) == .success else { - throw ErrorMessage("Timed out waiting for secondary Messages.app launch after \(timeout)s") + let application = try await Task.withTimeout(timeout) { + try await NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration) } - let app = try result.orThrow(ErrorMessage("Messages.app launch did not complete")).get() - try app.waitForLaunch(timeout: timeout) + try await application.waitForLaunch(timeout: timeout) if let initialDeepLink { - try sendDeepLink(initialDeepLink, to: app) + try sendDeepLink(initialDeepLink, to: application) } if hiding { - app.hide() + application.hide() } - return app + return application } } diff --git a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift new file mode 100644 index 00000000..8e1900a8 --- /dev/null +++ b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift @@ -0,0 +1,145 @@ +import Foundation + +// A single serial async "lane" for Messages.app automation, plus passive idle +// detection. All MessagesController automation funnels through one shared instance +// (see `PlatformAPI.runOnMessagesControllerLane`) so only one operation touches +// Messages.app at a time — this replaced the old DispatchQueue + UnfairLock model. +// +// `run` enqueues an action behind a tail of previously-submitted work; when the +// queue drains, an idle callback fires after `idleDelay` and keeps firing while the +// lane stays quiet (epoch-guarded so new active work cancels a stale idle cycle). +actor MessagesControllerAutomationLane { + typealias IdleCallback = @Sendable () async -> Void + + // True while an action runs on the lane. Re-entering the lane from within an + // action would deadlock (the nested task awaits a tail the current action is + // blocking); task-locals propagate across hops so the precondition in `run` + // catches it. + // + // WARNING: @TaskLocal values are also inherited by unstructured `Task {}` + // children spawned during a lane action. Such a child sees `isExecutingOnLane + // == true` even after the originating action returns, so this flag must NOT be + // used to gate an "inline fallback" — a leaked child re-entering the lane would + // run its action off-lane, concurrently, silently breaking serialization. The + // only safe response to re-entrancy is to fail (see `run`). + @TaskLocal private static var isExecutingOnLane = false + + private let idleDelay: TimeInterval + private var tail: Task? + private var pendingActiveWorkCount = 0 + private var idleEpoch: UInt = 0 + private var idleCallback: IdleCallback? + private var idleTask: Task? + + init(idleDelay: TimeInterval) { + self.idleDelay = idleDelay + } + + func run(_ action: @Sendable @escaping () async throws -> T) async throws -> T { + // `precondition`, not `assert`: re-entrancy must fail loudly in release too. + // The failure mode otherwise is a *silent* deadlock (the nested op queues + // behind the current action while the current action awaits it) — which is + // undiagnosable in the field. A crash with this message is strictly better. + // See the WARNING on `isExecutingOnLane`: an "inline fallback" is NOT a safe + // alternative because the task-local leaks into spawned `Task {}` children. + precondition( + !Self.isExecutingOnLane, + "re-entrant runOnMessagesControllerLane would deadlock the serial lane: " + + "a lane action must not call back into the lane." + ) + activeWorkSubmitted() + let task = enqueue(action) + defer { activeWorkCompleted() } + + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + func setIdleCallback(_ callback: IdleCallback?) { + idleCallback = callback + idleEpoch += 1 + idleTask?.cancel() + idleTask = nil + } + + private func enqueue(_ action: @Sendable @escaping () async throws -> T) -> Task { + let previous = tail + let task = Task { + await previous?.value + try Task.checkCancellation() + return try await Self.$isExecutingOnLane.withValue(true) { + try await action() + } + } + + tail = Task { + _ = try? await task.value + } + return task + } + + private func activeWorkSubmitted() { + idleEpoch += 1 + pendingActiveWorkCount += 1 + idleTask?.cancel() + idleTask = nil + } + + private func activeWorkCompleted() { + // Every activeWorkCompleted must pair with a prior activeWorkSubmitted. + // The assert surfaces an imbalance in debug; the max(0,…) keeps release safe. + assert(pendingActiveWorkCount > 0, "activeWorkCompleted without a matching activeWorkSubmitted") + pendingActiveWorkCount = max(0, pendingActiveWorkCount - 1) + guard pendingActiveWorkCount == 0 else { return } + scheduleIdleCallback() + } + + private func scheduleIdleCallback() { + guard idleCallback != nil else { return } + + let expectedEpoch = idleEpoch + let idleDelay = idleDelay + idleTask = Task { [weak self] in + do { + try await Task.sleep(forTimeInterval: idleDelay) + } catch { + return + } + + guard let self else { return } + // `await` the hop back onto the actor: with `[weak self]` the closure is no + // longer statically actor-isolated, so the actor-isolated enqueue must be awaited. + let task = await self.enqueuePassiveIdleCallback(expectedEpoch: expectedEpoch) + _ = try? await task.value + } + } + + private func enqueuePassiveIdleCallback(expectedEpoch: UInt) -> Task { + enqueue { + guard let callback = await self.idleCallbackIfStillCurrent(expectedEpoch: expectedEpoch) else { + return + } + + await callback() + + guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { + return + } + await self.scheduleIdleCallback() + } + } + + private func idleCallbackIfStillCurrent(expectedEpoch: UInt) -> IdleCallback? { + guard pendingActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { + return nil + } + return idleCallback + } + + private func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { + pendingActiveWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil + } +} diff --git a/src/IMessage/Sources/IMessage/OnboardingManager.swift b/src/IMessage/Sources/IMessage/OnboardingManager.swift index 2f449c89..a17eea18 100644 --- a/src/IMessage/Sources/IMessage/OnboardingManager.swift +++ b/src/IMessage/Sources/IMessage/OnboardingManager.swift @@ -6,6 +6,7 @@ import WindowControl private let log = Logger(imessageLabel: "onboarding-manager") +@MainActor final class OnboardingManager { private var onboardingWindow: NSWindow? private var pollingTimer: Timer? @@ -61,38 +62,38 @@ final class OnboardingManager { } func createWindow() { - DispatchQueue.main.async { - self.pollingTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in + // The timer is scheduled on (and fires on) the main run loop, so we're already + // main-actor-isolated — assume it rather than hopping through a fresh Task each tick. + pollingTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } guard let bounds = Self.getPrefsWindowBounds() else { self.onboardingWindow?.setIsVisible(false) return } self.createOrUpdateWindow(bounds) } - self.pollingTimer?.fire() } + pollingTimer?.fire() } func closeWindow() { log.info("OnboardingManager: closing window") - let close = { - self.onboardingWindow?.close() - self.onboardingWindow = nil - self.initialWidth = nil - self.pollingTimer?.invalidate() - self.pollingTimer = nil - } - - if Thread.isMainThread { - close() - } else { - DispatchQueue.main.sync(execute: close) - } + onboardingWindow?.close() + onboardingWindow = nil + initialWidth = nil + pollingTimer?.invalidate() + pollingTimer = nil } deinit { log.debug("OnboardingManager: deinit") - self.closeWindow() + let onboardingWindow = onboardingWindow + let pollingTimer = pollingTimer + Task { @MainActor in + onboardingWindow?.close() + pollingTimer?.invalidate() + } } } diff --git a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift index 260bc52f..e039dc9e 100644 --- a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift +++ b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift @@ -14,7 +14,7 @@ extension NSPasteboard { } } - func withRestoration(perform: () throws -> Void) rethrows { + func withRestoration(perform: () async throws -> Void) async rethrows { let backup = self.backup() defer { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) { @@ -23,6 +23,6 @@ extension NSPasteboard { } } self.prepareForNewContents(with: .currentHostOnly) // currentHostOnly disables universal clipboard - try perform() + try await perform() } } diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index ac4472d8..1998fb7d 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -1,3 +1,4 @@ +import Foundation import IMessageCore import Logging @@ -19,29 +20,43 @@ private actor MessagesControllerCoordinator { reportErrorMessage: PlatformAPI.ReportErrorMessage?, hasBeenDisposed: Protected, forceInvalidate: Bool = false, - _ action: @escaping @Sendable (MessagesController) throws -> T + _ action: @escaping @Sendable (MessagesController) async throws -> T ) async throws -> T { if forceInvalidate { try await disposeCachedController() } + // Bound the loop so a controller that keeps coming up invalid can't spin forever; + // each iteration either succeeds, throws, or retries after an invalidation. + let maxInvalidations = 30 + var invalidations = 0 while true { + try Task.checkCancellation() let entry = try await currentControllerEntry(reportErrorMessage: reportErrorMessage, hasBeenDisposed: hasBeenDisposed) do { - return try await PlatformAPI.onMessagesControllerQueue { + return try await PlatformAPI.runOnMessagesControllerLane { guard !hasBeenDisposed.read() else { throw ErrorMessage("PlatformAPI has been disposed") } guard entry.value.isValid else { throw MessagesControllerCoordinatorError.cachedControllerInvalid } - return try action(entry.value) + return try await action(entry.value) } } catch MessagesControllerCoordinatorError.cachedControllerInvalid { platformMessagesControllerLog.debug("disposing cached MessagesController because it became invalid") try await disposeIfCurrent(entry) + invalidations += 1 + guard invalidations < maxInvalidations else { + throw ErrorMessage("MessagesController repeatedly invalid") + } } catch MessagesControllerCoordinatorError.pendingControllerInvalidated { + try Task.checkCancellation() + invalidations += 1 + guard invalidations < maxInvalidations else { + throw ErrorMessage("MessagesController repeatedly invalid") + } continue } } @@ -53,23 +68,35 @@ private actor MessagesControllerCoordinator { current = nil self.pendingController = nil - var pendingError: Error? - if let pendingController { - do { - let created = try await pendingController.value - try await dispose(created) - } catch { - pendingError = error + // Run the await-construction-then-dispose inside an unstructured Task so it is + // immune to cancellation of *this* call. Unstructured tasks don't inherit the + // caller's cancellation, and `await disposal.value` completes regardless of it. + // Otherwise, if the caller is cancelled while we await `pendingController.value`, + // this method would throw, the detached construction Task would keep running, + // and the MessagesController it creates (having launched Messages.app) would + // never be disposed — a leak. We deliberately do NOT cancel `pendingController` + // itself: cancelling construction mid app-launch could leave a half-launched + // Messages.app. + let disposal = Task { + var pendingError: Error? + if let pendingController { + do { + let created = try await pendingController.value + try await self.dispose(created) + } catch { + pendingError = error + } } - } - if let entry { - try await dispose(entry) - } + if let entry { + try await self.dispose(entry) + } - if let pendingError { - throw pendingError + if let pendingError { + throw pendingError + } } + try await disposal.value } } @@ -92,7 +119,15 @@ private extension MessagesControllerCoordinator { private func startControllerCreation(reportErrorMessage: PlatformAPI.ReportErrorMessage?) -> Task { let task = Task { - try await Self.makeControllerEntry(reportErrorMessage: reportErrorMessage) + let controller = try await PlatformAPI.runOnMessagesControllerLane { + try await MessagesController(reportErrorMessage: { txt in + platformMessagesControllerLog.error(" report to sentry: \(txt)") + try? reportErrorMessage?(txt) + }) + } + let entry = MessagesControllerEntry(controller) + await PlatformAPI.installMessagesControllerIdleCallback(for: entry) + return entry } pendingController = task return task @@ -130,11 +165,6 @@ private extension MessagesControllerCoordinator { } } - static func makeControllerEntry(reportErrorMessage: PlatformAPI.ReportErrorMessage?) async throws -> MessagesControllerEntry { - let controller = try await PlatformAPI.makeMessagesController(reportErrorMessage: reportErrorMessage) - return MessagesControllerEntry(controller) - } - func disposeIfCurrent(_ entry: MessagesControllerEntry) async throws { guard current?.value === entry.value else { return @@ -145,8 +175,8 @@ private extension MessagesControllerCoordinator { func dispose(_ entry: MessagesControllerEntry) async throws { Log.default.notice("[PlatformAPI] disposing MessagesController") - try await PlatformAPI.onMessagesControllerQueue { - PlatformAPI.messagesControllerQueue.setIdleCallback(nil) + try await PlatformAPI.runOnMessagesControllerLane { + await PlatformAPI.clearMessagesControllerIdleCallback(ifOwnedBy: entry) entry.value.dispose() } } @@ -154,13 +184,14 @@ private extension MessagesControllerCoordinator { extension PlatformAPI { // IMessageHost is singleton-only within a process; PlatformAPI wrappers share - // one MessagesController and queue for Messages.app automation. - static let messagesControllerQueue = PassivelyAwareDispatchQueue(label: "messages-controller-platform-queue", idleDelay: 1) + // one MessagesController and one async lane for Messages.app automation. + private static let messagesControllerAutomationLane = MessagesControllerAutomationLane(idleDelay: 1) + private static let messagesControllerIdleCallbackOwner = Protected() fileprivate static let messagesControllerCoordinator = MessagesControllerCoordinator() func withMessagesController( forceInvalidate: Bool = false, - _ action: @escaping @Sendable (MessagesController) throws -> T + _ action: @escaping @Sendable (MessagesController) async throws -> T ) async throws -> T { try await Self.messagesControllerCoordinator.withController( reportErrorMessage: errorMessageReporter, @@ -174,22 +205,36 @@ extension PlatformAPI { try await Self.messagesControllerCoordinator.disposeCachedController() } - static func onMessagesControllerQueue( - _ action: @escaping @Sendable () throws -> T + static func runOnMessagesControllerLane( + _ action: @escaping @Sendable () async throws -> T ) async throws -> T { - try await withCheckedThrowingContinuation { continuation in - messagesControllerQueue.async { - continuation.resume(with: Result { try action() }) - } + try await messagesControllerAutomationLane.run(action) + } + + static func setMessagesControllerIdleCallback( + _ callback: (@Sendable () async -> Void)? + ) async { + await messagesControllerAutomationLane.setIdleCallback(callback) + } + + fileprivate static func installMessagesControllerIdleCallback(for entry: MessagesControllerEntry) async { + let owner = ObjectIdentifier(entry.value) + messagesControllerIdleCallbackOwner.withLock { $0 = owner } + await setMessagesControllerIdleCallback { [entry] in + guard messagesControllerIdleCallbackOwner.read() == owner else { return } + await PlatformAPI.observeSelectedThreadActivity(using: entry.value) } } - static func makeMessagesController(reportErrorMessage: ReportErrorMessage?) async throws -> MessagesController { - try await Self.onMessagesControllerQueue { - try MessagesController(reportErrorMessage: { txt in - platformMessagesControllerLog.error(" report to sentry: \(txt)") - try? reportErrorMessage?(txt) - }) + fileprivate static func clearMessagesControllerIdleCallback(ifOwnedBy entry: MessagesControllerEntry) async { + let owner = ObjectIdentifier(entry.value) + let shouldClear = messagesControllerIdleCallbackOwner.withLock { currentOwner in + guard currentOwner == owner else { return false } + currentOwner = nil + return true } + guard shouldClear else { return } + await setMessagesControllerIdleCallback(nil) } + } diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index 0d6f170b..1fd68f47 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -81,9 +81,15 @@ public final class PlatformAPI { private let currentUserCache = Protected() private let dndUserIDs = Protected(Set()) - private let threadObserveRequestToken = Protected() + private static let selectedThreadActivityState = Protected() let hasBeenDisposed = Protected(false) + private struct SelectedThreadActivityState: Sendable { + let owner: ObjectIdentifier + let threadID: String + let sendStatus: @Sendable (ThreadActivityObservation) async -> Void + } + public init(accountID: String, reportErrorMessage: ReportErrorMessage? = nil, enforceSingleton: Bool = true) throws { self.accountID = accountID self.errorMessageReporter = reportErrorMessage @@ -264,7 +270,7 @@ public final class PlatformAPI { if let existingThread { try await withMessagesController { controller in - try controller.sendMessage( + try await controller.sendMessage( threadID: existingThreadID, addresses: nil, text: messageText, @@ -277,7 +283,7 @@ public final class PlatformAPI { } try await withMessagesController { controller in - try controller.sendMessage( + try await controller.sendMessage( threadID: nil, addresses: userIDs, text: messageText, @@ -290,12 +296,12 @@ public final class PlatformAPI { public func updateThread(threadID publicThreadID: String, muted: Bool) async throws { let threadID = try originalThreadID(for: publicThreadID) - try await withMessagesController { try $0.muteThread(threadID: threadID, muted: muted) } + try await withMessagesController { try await $0.muteThread(threadID: threadID, muted: muted) } } public func deleteThread(threadID publicThreadID: String) async throws { let threadID = try originalThreadID(for: publicThreadID) - try await withMessagesController { try $0.deleteThread(threadID: threadID) } + try await withMessagesController { try await $0.deleteThread(threadID: threadID) } } public func sendMessage(threadID publicThreadID: String, text: String?, filePath: String?, quotedMessageID: String?) async throws -> PlatformSDK.MessageSendResult { @@ -310,7 +316,7 @@ public final class PlatformAPI { retries: quotedMessageID == nil ? 1 : 2, prepareAttempt: { try await self.lastMessageRowID() } ) { controller in - try controller.sendMessage( + try await controller.sendMessage( threadID: threadID, text: text, filePath: filePath, @@ -344,7 +350,7 @@ public final class PlatformAPI { } let threadID = try originalThreadID(for: publicThreadID) - try await withMessagesController { try $0.editMessage(threadID: threadID, messageID: messageID, newText: text) } + try await withMessagesController { try await $0.editMessage(threadID: threadID, messageID: messageID, newText: text) } } public func sendActivityIndicator(type: String, threadID publicThreadID: String?) async throws { @@ -366,16 +372,16 @@ public final class PlatformAPI { try await withMessagesController { controller in if type == "typing" { - try controller.sendTypingStatus(threadID: threadID) + try await controller.sendTypingStatus(threadID: threadID) } else { - try controller.clearTypingStatus() + try await controller.clearTypingStatus() } } } public func deleteMessage(threadID publicThreadID: String, messageID: String) async throws { let threadID = try originalThreadID(for: publicThreadID) - try await withMessagesController { try $0.undoSend(threadID: threadID, messageID: messageID) } + try await withMessagesController { try await $0.undoSend(threadID: threadID, messageID: messageID) } } public func sendReadReceipt(threadID publicThreadID: String) async throws { @@ -392,7 +398,7 @@ public final class PlatformAPI { } try await withMessagesController(forceInvalidate: attempt > 0) { - try $0.toggleThreadRead(threadID: threadID, read: true) + try await $0.toggleThreadRead(threadID: threadID, read: true) } } onError: { _, retriesLeft, error in platformLog.error("sendReadReceipt failed, retries left: \(retriesLeft): \(error)") @@ -421,7 +427,7 @@ public final class PlatformAPI { } let threadID = try originalThreadID(for: reference.threadID) - try await withMessagesController { try $0.loadAttachment(threadID: threadID, messageID: reference.messageID) } + try await withMessagesController { try await $0.loadAttachment(threadID: threadID, messageID: reference.messageID) } let loadedMessage = try await waitForLoadedAttachment( threadID: reference.threadID, @@ -477,38 +483,42 @@ public final class PlatformAPI { public func markAsUnread(threadID publicThreadID: String) async throws { let threadID = try originalThreadID(for: publicThreadID) - try await withMessagesController { try $0.toggleThreadRead(threadID: threadID, read: false) } + try await withMessagesController { try await $0.toggleThreadRead(threadID: threadID, read: false) } } public func notifyAnyway(threadID publicThreadID: String) async throws { let threadID = try originalThreadID(for: publicThreadID) - try await withMessagesController { try $0.notifyAnyway(threadID: threadID) } + try await withMessagesController { try await $0.notifyAnyway(threadID: threadID) } } public func getThreadActivityStatus(threadID publicThreadID: String) async throws -> ThreadActivityObservation { let threadID = try originalThreadID(for: publicThreadID) - return try await withMessagesController { try $0.activityStatus(threadID: threadID) } + return try await withMessagesController { try await $0.activityStatus(threadID: threadID) } } public func onThreadSelected( - threadID publicThreadID: String, + threadID publicThreadID: String?, sendEvents: @escaping EventCallback ) async throws { - guard !publicThreadID.isEmpty else { + guard let publicThreadID, !publicThreadID.isEmpty else { + clearSelectedThreadActivity() return } - let threadID = try originalThreadID(for: publicThreadID) - - guard !Preferences.enabledExperiments.contains("no_watch_thread") else { - return + let threadID: String + do { + threadID = try originalThreadID(for: publicThreadID) + } catch { + clearSelectedThreadActivity() + throw error } let singleParticipantID = singleParticipantAddress(threadID) let hashedThreadID = Hasher.thread.tokenizeRemembering(pii: threadID) - platformLog.debug("activity/\(publicThreadID): watching") - - try await watchThreadActivity(threadID: threadID) { [dndUserIDs] status in + let selectedThread = SelectedThreadActivityState( + owner: ObjectIdentifier(self), + threadID: threadID + ) { [dndUserIDs] status in platformLog.debug("activity/\(publicThreadID): received \(status)") guard let singleParticipantID else { @@ -558,7 +568,25 @@ public final class PlatformAPI { } } - try await sendEvents(events) + do { + try await sendEvents(events) + } catch { + platformLog.error("failed to send activity status: \(String(reflecting: error))") + } + } + Self.selectedThreadActivityState.withLock { $0 = selectedThread } + platformLog.debug("activity/\(publicThreadID): watching") + + try await withMessagesController { controller in + let readActivity = if Defaults.watchThreadActivity { + try Self.threadSupportsActivityObservation(threadID: threadID, controller: controller) + } else { + false + } + await Self.observeSelectedThreadActivity(using: controller, readActivity: readActivity) + if !readActivity { + self.clearSelectedThreadActivity() + } } } @@ -590,10 +618,11 @@ public final class PlatformAPI { } hasBeenDisposed.withLock { $0 = true } + clearSelectedThreadActivityIfOwned() // Clear cached state so logout/relogin in Messages.app while Beeper // restarts the account doesn't reuse stale state. currentUserCache.withLock { $0 = nil } - SystemSettingsOnboarding.stop() + await SystemSettingsOnboarding.stop() await EventWatcherLifecycle.shared.cancelWatchingIfNecessary(clearEventCallback: true) database.stopListeningAndReset() try await disposeCachedMessagesController() @@ -605,77 +634,74 @@ public final class PlatformAPI { } } - private func watchThreadActivity( - threadID: String, - statusSender: @escaping @Sendable (ThreadActivityObservation) async throws -> Void - ) async throws { - guard Defaults.watchThreadActivity else { - return - } + private func clearSelectedThreadActivity() { + Self.selectedThreadActivityState.withLock { $0 = nil } + } - // reset the idle callback in case we fail and bail out - Self.messagesControllerQueue.setIdleCallback(nil) + private func clearSelectedThreadActivityIfOwned() { + let owner = ObjectIdentifier(self) + Self.selectedThreadActivityState.withLock { state in + guard state?.owner == owner else { return } + state = nil + } + } - let requestID = UUID() - let threadObserveRequestToken = threadObserveRequestToken - threadObserveRequestToken.withLock { $0 = requestID } + private static func threadSupportsActivityObservation(threadID: String, controller: MessagesController) throws -> Bool { + // only watch thread activity for iMessage/RCS chats + // TODO: implement this for groups + if threadID.hasPrefix("iMessage;-;") || threadID.hasPrefix("RCS;-;") { + return true + } - @Sendable func sendStatus(_ status: ThreadActivityObservation) { - Task { - do { - try await statusSender(status) - } catch { - platformLog.error("failed to send activity status: \(String(reflecting: error))") - } - } + guard threadID.hasPrefix("any;-;") else { + // only bother checking the database if the GUID can't tell us what service the chat is for + // (can happen seemingly since macOS 26, which can use "any" as a universal GUID prefix) + #if DEBUG + platformLog.debug("chat isn't an iMessage 1:1 DM, not watching for activity") + #endif + return false } - try await withMessagesController { controller in - // only watch thread activity for iMessage chats - // TODO: implement this for groups - if !threadID.hasPrefix("iMessage;-;") { - guard threadID.hasPrefix("any;-;") else { - // only bother checking the database if the GUID can't tell us what service the chat is for - // (can happen seemingly since macOS 26, which can use "any" as a universal GUID prefix) - #if DEBUG - platformLog.debug("chat isn't an iMessage 1:1 DM, not watching for activity") - #endif - return - } + let chat = try controller.db.chat(withGUID: threadID) + guard let chat else { + platformLog.error("onThreadSelected: couldn't locate the chat to watch in the database") + return false + } - let chat = try controller.db.chat(withGUID: threadID) - guard let chat else { - platformLog.error("watchThreadActivity: couldn't locate the chat to watch in the database") - return - } + guard chat.serviceName == .imessage || chat.serviceName == .rcs else { + #if DEBUG + platformLog.debug("chat definitely isn't an iMessage/RCS 1:1 DM, not watching for activity") + #endif + return false + } - guard chat.serviceName == .imessage else { - #if DEBUG - platformLog.debug("chat definitely isn't an iMessage 1:1 DM, not watching for activity") - #endif - return - } - } + return true + } - guard threadObserveRequestToken.read() == requestID else { return } + static func observeSelectedThreadActivity(using controller: MessagesController, readActivity: Bool = true) async { + guard let selectedThread = selectedThreadActivityState.read() else { + return + } - let observe = try controller.idleCallback(observingThreadID: threadID, statusSender: sendStatus) - Self.messagesControllerQueue.setIdleCallback { quiescence in - guard threadObserveRequestToken.read() == requestID else { return } - do { - try observe(quiescence) - } catch { - platformLog.error("failed to observe activity: \(error)") + @Sendable func sendStatus(_ status: ThreadActivityObservation) { + Task { + guard let currentThread = selectedThreadActivityState.read(), + currentThread.threadID == selectedThread.threadID + else { + return } + await currentThread.sendStatus(status) } + } - // if another watchThreadActivity request has been enqueued - // after our current one (but before this block began executing), - // then this check will fail and prevent the current block from - // unnecessarily running - guard threadObserveRequestToken.read() == requestID else { return } - - try observe(.began) + do { + try await controller.observeIdleActivity( + threadID: selectedThread.threadID, + readActivity: readActivity, + statusSender: sendStatus + ) + } catch { + platformLog.error("failed to observe activity: \(error)") } } @@ -684,13 +710,13 @@ public final class PlatformAPI { name: String, retries: Int, prepareAttempt: @escaping @Sendable () async throws -> AttemptContext, - _ action: @escaping @Sendable (MessagesController) throws -> Void, + _ action: @escaping @Sendable (MessagesController) async throws -> Void, afterAttempt: (@Sendable (AttemptContext) async throws -> Void)? = nil ) async throws -> AttemptContext { try await retry(retries: retries) { attempt in let context = try await prepareAttempt() try await withMessagesController(forceInvalidate: attempt > 0) { controller in - try action(controller) + try await action(controller) } try await afterAttempt?(context) return context @@ -706,7 +732,7 @@ public final class PlatformAPI { retries: 2, prepareAttempt: { try await self.lastMessageRowID() } ) { controller in - try controller.setReaction(threadID: threadID, messageID: messageID, reactionName: reaction, on: on) + try await controller.setReaction(threadID: threadID, messageID: messageID, reactionName: reaction, on: on) } afterAttempt: { lastRowID in _ = try await self.waitForMessageSend( threadID: threadID, diff --git a/src/IMessage/Sources/IMessage/PromptAutomation.swift b/src/IMessage/Sources/IMessage/PromptAutomation.swift index c477fcff..4517a275 100644 --- a/src/IMessage/Sources/IMessage/PromptAutomation.swift +++ b/src/IMessage/Sources/IMessage/PromptAutomation.swift @@ -65,14 +65,22 @@ enum PromptAutomation { } } - static func disableNotificationsForApp(named appName: String) throws -> Bool { - let app = try NSWorkspace.shared.open( + static func disableNotificationsForApp(named appName: String) async throws -> Bool { + let configuration = NSWorkspace.OpenConfiguration() + + // hides shows a gray background and doesn't render the UI + // configuration.hides = false + + configuration.activates = false + + let app = try await NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!, - options: [.withoutActivation], // .andHide shows a gray background and doesn't render the UI - configuration: [:] + configuration: configuration ) - try app.waitForLaunch() - return try retry(withTimeout: 3, interval: 0.1) { + + try await app.waitForLaunch() + + return try await retry(withTimeout: 3, interval: 0.1) { let appElement = Accessibility.Element(pid: app.processIdentifier) let windows = try appElement.appWindows() let window = try windows.first.orThrow(ErrorMessage("window not found")) @@ -112,7 +120,7 @@ enum PromptAutomation { log.debug("notifications are enabled, disabling") try notificationsSwitch.press() // Closing too soon causes the value to not change - sleep(1) + try await Task.sleep(forTimeInterval: 1) } } else { let tabView = try window.children().first(where: { (try? $0.role()) == Accessibility.Role.tabGroup }).orThrow(ErrorMessage("tabView not found")) diff --git a/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift b/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift index dc65ce91..b5943293 100644 --- a/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift +++ b/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift @@ -1,3 +1,4 @@ +@MainActor public enum SystemSettingsOnboarding { static var onboardingManager: OnboardingManager? diff --git a/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift b/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift new file mode 100644 index 00000000..f6be5193 --- /dev/null +++ b/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift @@ -0,0 +1,123 @@ +import Foundation + +public extension AsyncSequence { + func first( + until deadline: Date, + where predicate: @escaping @Sendable (Element) async throws -> Bool + ) async throws -> Element? { + guard Date() < deadline else { + return nil + } + + let timeoutTask: @Sendable () async throws -> Element? = { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + return nil + } + + do { + if #available(macOS 13.0, *) { + let clock = ContinuousClock() + let clockDeadline = clock.now.advanced(by: .seconds(remaining)) + try await Task.sleep(until: clockDeadline, tolerance: nil, clock: clock) + } else { + try await Task.sleep(forTimeInterval: remaining) + } + } catch is CancellationError { + return nil + } + + return nil + } + + return try await withThrowingTaskGroup(of: Element?.self) { group in + if #available(macOS 26.0, *) { + group.addImmediateTask(operation: timeoutTask) + } else { + group.addTask(operation: timeoutTask) + } + + group.addTask { + for try await value in self { + try Task.checkCancellation() + if try await predicate(value) { + return value + } + } + + return nil + } + + defer { + group.cancelAll() + } + + return try await group.next() ?? nil + } + } +} + +public extension NSObjectProtocol where Self: NSObject { + func asyncValues( + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [.initial, .new], + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .bufferingNewest(1) + ) -> AsyncStream { + AsyncStream(Value.self, bufferingPolicy: bufferingPolicy) { continuation in + let observation = self.observe(keyPath, options: options) { _, change in + guard let value = change.newValue else { + return + } + + continuation.yield(value) + } + + continuation.onTermination = { _ in + observation.invalidate() + } + } + } + + @discardableResult + func waitForValue( + _ keyPath: KeyPath, + options: NSKeyValueObservingOptions = [.initial, .new], + where predicate: @escaping @Sendable (Value) async throws -> Bool + ) async throws -> Value? { + for await value in asyncValues(for: keyPath, options: options) { + try Task.checkCancellation() + if try await predicate(value) { + return value + } + } + + return nil + } + + @discardableResult + func waitForValue( + _ keyPath: KeyPath, + _ expectedValue: Value, + options: NSKeyValueObservingOptions = [.initial, .new] + ) async throws -> Value? where Value: Equatable { + try await self.waitForValue(keyPath) { value in + return value == expectedValue + } + } + + @discardableResult + func waitForValue( + _ keyPath: KeyPath, + timeout: TimeInterval, + options: NSKeyValueObservingOptions = [.initial, .new], + where predicate: @escaping @Sendable (Value) async throws -> Bool + ) async throws -> Value { + try await Task.withTimeout(timeout) { [self] in + guard let value = try await self.waitForValue(keyPath, options: options, where: predicate) else { + throw Swift.CancellationError() + } + + return value + } + } +} diff --git a/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift b/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift new file mode 100644 index 00000000..8cb3a91b --- /dev/null +++ b/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift @@ -0,0 +1,186 @@ +import Foundation +import IMessageCore + +extension Task where Success == Never, Failure == Never { + struct TimeoutError: Error, Equatable, Sendable {} +} + +extension Task where Failure == any Error { + static func withTimeout( + _ timeout: TimeInterval, + operation: sending @escaping @isolated(any) () async throws -> Success + ) async throws -> Success { + try await Task._withTimeout(timeout, operation: operation) + } + + public init( + name: String? = nil, + timeout: TimeInterval, + priority: TaskPriority? = nil, + operation: sending @escaping @isolated(any) () async throws -> Success + ) { + self.init(name: name, priority: priority) { + try await Self.withTimeout(timeout, operation: operation) + } + } +} + +private extension Task where Success == Never, Failure == Never { + struct TimeoutState { + var continuation: CheckedContinuation? + var operationTask: Task? + var timeoutTask: Task? + var result: Result? + } + + static func _withTimeout( + _ timeout: TimeInterval, + operation: sending @escaping @isolated(any) () async throws -> Output + ) async throws -> Output { + let deadline: Date = Date().addingTimeInterval(timeout) + + guard timeout > 0 else { + throw Task.TimeoutError() + } + + let state = Protected(TimeoutState()) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let operationTask = Task { + do { + finishTimeout( + .success(try await operation()), + state: state + ) + } catch { + finishTimeout(.failure(error), state: state) + } + } + + let timeoutTask = Task { + do { + try await sleepUntilTimeoutDeadline(deadline) + try Task.checkCancellation() + finishTimeout(.failure(Task.TimeoutError()), state: state) + } catch { + finishTimeout(.failure(error), state: state) + } + } + + let completion = state.withLock { state -> ( + continuation: CheckedContinuation?, + result: Result, + operationTask: Task?, + timeoutTask: Task? + )? in + if let result = state.result { + return ( + continuation: continuation, + result: result, + operationTask: operationTask, + timeoutTask: timeoutTask + ) + } + + state.continuation = continuation + state.operationTask = operationTask + state.timeoutTask = timeoutTask + + return nil + } + + if let completion { + completeTimeout( + continuation: completion.continuation, + result: completion.result, + operationTask: completion.operationTask, + timeoutTask: completion.timeoutTask + ) + } + + if Task.isCancelled { + finishTimeout(.failure(Swift.CancellationError()), state: state) + } + } + } onCancel: { + finishTimeout(.failure(Swift.CancellationError()), state: state) + } + } + + static func finishTimeout( + _ result: Result, + state: Protected> + ) { + let completion = state.withLock { state -> ( + continuation: CheckedContinuation?, + result: Result, + operationTask: Task?, + timeoutTask: Task? + )? in + guard state.result == nil else { + return nil + } + + state.result = result + + let completion = ( + continuation: state.continuation, + result: result, + operationTask: state.operationTask, + timeoutTask: state.timeoutTask + ) + + state.continuation = nil + state.operationTask = nil + state.timeoutTask = nil + + return completion + } + + if let completion { + completeTimeout( + continuation: completion.continuation, + result: completion.result, + operationTask: completion.operationTask, + timeoutTask: completion.timeoutTask + ) + } + } + + static func completeTimeout( + continuation: CheckedContinuation?, + result: Result, + operationTask: Task?, + timeoutTask: Task? + ) { + operationTask?.cancel() + timeoutTask?.cancel() + + switch (continuation, result) { + case let (.some(continuation), .success(value)): + continuation.resume(returning: value) + case let (.some(continuation), .failure(error)): + continuation.resume(throwing: error) + case (.none, _): + break + } + } + + static func sleepUntilTimeoutDeadline(_ deadline: Date) async throws { + let remainingTime = deadline.timeIntervalSinceNow + + guard remainingTime > 0 else { + return + } + + if #available(macOS 13.0, *) { + let clock = ContinuousClock() + let deadlineInstant = clock.now.advanced(by: .seconds(remainingTime)) + + try await Task.sleep(until: deadlineInstant, tolerance: nil, clock: clock) + } else { + try await Task.sleep(forTimeInterval: remainingTime) + } + } +} diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebugger.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebugger.swift index 05cf5d5b..987dbfcc 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebugger.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebugger.swift @@ -98,6 +98,7 @@ public extension EclipsingDebugger { } private extension NSScreen { + @MainActor static var suitableForDebugger: NSScreen { if let screen = NSApp.largestElectronWindow?.screen { return screen diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift index 382ef97d..d8ca216d 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift @@ -15,6 +15,7 @@ private let log = Logger(imessageLabel: "eclipsing-window-coordinator") * even if the user briefly takes manual control of Messages. */ final class EclipsingWindowCoordinator: WindowCoordinator { + @MainActor var app: NSRunningApplication? { didSet { if let app { @@ -37,13 +38,18 @@ final class EclipsingWindowCoordinator: WindowCoordinator { hideDebouncer = HideDebouncer(debouncingFor: Self.debouncingPeriod) } - func makeAutomatable(_ messagesWindow: Accessibility.Element) throws { - // Required so we can exclude the Messages window itself from external anchor candidates; - // without it the eclipse would no-op (positioning Messages on top of itself). - guard let messagesPID = app?.processIdentifier else { + func makeAutomatable(_ messagesWindow: Accessibility.Element) async throws { + guard let messagesPID = await app?.processIdentifier else { throw WindowCoordinatorError.generic(message: "no app to coordinate") } - let anchorWindow = try Self.eclipsingAnchorWindow(messagesPID: messagesPID) + // AppKit-affined reads (NSApp/NSScreen) happen on the main actor; the blocking + // Accessibility IPC below runs off the main thread so a slow Messages.app can't + // freeze the UI. + let (anchorWindow, mainScreenDebug) = try await MainActor.run { () -> (AnchorWindow, String?) in + let anchor = try Self.eclipsingAnchorWindow(messagesPID: messagesPID) + let mainScreen = NSScreen.main.map { "\($0.frame.formatted) [visible: \($0.visibleFrame.formatted)]" } + return (anchor, mainScreen) + } let originalMessagesFrame = try messagesWindow.frame() if windowFramePreEclipse == nil { @@ -75,8 +81,8 @@ final class EclipsingWindowCoordinator: WindowCoordinator { let visibleFrame = anchorWindow.containingScreenVisibleFrame { log.debug("screen with anchor frame: \(screenFrame.formatted) [visible: \(visibleFrame.formatted)]") } - if let main = NSScreen.main { - log.debug("main screen: \(main.frame.formatted) [visible: \(main.visibleFrame.formatted)]") + if let mainScreenDebug { + log.debug("main screen: \(mainScreenDebug)") } guard anchorWindow.screenFrame.size.encompasses(targetSize) || !Self.shouldOnlyEclipseIfEncompasses else { log.warning("the eclipsing anchor's frame \(anchorWindow.screenFrame.formatted) isn't big enough to encompass the target size \(targetSize), _not_ eclipsing!") @@ -105,30 +111,31 @@ final class EclipsingWindowCoordinator: WindowCoordinator { let targetRect = NSRect(origin: targetOrigin, size: targetSize) log.notice("eclipsing (\(originalMessagesFrame.formatted) -> \(targetRect.formatted))") - hideDebouncer.immediatelyUnhide() + await MainActor.run { hideDebouncer.immediatelyUnhide() } try messagesWindow.size(assign: targetSize) try messagesWindow.position(assign: targetOrigin) if #available(macOS 14, *), Defaults.imessage.bool(forKey: DefaultsKeys.eclipsingDebug) { - Task { @MainActor in + // read the frame off-main, then hand only plain values to the main-actor debugger + let finalFrame = try? messagesWindow.frame() + await MainActor.run { let debugger = EclipsingDebugger.shared debugger.note(EclipsingRect(at: originalMessagesFrame, label: "Original", color: NSColor.systemRed.cgColor)) debugger.note(EclipsingRect(at: anchorWindow.screenFrame, label: anchorWindow.debugLabel, color: NSColor.systemGray.cgColor)) debugger.note(EclipsingRect(at: targetRect, label: "Target", color: NSColor.systemGreen.cgColor)) - // i think this is up-to-date by now? might need to wait for a next - // runloop turn? - guard let frame = try? messagesWindow.frame() else { return } - EclipsingDebugger.shared.note(EclipsingRect(at: frame, label: "Final", color: NSColor.systemBlue.cgColor)) + if let finalFrame { + EclipsingDebugger.shared.note(EclipsingRect(at: finalFrame, label: "Final", color: NSColor.systemBlue.cgColor)) + } } } } - func automationDidComplete(_: Accessibility.Element) throws { + func automationDidComplete() throws { hideDebouncer.requestHide() } - func reset(_ window: Accessibility.Element) throws { - hideDebouncer.immediatelyUnhide() + func reset(_ window: Accessibility.Element) async throws { + await MainActor.run { hideDebouncer.immediatelyUnhide() } guard let originalFrame = windowFramePreEclipse else { log.warning("no last known frame, not setting a frame back") @@ -155,46 +162,20 @@ final class EclipsingWindowCoordinator: WindowCoordinator { private extension EclipsingWindowCoordinator { /// A fully-resolved eclipse anchor. All AppKit-derived geometry is captured - /// eagerly at construction (on the main thread, see `eclipsingAnchorWindow`), - /// so consumers on the background automation queue only read plain values. - struct AnchorWindow { + /// eagerly at construction (on the main actor, see `eclipsingAnchorWindow`), + /// so consumers only read plain values. `Sendable` so it can cross back out + /// of `makeAutomatable`'s `MainActor.run`. + struct AnchorWindow: Sendable { /// Frame in screen/AX space (origin at the top-left of the primary display). let screenFrame: NSRect /// The original Cocoa frame; only the Electron window has one. let originalFrame: NSRect? - /// Diagnostics only. Captured eagerly on the main thread so consumers - /// don't have to touch `NSScreen` from the automation queue. + /// Diagnostics only. Captured eagerly on the main actor so consumers + /// don't have to touch `NSScreen`. let containingScreenFrame: NSRect? let containingScreenVisibleFrame: NSRect? let debugLabel: String let description: String - - static func electron(_ window: NSWindow) -> AnchorWindow { - let frame = EclipsingWindowCoordinator.screenFrame(for: window) - let screen = EclipsingWindowCoordinator.screen(containing: frame) - return AnchorWindow( - screenFrame: frame, - originalFrame: window.frame, - containingScreenFrame: screen?.frame, - containingScreenVisibleFrame: screen?.visibleFrame, - debugLabel: "Electron", - description: "Electron window" - ) - } - - static func external(_ description: Window.Description) -> AnchorWindow { - let frame = NSRectFromCGRect(description.bounds) // CGWindow bounds are already in screen/AX space - let name = description.ownerName ?? "unknown" - let screen = EclipsingWindowCoordinator.screen(containing: frame) - return AnchorWindow( - screenFrame: frame, - originalFrame: nil, - containingScreenFrame: screen?.frame, - containingScreenVisibleFrame: screen?.visibleFrame, - debugLabel: name, - description: "\(name) window (pid \(description.owner))" - ) - } } private static var debouncingPeriod: RunLoop.SchedulerTimeType.Stride { .init(Defaults.imessage.double(forKey: DefaultsKeys.hidingCoordinatorDebounce)) } @@ -212,32 +193,52 @@ private extension EclipsingWindowCoordinator { // Accurate as of macOS 15.3.2. static let messagesAppMinimumSize = NSSize(width: 660.0, height: 320.0) +} - static func eclipsingAnchorWindow(messagesPID: pid_t) throws -> AnchorWindow { - // These reads touch main-thread-affined AppKit state (NSApp.windows, - // NSWorkspace, NSScreen), but makeAutomatable runs on a background queue. - try onMain { - if let window = NSApplication.shared.largestElectronWindow { - return AnchorWindow.electron(window) - } +@MainActor +private extension EclipsingWindowCoordinator.AnchorWindow { + static func electron(_ window: NSWindow) -> EclipsingWindowCoordinator.AnchorWindow { + let frame = EclipsingWindowCoordinator.screenFrame(for: window) + let screen = EclipsingWindowCoordinator.screen(containing: frame) + return EclipsingWindowCoordinator.AnchorWindow( + screenFrame: frame, + originalFrame: window.frame, + containingScreenFrame: screen?.frame, + containingScreenVisibleFrame: screen?.visibleFrame, + debugLabel: "Electron", + description: "Electron window" + ) + } - if let description = externalEclipsingAnchorWindow(messagesPID: messagesPID) { - let anchor = AnchorWindow.external(description) - log.notice("falling back to external frontmost window for eclipsing: \(anchor.description)") - return anchor - } + static func external(_ description: Window.Description) -> EclipsingWindowCoordinator.AnchorWindow { + let frame = NSRectFromCGRect(description.bounds) // CGWindow bounds are already in screen/AX space + let name = description.ownerName ?? "unknown" + let screen = EclipsingWindowCoordinator.screen(containing: frame) + return EclipsingWindowCoordinator.AnchorWindow( + screenFrame: frame, + originalFrame: nil, + containingScreenFrame: screen?.frame, + containingScreenVisibleFrame: screen?.visibleFrame, + debugLabel: name, + description: "\(name) window (pid \(description.owner))" + ) + } +} - throw WindowCoordinatorError.generic(message: "Couldn't find an eclipsing anchor window") +@MainActor +private extension EclipsingWindowCoordinator { + static func eclipsingAnchorWindow(messagesPID: pid_t) throws -> AnchorWindow { + if let window = NSApplication.shared.largestElectronWindow { + return AnchorWindow.electron(window) } - } - /// Runs `work` on the main thread synchronously, without deadlocking if the - /// caller is already on it. - private static func onMain(_ work: () throws -> T) rethrows -> T { - if Thread.isMainThread { - return try work() + if let description = externalEclipsingAnchorWindow(messagesPID: messagesPID) { + let anchor = AnchorWindow.external(description) + log.notice("falling back to external frontmost window for eclipsing: \(anchor.description)") + return anchor } - return try DispatchQueue.main.sync(execute: work) + + throw WindowCoordinatorError.generic(message: "Couldn't find an eclipsing anchor window") } static func screenFrame(for window: NSWindow) -> NSRect { @@ -311,6 +312,7 @@ private extension NSSize { } extension NSApplication { + @MainActor var largestElectronWindow: NSWindow? { let prefix = Defaults.imessage.string(forKey: DefaultsKeys.eclipsingWindowClassNamePrefix) ?? "Electron" // XXX: It's likely possible for this read to race with Electron's main thread, or whatever actually owns the window. diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift index f21f3297..441e370e 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift @@ -6,6 +6,7 @@ import Logging private let log = Logger(imessageLabel: "spaces-window-coordinator") final class SpacesWindowCoordinator { + @MainActor var app: NSRunningApplication? private var lastKnownWindow: Accessibility.Element? @@ -64,13 +65,13 @@ final class SpacesWindowCoordinator { extension SpacesWindowCoordinator: WindowCoordinator { var canReuseExtantInstance: Bool { true } - func makeAutomatable(_ window: Accessibility.Element) throws { - guard app?.isActive == false else { return } + func makeAutomatable(_ window: Accessibility.Element) async throws { + guard await app?.isActive == false else { return } lastKnownWindow = window try moveLastKnownWindowToHiddenSpace() } - func reset(_ window: Accessibility.Element) throws { + func reset(_ window: Accessibility.Element) async throws { guard let currentSpace = try? lastKnownDisplayWindowWasOn?.currentSpace(), lastKnownWindow != nil else { log.debug("can't reset, the last known window or current space was missing") return @@ -79,7 +80,7 @@ extension SpacesWindowCoordinator: WindowCoordinator { try (window.window()).moveToSpace(currentSpace) } - func automationDidComplete(_: Accessibility.Element) throws { + func automationDidComplete() throws { // after automating, keep the window on the hidden space } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift index 87dc4aa5..c76ed800 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift @@ -4,8 +4,9 @@ import AccessibilityControl /** * An abstraction over a way to make the Messages app automatable for short periods of time. */ -protocol WindowCoordinator { +protocol WindowCoordinator: AnyObject { /** The application to coordinate. */ + @MainActor var app: NSRunningApplication? { get set } /** Specifies whether the coordinator is okay with reusing an instance of Messages that was already open. */ @@ -15,32 +16,50 @@ protocol WindowCoordinator { * Manipulates the Messages window in such a way that it becomes controllable via Accessibility APIs. * * This is called right before the app needs to be automated. + * + * Intentionally NOT `@MainActor`: implementations perform blocking Accessibility/ + * window-server IPC that must stay off the main thread (an unresponsive Messages.app + * would otherwise freeze the UI). Implementations hop to `@MainActor` only for the + * AppKit reads they actually need. */ - func makeAutomatable(_ window: Accessibility.Element) throws + func makeAutomatable(_ window: Accessibility.Element) async throws - /** Signals to the coordinator that automation has completed; if desired, it may now e.g. hide the window. */ - func automationDidComplete(_ window: Accessibility.Element) throws + /** + * Signals to the coordinator that automation has completed; if desired, it may now e.g. hide the window. + * + * Takes no window argument: implementations don't need one, and passing the + * non-`Sendable` `Accessibility.Element` across the actor boundary into this + * `@MainActor` method would be a concurrency violation. + */ + @MainActor + func automationDidComplete() throws /** * Reverts the manipulations performed in `makeAutomatable`. * * For example, this is called when the user manually activates the app. Coordination should quiesce until the user * resigns manual control. + * + * Not `@MainActor`, for the same reason as `makeAutomatable`. */ - func reset(_ window: Accessibility.Element) throws + func reset(_ window: Accessibility.Element) async throws /** Called when the user manually activates the app. `reset` is also called in this case. */ + @MainActor func userManuallyActivated(_ app: NSRunningApplication) throws /** Called when the user finishes manual control over the app. */ + @MainActor func userManuallyDeactivated(_ app: NSRunningApplication) throws } extension WindowCoordinator { + @MainActor func userManuallyActivated(_: NSRunningApplication) throws { // make this method optional } + @MainActor func userManuallyDeactivated(_: NSRunningApplication) throws { // make this method optional } diff --git a/src/IMessage/Sources/IMessageCLI/IMessageCLI.swift b/src/IMessage/Sources/IMessageCLI/IMessageCLI.swift index 7956e393..ab5a0a3c 100644 --- a/src/IMessage/Sources/IMessageCLI/IMessageCLI.swift +++ b/src/IMessage/Sources/IMessageCLI/IMessageCLI.swift @@ -114,6 +114,7 @@ private enum AuthorizationRequirement: String { } } + @MainActor func request() async throws { switch self { case .accessibility: diff --git a/src/IMessage/Sources/IMessageCore/PassivelyAwareDispatchQueue.swift b/src/IMessage/Sources/IMessageCore/PassivelyAwareDispatchQueue.swift deleted file mode 100644 index 05e56a97..00000000 --- a/src/IMessage/Sources/IMessageCore/PassivelyAwareDispatchQueue.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Dispatch -import Foundation -import Logging - -private let log = Logger(imessageLabel: "idle-aware-queue") - -public enum Quiescence { - /// Passive work was scheduled due to a lull in active work. - case began - /// No active work has been scheduled since the idle callback was last scheduled. - case continuing -} - -public final class PassivelyAwareDispatchQueue { - public typealias PassiveCallback = @Sendable (Quiescence) -> Void - - public let queue: DispatchQueue - - private var activityState = Protected(ActivityState()) - private var uponIdle = Protected() - public private(set) var idleDelay: TimeInterval - - public init(label: String, idleDelay: TimeInterval, qos: DispatchQoS = .unspecified) { - self.queue = DispatchQueue(label: label, qos: qos) - self.idleDelay = idleDelay - } - - // Updating the idle callback is not itself considered "work" at all; it - // happens instantly and it'll run even if the passive work was - // scheduled before the callback was updated. - public func setIdleCallback(_ callback: PassiveCallback?) { - uponIdle.withLock { $0 = callback } - } - - public func async(execute activeWork: @Sendable @escaping () -> Void) { - bumpStateInResponseToWorkSubmission() - - queue.async { [self] in - activeWork() - - let (pendingPostDecrement, currentEpoch) = completeWork() - #if DEBUG - log.debug("\(queue.label): ✅ finished work, pending is now \(pendingPostDecrement)") - #endif - if pendingPostDecrement == 0 { - // There isn't any work left in the queue, so arm the passive - // work to potentially execute soon. - armPassive(expectingEpoch: currentEpoch, quiescence: .began) - } - } - } -} - -private extension PassivelyAwareDispatchQueue { - struct ActivityState { - var pending = 0 - var epoch: UInt = 0 - } - - private func bumpStateInResponseToWorkSubmission() { - let newCount = activityState.withLock { state in - state.epoch += 1 - state.pending += 1 - return state.pending - } - #if DEBUG - log.debug("\(queue.label): 🔄 enqueuing work, pending is now \(newCount)") - #endif - } - - private func completeWork() -> (pending: Int, epoch: UInt) { - activityState.withLock { state in - state.pending -= 1 - return (state.pending, state.epoch) - } - } - - func armPassive(expectingEpoch expectedEpoch: UInt, quiescence: Quiescence) { - // Submission-side epoch changes logically cancel delayed idle checks - // without retaining and releasing DispatchWorkItems across threads. - queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in - guard let self else { return } - #if DEBUG - // log.debug("\(queue.label): 💭 running passive work now") - #endif - - let (isQuiet, epochUnchanged) = activityState.withLock { state in - (state.pending == 0, state.epoch == expectedEpoch) - } - guard isQuiet, epochUnchanged else { - #if DEBUG - log.debug("\(queue.label): 🚫 backing out of passive work (quiet? \(isQuiet), epoch unchanged? \(epochUnchanged))") - #endif - return - } - - uponIdle.read()?(quiescence) - - let shouldContinue = activityState.withLock { state in - state.pending == 0 && state.epoch == expectedEpoch - } - if shouldContinue { - // If no active work was scheduled while we were busy with - // passive work, schedule the passive work to run again soon. - armPassive(expectingEpoch: expectedEpoch, quiescence: .continuing) - } - } - } -} diff --git a/src/IMessage/Sources/IMessageCore/Result+Async.swift b/src/IMessage/Sources/IMessageCore/Result+Async.swift new file mode 100644 index 00000000..5224a2a9 --- /dev/null +++ b/src/IMessage/Sources/IMessageCore/Result+Async.swift @@ -0,0 +1,9 @@ +public extension Result where Failure == Error { + init(catching body: () async throws -> Success) async { + do { + self = .success(try await body()) + } catch { + self = .failure(error) + } + } +} diff --git a/src/IMessage/Sources/IMessageCore/Retry.swift b/src/IMessage/Sources/IMessageCore/Retry.swift index 90a6db3e..cbde36f3 100644 --- a/src/IMessage/Sources/IMessageCore/Retry.swift +++ b/src/IMessage/Sources/IMessageCore/Retry.swift @@ -53,6 +53,40 @@ public func retry( return try res.get() } +public func retry( + withTimeout timeout: TimeInterval, + interval: TimeInterval? = nil, + _ perform: () async throws -> T, + onError: ((_ attempt: Int, _ err: Error?) async throws -> Void)? = nil +) async throws -> T { + let start = Date() + var res: Result! + var attempt = 0 + repeat { + await Task.yield() + try Task.checkCancellation() + do { + return try await perform() + } catch let error as CancellationError { + throw error + } catch { + res = .failure(error) + do { + try await onError?(attempt, error) + attempt += 1 + } catch let error as CancellationError { + throw error + } catch { + Log.errors.error("retry onError errored \(error)") + } + } + if let interval { + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } while -start.timeIntervalSinceNow < timeout + return try res.get() +} + public func retry( retries: Int, interval: TimeInterval? = nil, diff --git a/src/IMessage/Sources/IMessageNode/IMessageNodeExports.swift b/src/IMessage/Sources/IMessageNode/IMessageNodeExports.swift index 4c36be61..b1cbe08f 100644 --- a/src/IMessage/Sources/IMessageNode/IMessageNodeExports.swift +++ b/src/IMessage/Sources/IMessageNode/IMessageNodeExports.swift @@ -56,11 +56,15 @@ enum IMessageNodeExports { ] dict["SystemSettingsOnboarding"] = try [ - "start": NodeFunction { - SystemSettingsOnboarding.start() + "start": NodeFunction { () async in + await MainActor.run { + SystemSettingsOnboarding.start() + } }, - "stop": NodeFunction { - SystemSettingsOnboarding.stop() + "stop": NodeFunction { () async in + await MainActor.run { + SystemSettingsOnboarding.stop() + } }, ] dict["MacPermissions"] = try [ diff --git a/src/IMessage/Sources/IMessageNode/PlatformAPINodeWrapper.swift b/src/IMessage/Sources/IMessageNode/PlatformAPINodeWrapper.swift index f6f24d4a..a1cb8241 100644 --- a/src/IMessage/Sources/IMessageNode/PlatformAPINodeWrapper.swift +++ b/src/IMessage/Sources/IMessageNode/PlatformAPINodeWrapper.swift @@ -148,14 +148,7 @@ import PlatformSDK try await api.notifyAnyway(threadID: threadID) } - @NodeMethod func onThreadSelected(_ args: NodeArguments) async throws { - guard args.count == 2, - let threadID = try args[0].as(String.self), - let sendEventsFunction = try args[1].as(NodeFunction.self) - else { - throw ErrorMessage("Bad PlatformAPI call: \(#function)") - } - + @NodeMethod func onThreadSelected(threadID: String?, sendEventsFunction: NodeFunction) async throws { let sendEvents = UncheckedSendableBox(sendEventsFunction) let eventQueue = threadActivityEventQueue try await api.onThreadSelected(threadID: threadID) { events in diff --git a/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift b/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift new file mode 100644 index 00000000..1f3e0d69 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift @@ -0,0 +1,103 @@ +import Foundation +@testable import IMessage +@testable import IMessageCore +import Testing + +private struct TaskTimeoutTestError: Error, Equatable {} + +private final class ObservableFlag: NSObject { + @objc dynamic var isReady = false +} + +@Test func withTimeoutReturnsOperationValue() async throws { + let value = try await Task.withTimeout(5) { + 42 + } + + #expect(value == 42) +} + +@Test func withTimeoutPropagatesOperationError() async throws { + await #expect(throws: TaskTimeoutTestError.self) { + _ = try await Task.withTimeout(5) { + throw TaskTimeoutTestError() + } + } +} + +@Test func withTimeoutThrowsWhenOperationDoesNotFinishBeforeDeadline() async throws { + let started = Protected(false) + let startedAt = Date() + + await #expect(throws: Task.TimeoutError.self) { + _ = try await Task.withTimeout(0.05) { + started.withLock { $0 = true } + try await Task.sleep(forTimeInterval: 60) + return 0 + } + } + + #expect(started.read()) + #expect(Date().timeIntervalSince(startedAt) < 1) +} + +@Test func withTimeoutAbandonsCancellationIgnoringOperation() async throws { + let startedAt = Date() + + await #expect(throws: Task.TimeoutError.self) { + _ = try await Task.withTimeout(0.05) { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: 123) + } + } + } + } + + #expect(Date().timeIntervalSince(startedAt) < 0.2) +} + +@Test func withTimeoutPropagatesCallerCancellation() async throws { + let started = Protected(false) + let task = Task { + try await Task.withTimeout(10) { + started.withLock { $0 = true } + try await Task.sleep(forTimeInterval: 60) + return 0 + } + } + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { started.read() }) + let cancelledAt = Date() + task.cancel() + + let result = await task.result + #expect(throws: CancellationError.self) { try result.get() } + #expect(Date().timeIntervalSince(cancelledAt) < 1) +} + +@Test func waitForValueReturnsWhenObservedValueMatches() async throws { + let object = ObservableFlag() + let task = Task { + try await object.waitForValue(\.isReady, timeout: 2) { $0 } + } + + object.isReady = true + + let value = try await task.value + #expect(value) +} + +@Test func waitForValueUsesTimeoutWhenObservedValueNeverMatches() async throws { + let object = ObservableFlag() + + await #expect(throws: Task.TimeoutError.self) { + _ = try await object.waitForValue(\.isReady, timeout: 0.05) { $0 } + } +} + +@Test func openDeepLinkHonorsZeroTimeoutBeforeLaunchServicesOpen() async throws { + await #expect(throws: Task.TimeoutError.self) { + _ = try await MessagesController.openDeepLink(.compose, timeout: 0) + } +} diff --git a/src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift b/src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift index 3911afbb..806d401f 100644 --- a/src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift +++ b/src/IMessage/Sources/IMessageTests/DatabaseTickWaitTests.swift @@ -348,14 +348,3 @@ private func messageWithAttachmentLoading(_ loading: Bool) -> PlatformSDK.Messag ] ) } - -private func eventually(timeout: TimeInterval = 1, _ predicate: () -> Bool) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if predicate() { - return true - } - try? await Task.sleep(forTimeInterval: 0.01) - } - return predicate() -} diff --git a/src/IMessage/Sources/IMessageTests/Eventually.swift b/src/IMessage/Sources/IMessageTests/Eventually.swift new file mode 100644 index 00000000..15f510b6 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/Eventually.swift @@ -0,0 +1,18 @@ +import Foundation + +func eventually( + timeout: TimeInterval = 1, + pollInterval: TimeInterval = 0.01, + _ predicate: @Sendable () -> Bool +) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + let pollNanoseconds = UInt64(max(0, pollInterval) * 1_000_000_000) + + while Date() < deadline { + if predicate() { + return true + } + try? await Task.sleep(nanoseconds: pollNanoseconds) + } + return predicate() +} diff --git a/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift b/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift new file mode 100644 index 00000000..0123fd7a --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift @@ -0,0 +1,88 @@ +import Carbon.HIToolbox.Events +import Foundation +@testable import IMessage +@testable import IMessageCore +import Testing + +private func runOffMainThread(_ operation: @escaping () -> Void) async { + await withCheckedContinuation { continuation in + DispatchQueue(label: "KeyPresserTests.background").async { + operation() + continuation.resume() + } + } +} + +@Test func keyPressRunsOnCallingThreadByDefault() async throws { + let result = Protected?>() + let observedKeyPress = Protected<(key: CGKeyCode, flags: CGEventFlags?, isMainThread: Bool)?>() + + await runOffMainThread { + let keyPresser = KeyPresser(pid: 0) { key, flags in + observedKeyPress.withLock { $0 = (key, flags, Thread.isMainThread) } + } + + result.withLock { + $0 = Result { try keyPresser.return() } + } + } + + try #require(result.read()).get() + let observed = try #require(observedKeyPress.read()) + #expect(observed.key == CGKeyCode(kVK_Return)) + #expect(observed.flags == nil) + #expect(observed.isMainThread == false) +} + +@Test func keyPressCanOptIntoMainThreadDispatch() async throws { + let result = Protected?>() + let observedIsMainThread = Protected() + + await runOffMainThread { + let keyPresser = KeyPresser(pid: 0) { _, _ in + observedIsMainThread.withLock { $0 = Thread.isMainThread } + } + + result.withLock { + $0 = Result { try keyPresser.return(onMainThread: true) } + } + } + + try #require(result.read()).get() + let isMainThread = try #require(observedIsMainThread.read() as Bool?) + #expect(isMainThread == true) +} + +@Test func mappedKeyLookupRunsOnMainThreadEvenWhenKeyPressDoesNot() async throws { + let result = Protected?>() + let observedLookup = Protected<(key: Character, isMainThread: Bool)?>() + let observedKeyPress = Protected<(key: CGKeyCode, flags: CGEventFlags?, isMainThread: Bool)?>() + + await runOffMainThread { + let keyPresser = KeyPresser( + pid: 0, + postKeyEvents: { key, flags in + observedKeyPress.withLock { $0 = (key, flags, Thread.isMainThread) } + }, + keyCodeForCharacter: { key in + observedLookup.withLock { $0 = (key, Thread.isMainThread) } + return UInt16(kVK_ANSI_U) + } + ) + + result.withLock { + $0 = Result { try keyPresser.commandShiftU() } + } + } + + try #require(result.read()).get() + let lookup = try #require(observedLookup.read()) + #expect(lookup.key == "u") + #expect(lookup.isMainThread == true) + + let keyPress = try #require(observedKeyPress.read()) + #expect(keyPress.key == CGKeyCode(kVK_ANSI_U)) + #expect(keyPress.flags?.contains(.maskCommand) == true) + #expect(keyPress.flags?.contains(.maskShift) == true) + #expect(keyPress.isMainThread == false) +} diff --git a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift new file mode 100644 index 00000000..7af7d9e8 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift @@ -0,0 +1,232 @@ +import Foundation +@testable import IMessage +@testable import IMessageCore +import Testing + +// Replaces the deleted PassivelyAwareDispatchQueueTests: the idle/serialization +// state machine was re-implemented in `MessagesControllerAutomationLane`, so the +// behavior still needs coverage. Adds tests for the actor-specific paths the old +// dispatch-queue version never had (serialization guarantee, cancellation, +// error isolation). + +private struct Boom: Error {} + +@Test func laneSerializesConcurrentWork() async throws { + // idleDelay is irrelevant here; keep it long so idle work doesn't interfere. + let lane = MessagesControllerAutomationLane(idleDelay: 10) + let state = Protected<(active: Int, maxActive: Int)>((0, 0)) + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<50 { + group.addTask { + try? await lane.run { + state.withLock { s in + s.active += 1 + s.maxActive = max(s.maxActive, s.active) + } + // a window during which a second action would overlap if the + // lane weren't serial + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + state.withLock { $0.active -= 1 } + } + } + } + } + + #expect(state.read().maxActive == 1) + #expect(state.read().active == 0) +} + +@Test func laneRunsSequentialWorkInOrder() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 10) + let order = Protected<[Int]>([]) + + for i in 0..<10 { + try await lane.run { order.withLock { $0.append(i) } } + } + + #expect(order.read() == Array(0..<10)) +} + +@Test func laneIdleFiresAfterWorkDrains() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 0.1) + let idleCount = Protected(0) + await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } + + try await lane.run {} + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 1 }) +} + +@Test func laneIdleRepeatsWhileQuiet() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 0.1) + let idleCount = Protected(0) + await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } + + try await lane.run {} + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 2 }) +} + +@Test func laneStaleIdleIsSuppressedByNewWorkEpochBump() async throws { + // Exercises the epoch guard (`idleCallbackIfStillCurrent`) and `activeWorkSubmitted`'s + // epoch bump: new work submitted before idleDelay elapses must cancel the stale idle. + let idleDelay: TimeInterval = 0.1 + let lane = MessagesControllerAutomationLane(idleDelay: idleDelay) + + // Record the time of each idle fire so we can reason about which cycle fired. + let idleFires = Protected<[Date]>([]) + await lane.setIdleCallback { idleFires.withLock { $0.append(Date()) } } + + // (1) First drain schedules an idle for epoch E. + try await lane.run {} + let afterFirstDrain = Date() + + // (2) Submit + (3) drain new work well before idleDelay elapses. This bumps the + // epoch and cancels the stale epoch-E idle before it can fire. + try await Task.sleep(nanoseconds: UInt64(idleDelay * 0.3 * 1_000_000_000)) + try await lane.run {} + let afterSecondDrain = Date() + + // No idle should have fired yet: the stale epoch-E idle was suppressed, and the + // fresh idle hasn't waited out idleDelay. + #expect(idleFires.read().isEmpty) + + // (4) Wait out the fresh idle and confirm it fires. + #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleFires.read().count >= 1 }) + + // The fire that occurred must belong to the fresh (post-second-drain) cycle: it + // happens at least idleDelay after the second drain. A surviving stale epoch-E idle + // would have fired ~idleDelay after the FIRST drain, i.e. before this point. + let firstFire = idleFires.read().first! + #expect(firstFire.timeIntervalSince(afterSecondDrain) >= idleDelay * 0.5) + // Sanity: the stale idle (epoch E) would have been due ~idleDelay after the first + // drain; that moment has passed without a fire attributable to it. + #expect(firstFire.timeIntervalSince(afterFirstDrain) >= idleDelay) +} + +@Test func laneClearingIdleCallbackStopsFurtherIdleWork() async throws { + let idleDelay: TimeInterval = 0.1 + let lane = MessagesControllerAutomationLane(idleDelay: idleDelay) + let idleCount = Protected(0) + await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } + + try await lane.run {} + + // Wait for at least 3 idle cycles to confirm the repeating idle is healthy. + #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 3 }) + + await lane.setIdleCallback(nil) + let countAtClear = idleCount.read() + + // Several more idle periods elapse; with the callback cleared, nothing more fires. + try? await Task.sleep(nanoseconds: UInt64(idleDelay * 4 * 1_000_000_000)) + #expect(idleCount.read() == countAtClear) +} + +@Test func laneCancellingOneActionStillRunsOthers() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 10) + let firstStarted = Protected(false) + let secondRan = Protected(false) + + let first = Task { + try await lane.run { + firstStarted.withLock { $0 = true } + try await Task.sleep(nanoseconds: 5_000_000_000) // 5s; should be cancelled + } + } + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { firstStarted.read() }) + first.cancel() + + // a queued action behind the cancelled one must still execute + try await lane.run { secondRan.withLock { $0 = true } } + #expect(secondRan.read()) + + // the cancelled action surfaces an error to its caller + let firstResult = await first.result + if case .success = firstResult { + Issue.record("expected the cancelled action to throw") + } +} + +@Test func laneErrorInOneActionDoesNotBreakChain() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 10) + + await #expect(throws: Boom.self) { + try await lane.run { throw Boom() } + } + + let ran = Protected(false) + try await lane.run { ran.withLock { $0 = true } } + #expect(ran.read()) +} + +@Test func laneRunReturnsActionValue() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 10) + let value = try await lane.run { 42 } + #expect(value == 42) +} + +@Test func laneCancellingQueuedActionNeverRunsIt() async throws { + // Distinct from laneCancellingOneActionStillRunsOthers (which cancels an action + // that has already STARTED): here we cancel an action while it is still QUEUED + // behind a long-running one. It must surface an error and never execute its body. + let lane = MessagesControllerAutomationLane(idleDelay: 10) + let firstStarted = Protected(false) + let releaseFirst = Protected(false) + let secondRan = Protected(false) + + let first = Task { + try await lane.run { + firstStarted.withLock { $0 = true } + while !releaseFirst.read() { + try await Task.sleep(nanoseconds: 2_000_000) // 2ms + } + } + } + #expect(await eventually(timeout: 2, pollInterval: 0.005) { firstStarted.read() }) + + // Queue the second action behind the (still-running) first, then cancel it. + let second = Task { + try await lane.run { secondRan.withLock { $0 = true } } + } + try await Task.sleep(nanoseconds: 20_000_000) // let it enqueue behind `first` + second.cancel() + + // Drain the lane. + releaseFirst.withLock { $0 = true } + _ = try? await first.value + + let secondResult = await second.result + #expect(throws: (any Error).self) { try secondResult.get() } + + // Give the lane a beat; the cancelled-while-queued body must not have run. + try await Task.sleep(nanoseconds: 50_000_000) + #expect(secondRan.read() == false) +} + +@Test func laneIdleUsesLatestCallbackAfterMidFlightSwap() async throws { + // Swapping the idle callback while an action is in flight must drop the old + // callback entirely; only the latest one fires once work drains. + let lane = MessagesControllerAutomationLane(idleDelay: 0.1) + let firstCallbackFired = Protected(false) + let secondCallbackFired = Protected(false) + + await lane.setIdleCallback { firstCallbackFired.withLock { $0 = true } } + + try await lane.run { + await lane.setIdleCallback { secondCallbackFired.withLock { $0 = true } } + try await Task.sleep(nanoseconds: 10_000_000) // 10ms, still in flight + } + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { secondCallbackFired.read() }) + #expect(firstCallbackFired.read() == false) +} + +// NOTE: lane re-entrancy (calling `run` from within a `run` action) is guarded by a +// `precondition` in `MessagesControllerAutomationLane.run`. Asserting that it traps +// would require a Swift Testing exit test (subprocess + signal matching), which is +// fragile and toolchain-version-sensitive, so it is intentionally not unit-tested +// here. The contract is enforced by the precondition and documented on +// `isExecutingOnLane`; see plan-eng-review (Issue 3 / Codex Finding 1). diff --git a/src/IMessage/Sources/IMessageTests/PassivelyAwareDispatchQueueTests.swift b/src/IMessage/Sources/IMessageTests/PassivelyAwareDispatchQueueTests.swift deleted file mode 100644 index 3022b419..00000000 --- a/src/IMessage/Sources/IMessageTests/PassivelyAwareDispatchQueueTests.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Dispatch -import Foundation -import IMessageCore -import Testing - -@Test func passivelyAwareQueueFiresIdleAfterActiveWorkDrains() { - let queue = PassivelyAwareDispatchQueue(label: testQueueLabel(), idleDelay: 0.02) - let observations = Protected<[String]>([]) - let idleObserved = DispatchSemaphore(value: 0) - let workFinished = DispatchSemaphore(value: 0) - - queue.setIdleCallback { quiescence in - observations.withLock { $0.append(label(for: quiescence)) } - idleObserved.signal() - } - - queue.async { - workFinished.signal() - } - - #expect(workFinished.wait(timeout: .now() + 1) == .success) - #expect(idleObserved.wait(timeout: .now() + 1) == .success) - #expect(observations.read().first == "began") -} - -@Test func passivelyAwareQueueSuppressesStaleIdleCallbacksAfterNewWork() { - let queue = PassivelyAwareDispatchQueue(label: testQueueLabel(), idleDelay: 0.3) - let observations = Protected<[String]>([]) - let idleObserved = DispatchSemaphore(value: 0) - let firstWorkFinished = DispatchSemaphore(value: 0) - let secondWorkFinished = DispatchSemaphore(value: 0) - - queue.setIdleCallback { quiescence in - observations.withLock { $0.append(label(for: quiescence)) } - idleObserved.signal() - } - - queue.async { - firstWorkFinished.signal() - } - #expect(firstWorkFinished.wait(timeout: .now() + 1) == .success) - - Thread.sleep(forTimeInterval: 0.1) - - queue.async { - secondWorkFinished.signal() - } - #expect(secondWorkFinished.wait(timeout: .now() + 1) == .success) - - #expect(idleObserved.wait(timeout: .now() + 0.25) == .timedOut) - #expect(idleObserved.wait(timeout: .now() + 1) == .success) - #expect(observations.read() == ["began"]) -} - -@Test func passivelyAwareQueueRepeatsContinuingIdleWhileQuiet() { - let queue = PassivelyAwareDispatchQueue(label: testQueueLabel(), idleDelay: 0.02) - let observations = Protected<[String]>([]) - let idleObservedTwice = DispatchSemaphore(value: 0) - - queue.setIdleCallback { quiescence in - let count = observations.withLock { observations in - observations.append(label(for: quiescence)) - return observations.count - } - if count == 2 { - idleObservedTwice.signal() - } - } - - queue.async {} - - #expect(idleObservedTwice.wait(timeout: .now() + 1) == .success) - #expect(Array(observations.read().prefix(2)) == ["began", "continuing"]) -} - -@Test func passivelyAwareQueueSuppressesContinuingIdleWhenIdleCallbackEnqueuesWork() { - let queue = PassivelyAwareDispatchQueue(label: testQueueLabel(), idleDelay: 0.02) - let observations = Protected<[String]>([]) - let didScheduleReentrantWork = Protected(false) - let initialWorkFinished = DispatchSemaphore(value: 0) - let reentrantWorkFinished = DispatchSemaphore(value: 0) - let idleObservedTwice = DispatchSemaphore(value: 0) - - queue.setIdleCallback { quiescence in - let count = observations.withLock { observations in - observations.append(label(for: quiescence)) - return observations.count - } - if !didScheduleReentrantWork.withLock({ scheduled in - defer { scheduled = true } - return scheduled - }) { - queue.async { - reentrantWorkFinished.signal() - } - } - if count == 2 { - idleObservedTwice.signal() - } - } - - queue.async { - initialWorkFinished.signal() - } - - #expect(initialWorkFinished.wait(timeout: .now() + 1) == .success) - #expect(reentrantWorkFinished.wait(timeout: .now() + 1) == .success) - #expect(idleObservedTwice.wait(timeout: .now() + 1) == .success) - #expect(Array(observations.read().prefix(2)) == ["began", "began"]) -} - -@Test func passivelyAwareQueueHandlesRapidConcurrentSubmissions() { - let queue = PassivelyAwareDispatchQueue(label: testQueueLabel(), idleDelay: 0.01) - let totalWorkItems = 500 - let completedCount = Protected(0) - let allWorkFinished = DispatchSemaphore(value: 0) - let idleObserved = DispatchSemaphore(value: 0) - - queue.setIdleCallback { quiescence in - if case .began = quiescence { - idleObserved.signal() - } - } - - DispatchQueue.concurrentPerform(iterations: totalWorkItems) { _ in - queue.async { - let completed = completedCount.withLock { count in - count += 1 - return count - } - if completed == totalWorkItems { - allWorkFinished.signal() - } - } - } - - #expect(allWorkFinished.wait(timeout: .now() + 2) == .success) - #expect(idleObserved.wait(timeout: .now() + 2) == .success) - #expect(completedCount.read() == totalWorkItems) -} - -private func label(for quiescence: Quiescence) -> String { - switch quiescence { - case .began: - return "began" - case .continuing: - return "continuing" - } -} - -private func testQueueLabel() -> String { - "passively-aware-dispatch-queue-test-\(UUID().uuidString)" -} diff --git a/src/IMessage/Sources/IMessageTests/RetryTests.swift b/src/IMessage/Sources/IMessageTests/RetryTests.swift new file mode 100644 index 00000000..402609d1 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/RetryTests.swift @@ -0,0 +1,124 @@ +import Foundation +@testable import IMessage +@testable import IMessageCore +import Testing + +// Covers the async `retry` overloads (the sync Thread.sleep overload is unchanged +// and exercised indirectly elsewhere). Focus: success, retry-until-success, +// timeout rethrows the last error, onError attempt accounting, cancellation +// rethrow, and the retries-count exhaustion path. + +private struct RetryTestError: Error, Equatable { let id: Int } + +@Test func retryAsyncReturnsImmediatelyOnSuccess() async throws { + let attempts = Protected(0) + let value = try await retry(withTimeout: 1) { () async throws -> Int in + attempts.withLock { $0 += 1 } + return 42 + } + #expect(value == 42) + #expect(attempts.read() == 1) +} + +@Test func retryAsyncRetriesUntilSuccess() async throws { + let attempts = Protected(0) + let value = try await retry(withTimeout: 5, interval: 0.01) { () async throws -> String in + let n = attempts.withLock { $0 += 1; return $0 } + if n < 3 { throw RetryTestError(id: n) } + return "ok" + } + #expect(value == "ok") + #expect(attempts.read() == 3) +} + +@Test func retryAsyncRethrowsLastErrorOnTimeout() async throws { + await #expect(throws: RetryTestError.self) { + try await retry(withTimeout: 0.1, interval: 0.02) { () async throws -> Int in + throw RetryTestError(id: -1) + } + } +} + +@Test func retryAsyncInvokesOnErrorWithIncrementingAttempt() async throws { + let seenAttempts = Protected<[Int]>([]) + let attempts = Protected(0) + let value = try await retry(withTimeout: 5, interval: 0.01, { () async throws -> Int in + let n = attempts.withLock { $0 += 1; return $0 } + if n < 3 { throw RetryTestError(id: n) } + return n + }, onError: { attempt, _ in + seenAttempts.withLock { $0.append(attempt) } + }) + #expect(value == 3) + #expect(seenAttempts.read() == [0, 1]) +} + +@Test func retryAsyncRethrowsCancellation() async throws { + let started = Protected(false) + let task = Task { + try await retry(withTimeout: 60, interval: 0.01) { () async throws -> Int in + started.withLock { $0 = true } + try await Task.sleep(forTimeInterval: 1) // cancelled mid-sleep + return 0 + } + } + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { started.read() }) + task.cancel() + + let result = await task.result + #expect(throws: CancellationError.self) { try result.get() } +} + +@Test func retryAsyncRethrowsCancellationDuringOnError() async throws { + // `perform` always throws, so the retry enters `onError`; `onError` then sleeps for + // a full second. Cancelling the task while it is parked in `onError` must propagate + // CancellationError promptly (the sleep throws and `retry` rethrows it) rather than + // waiting out the whole 1s sleep. + let onErrorEntered = Protected(false) + + let task = Task { + try await retry(withTimeout: 60, interval: 0.01, { () async throws -> Int in + throw RetryTestError(id: -1) + }, onError: { _, _ in + onErrorEntered.withLock { $0 = true } + try await Task.sleep(forTimeInterval: 1) // cancelled mid-sleep + }) + } + + #expect(await eventually(timeout: 2, pollInterval: 0.005) { onErrorEntered.read() }) + let cancelledAt = Date() + task.cancel() + + let result = await task.result + #expect(throws: CancellationError.self) { try result.get() } + // Must surface promptly after cancellation, well before the 1s onError sleep would + // have elapsed. + #expect(cancelledAt.timeIntervalSinceNow * -1 < 0.2) +} + +@Test func retryCountExhaustionThrowsAfterRetries() async throws { + let attempts = Protected(0) + await #expect(throws: RetryTestError.self) { + try await retry(retries: 2, interval: 0.01) { (attempt: Int) async throws -> Int in + attempts.withLock { $0 += 1 } + throw RetryTestError(id: attempt) + } + } + // retries: 2 → 1 initial attempt + 2 retries = 3 perform calls + #expect(attempts.read() == 3) +} + +@Test func retryCountReportsRetriesLeftToOnError() async throws { + let retriesLeftSeen = Protected<[Int]>([]) + let attempts = Protected(0) + let value = try await retry(retries: 3, interval: 0.01, { (_: Int) async throws -> String in + let n = attempts.withLock { $0 += 1; return $0 } + if n < 2 { throw RetryTestError(id: n) } + return "done" + }, onError: { _, retriesLeft, _ in + retriesLeftSeen.withLock { $0.append(retriesLeft) } + }) + #expect(value == "done") + #expect(retriesLeftSeen.read() == [3]) // one failure (attempt 0) → retriesLeft = 3 - 0 +} diff --git a/src/IMessage/Sources/IMessageTests/WaitForLaunchTests.swift b/src/IMessage/Sources/IMessageTests/WaitForLaunchTests.swift new file mode 100644 index 00000000..e08d1729 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/WaitForLaunchTests.swift @@ -0,0 +1,173 @@ +import AppKit +import Foundation +@testable import IMessage +import Testing + +private let messagesBundleIdentifier = "com.apple.MobileSMS" + +private struct LaunchTiming { + let appName: String + let processIdentifier: pid_t + let elapsed: TimeInterval +} + +private struct LaunchedApplicationCleanup { + let bundleIdentifier: String + let preservedPIDs: Set + + init(bundleIdentifier: String) { + self.bundleIdentifier = bundleIdentifier + self.preservedPIDs = Self.runningPIDs(bundleIdentifier: bundleIdentifier) + } + + func terminateLaunchedApplications() { + var apps = Self.newApplications(bundleIdentifier: bundleIdentifier, preserving: preservedPIDs) + guard !apps.isEmpty else { return } + + print("cleaning up \(apps.count) launched \(bundleIdentifier) instance(s): \(apps.map(\.processIdentifier).sorted())") + for app in apps where !app.isTerminated { + app.terminate() + } + + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + apps = Self.newApplications(bundleIdentifier: bundleIdentifier, preserving: preservedPIDs) + if apps.allSatisfy(\.isTerminated) || apps.isEmpty { + return + } + Thread.sleep(forTimeInterval: 0.05) + } + + let remaining = Self.newApplications(bundleIdentifier: bundleIdentifier, preserving: preservedPIDs) + .filter { !$0.isTerminated } + for app in remaining { + print("force terminating launched \(bundleIdentifier) instance \(app.processIdentifier)") + app.forceTerminate() + } + + let forceTerminateDeadline = Date().addingTimeInterval(2) + while Date() < forceTerminateDeadline { + let apps = Self.newApplications(bundleIdentifier: bundleIdentifier, preserving: preservedPIDs) + .filter { !$0.isTerminated } + if apps.isEmpty { + return + } + Thread.sleep(forTimeInterval: 0.05) + } + + let stillRunning = Self.newApplications(bundleIdentifier: bundleIdentifier, preserving: preservedPIDs) + .filter { !$0.isTerminated } + .map(\.processIdentifier) + .sorted() + if !stillRunning.isEmpty { + print("timed out waiting to clean up \(bundleIdentifier) instance(s): \(stillRunning)") + } + } + + private static func runningPIDs(bundleIdentifier: String) -> Set { + Set(NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).map(\.processIdentifier)) + } + + private static func newApplications( + bundleIdentifier: String, + preserving preservedPIDs: Set + ) -> [NSRunningApplication] { + NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) + .filter { !preservedPIDs.contains($0.processIdentifier) } + } +} + +private func launchApplication(bundleIdentifier: String) async throws -> NSRunningApplication { + let appURL = try #require(NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier)) + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + configuration.hides = true + configuration.addsToRecentItems = false + configuration.createsNewApplicationInstance = true + configuration.allowsRunningApplicationSubstitution = false + configuration.launchIsUserAction = true + configuration.preferRunningInstance = false + configuration.launchWithoutRestoringState = true + configuration.waitForApplicationToCheckIn = true + + return try await NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) +} + +private func waitForLaunchTiming( + bundleIdentifier: String, + timeout: TimeInterval = 20 +) async throws -> LaunchTiming { + let cleanup = LaunchedApplicationCleanup(bundleIdentifier: bundleIdentifier) + defer { cleanup.terminateLaunchedApplications() } + + let app = try await launchApplication(bundleIdentifier: bundleIdentifier) + let startedAt = Date() + try await app.waitForLaunch(timeout: timeout) + let elapsed = Date().timeIntervalSince(startedAt) + let appName = app.localizedName ?? bundleIdentifier + + print("waitForLaunch for \(appName) pid \(app.processIdentifier) took \(String(format: "%.3f", elapsed))s") + + #expect(app.isFinishedLaunching) + return LaunchTiming(appName: appName, processIdentifier: app.processIdentifier, elapsed: elapsed) +} + +@Suite(.serialized) +struct WaitForLaunchTests { + @Test func waitForLaunchPrintsLaunchDurationForSlowerApp() async throws { + let timing = try await waitForLaunchTiming( + bundleIdentifier: messagesBundleIdentifier, + timeout: 30 + ) + + print("slower app launch candidate: \(timing.appName) pid \(timing.processIdentifier) finished in \(String(format: "%.3f", timing.elapsed))s") + } + + @Test func waitForLaunchSequentialLaunchStress() async throws { + let launchCount = 8 + var timings: [LaunchTiming] = [] + + for index in 1...launchCount { + let timing = try await waitForLaunchTiming( + bundleIdentifier: messagesBundleIdentifier, + timeout: 20 + ) + print("sequential waitForLaunch stress \(index)/\(launchCount): pid \(timing.processIdentifier), \(String(format: "%.3f", timing.elapsed))s") + timings.append(timing) + } + + #expect(timings.count == launchCount) + } + + @Test func waitForLaunchConcurrentWaiterStress() async throws { + let cleanup = LaunchedApplicationCleanup(bundleIdentifier: messagesBundleIdentifier) + defer { cleanup.terminateLaunchedApplications() } + + let app = try await launchApplication(bundleIdentifier: messagesBundleIdentifier) + let waiterCount = 8 + + let elapsedTimes = try await withThrowingTaskGroup(of: TimeInterval.self) { group in + for _ in 0.. Promise - onThreadSelected: (threadID: ThreadID, onEvent: OnServerEventCallback) => Promise + onThreadSelected: (threadID: ThreadID | null, onEvent: OnServerEventCallback) => Promise subscribeToEvents: NativeVoidPlatformAPIMethod<'subscribeToEvents'> diff --git a/src/api.ts b/src/api.ts index dc0a9f73..3fbcf920 100644 --- a/src/api.ts +++ b/src/api.ts @@ -290,7 +290,6 @@ export default class AppleiMessage implements PlatformAPI { notifyAnyway = (hashedThreadID: ThreadID) => this.swiftPlatformAPI!.notifyAnyway(hashedThreadID) onThreadSelected = async (hashedThreadID: ThreadID | null) => { - if (!hashedThreadID) return if (!this.onEvent) return const swiftAPI = this.swiftPlatformAPI! diff --git a/todos.md b/todos.md index c3586488..0bdd2214 100644 --- a/todos.md +++ b/todos.md @@ -22,7 +22,9 @@ - concurrency - [ ] review for races, `PlatformAPI.messagesController` is mutated without isolation - - [ ] kill `PassivelyAwareDispatchQueue` + - [ ] adopt strict-concurrency checking (compiler-verify the actor-isolation model the automation lane relies on). Add `swiftSettings: [.unsafeFlags(["-strict-concurrency=targeted"])]` to the `IMessage` target in Package.swift, then clear the ~20 concurrency warnings it surfaces (26 unique total): non-Sendable captures in `@Sendable` closures and generic `T` not Sendable across `PlatformAPI.swift`, `PlatformAPI+MessagesController.swift` (lane generics), `MessagesController.swift`, `EclipsingWindowCoordinator.swift`, `PromptAutomation.swift`, `Pasteboard+Backup.swift`. Fix with real `Sendable` conformances / isolation annotations — NOT blanket `@unchecked Sendable`, which would defeat the point. Warnings only in Swift 5 mode (build still succeeds), so this can land incrementally; do it as a focused pass with its own QA since it touches fragile AX code. Surfaced by /plan-eng-review on kb/modern-concurrency (Issue 2 / T5); deferred after measuring the warning volume. + - [ ] add a factory seam to `MessagesControllerCoordinator` (inject the controller-construction closure, default = real `MessagesController`) and unit-test the dedup/invalidate/dispose logic: concurrent callers share one construction, `cachedControllerInvalid` → rebuild, `forceInvalidate` disposes first, disposed-mid-flight throws+disposes. Currently untestable because `startControllerCreation` hard-codes the real constructor (needs Accessibility + Messages.app). Surfaced by /plan-eng-review on kb/modern-concurrency (Test Gap 2). + - [ ] make `withAutomation` cleanup fire when `makeAutomatable` partially succeeded: if `EclipsingWindowCoordinator.makeAutomatable` unhides/resizes the window and then a later throwing call (e.g. `position(assign:)`) throws or cancellation hits, `automationDidComplete` is skipped and Messages.app is left visible with stale `windowFramePreEclipse`. Move `makeAutomatable` inside the Result/cleanup scope or make it transactional. Pre-existing (the old prepare/finish+defer shared it); surfaced by codex outside-voice on kb/modern-concurrency (Finding 3). - [ ] improve misfire prevention and robustness - [ ] DatabaseTickWaits.{sentMessageIDs,sentThreadIDs} shouldn't exist, we get ServerEvents for new messages, use that. [wip](https://github.com/beeper/platform-imessage/tree/purav/fix-imessage-send-upsert)