From 0a52564469081ea86f7ff7adb5385f83afff8e53 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 13:50:32 +0530 Subject: [PATCH 01/59] - --- .../Sources/IMessage/Extensions.swift | 1 + ...essagesController+PlatformOperations.swift | 20 +- .../Messages/MessagesController.swift | 610 ++++++++---------- .../Sources/IMessage/OnboardingManager.swift | 33 +- .../PlatformAPI+MessagesController.swift | 18 +- .../Sources/IMessage/PlatformAPI.swift | 38 +- .../IMessage/SystemSettingsOnboarding.swift | 3 + .../Eclipsing/EclipsingDebugger.swift | 1 + .../EclipsingWindowCoordinator.swift | 52 +- .../SpacesWindowCoordinator.swift | 13 +- .../WindowCoordinator.swift | 17 +- .../PassivelyAwareTaskQueue.swift | 149 +++++ 12 files changed, 522 insertions(+), 433 deletions(-) create mode 100644 src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift diff --git a/src/IMessage/Sources/IMessage/Extensions.swift b/src/IMessage/Sources/IMessage/Extensions.swift index 04fd6feb..8eb2daa7 100644 --- a/src/IMessage/Sources/IMessage/Extensions.swift +++ b/src/IMessage/Sources/IMessage/Extensions.swift @@ -60,6 +60,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/Messages/MessagesController+PlatformOperations.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController+PlatformOperations.swift index 15de4671..8d5bab21 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, allowOverlay: false) - 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 splitPlatformMessageID(_ messageID: String) -> (messageGUID: String, partIndex: Int?) { diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 640b0c44..d1eebd6f 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -263,17 +263,18 @@ final class MessagesController { try 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) throws -> NSRunningApplication in // waiting reduces the likelihood that messages.app shows up visible (requiring us to restart it) - if !windowCoordinator.canReuseExtantInstance && Defaults.shouldCoordinateWindow { + if !coordinator.canReuseExtantInstance && Defaults.shouldCoordinateWindow { Thread.sleep(forTimeInterval: 0.1) } log.info("launching messages... (without activation? \(withoutActivation))") @@ -296,7 +297,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 { @@ -311,17 +312,19 @@ final class MessagesController { } } - 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 selectedApp.waitForLaunch() elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in try Self.openDeepLink(url, targeting: selectedApp) }) - keyPresser = KeyPresser(pid: app.processIdentifier) + keyPresser = KeyPresser(pid: selectedApp.processIdentifier) // if app.isHidden { // debugLog("Unhiding Messages...") @@ -384,10 +387,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") @@ -437,27 +440,34 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) !app.isTerminated && (try? elements.mainWindow.isFrameValid) != nil && isMessagesAppResponsive } - @inlinable func prepareForAutomation() throws { + private func withAutomation(_ operation: () throws -> T) async throws -> T { + try await prepareForAutomation() + do { + let result = try operation() + await finishedAutomation() + return result + } catch { + await finishedAutomation() + throw error + } + } + + private func prepareForAutomation() async 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) + try await windowCoordinator.makeAutomatable(mainWindow) } } - @inlinable func finishedAutomation() { + private func finishedAutomation() async { log.info("finishedAutomation") - activityLock.unlock() // this isn't propagated to make finishedAutomation callable inside of defer { … } if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { do { - try windowCoordinator.automationDidComplete(mainWindow) + try await windowCoordinator.automationDidComplete(mainWindow) } catch { log.error("failed to call automationDidComplete on window coordinator: \(String(reflecting: error))") } @@ -466,17 +476,23 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) scheduleCancelReplyTranscriptView() } - private var afterAutomationTask: DispatchWorkItem? - - private static let queue = DispatchQueue(label: "messages-controller-queue") + private var afterAutomationTask: Task? private func scheduleCancelReplyTranscriptView() { - afterAutomationTask = DispatchWorkItem { [self] in - activityLock.lock() - defer { activityLock.unlock() } - try? closeReplyTranscriptView(wait: false) + afterAutomationTask = Task { [weak self] in + do { + try await Task.sleep(forTimeInterval: 1.5) + try await PlatformAPI.onMessagesControllerQueue { [weak self] in + try Task.checkCancellation() + guard let self else { return } + try closeReplyTranscriptView(wait: false) + } + } 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 { @@ -612,65 +628,6 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - 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 { log.debug("withMessageCell (messageCell=\(messageCell))") @@ -777,86 +734,85 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) return MessageCell(messageGUID: closest.closestSelectable.parentMessageGUID.description, offset: closest.offsetFromTarget, cellID: cellID, cellRole: nil, overlay: false) } - 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 openReactionPicker(messageCell: $0) + + let btn = try { + if isSequoiaOrUp { + return try 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 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 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") + } } } } } } - func undoSend(threadID: String, messageCell: MessageCell) throws { + func undoSend(threadID: String, messageCell: MessageCell) async throws { guard isVenturaOrUp else { throw ErrorMessage("!isVenturaOrUp") } @@ -864,29 +820,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 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 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") } @@ -894,9 +848,6 @@ 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 { // this is seemingly always available, even when you're not editing @@ -932,30 +883,32 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) tryPressingCancelEditButton() } - tryPressingCancelEditButton() + try await withAutomation { + 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 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) + + 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 retry(withTimeout: 6.0, interval: 2.0, { - try editAction() + Thread.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) + try elements.menuEditItem.press() + try assignAndCommitEdit() }, onError: onError) - - 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 retry(withTimeout: 6.0, interval: 2.0, { - Thread.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) - try elements.menuEditItem.press() - - try assignAndCommitEdit() - }, onError: onError) } } @@ -1019,49 +972,48 @@ 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() - 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) { - log.debug("retrying unpin") - try triggerThreadCellAction(threadID: threadID, action: .unpin) + try await withAutomation { + 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) { + log.debug("retrying unpin") + try triggerThreadCellAction(threadID: threadID, action: .unpin) + } + } + 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)") - } + 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) } - 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") } @@ -1069,26 +1021,25 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - try prepareForAutomation() - defer { finishedAutomation() } - - 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 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() + } } } } - 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") } @@ -1096,17 +1047,16 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - 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 withActivation(openBefore: url) { + try assertSelectedThread(threadID: threadID) + try triggerThreadCellAction(threadID: threadID, action: .delete) + try 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 @@ -1114,10 +1064,9 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // shows up client-side as a ghost message. let url = try MessagesDeepLink(threadID: threadID, body: " ").url() - try prepareForAutomation() - defer { finishedAutomation() } - - try openDeepLink(url) + try await withAutomation { + _ = try openDeepLink(url) + } } func clearTypingStatus() throws { @@ -1254,7 +1203,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } // 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") } @@ -1297,43 +1246,42 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 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? closeReplyTranscriptView(wait: true) } // needed even when opening deep link - try withActivation(openBefore: url) { - if let threadID { try assertSelectedThread(threadID: threadID) } + try withActivation(openBefore: url) { + if let threadID { try 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 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) + } - 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 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) + } + try sendMessageInField(messageField) + } else if let filePath { + try pasteFileInBodyFieldAndSend(messageField, filePath: filePath) } - try sendMessageInField(messageField) - } else if let filePath { - try pasteFileInBodyFieldAndSend(messageField, filePath: filePath) } } } @@ -1407,34 +1355,38 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) var messagesIsManuallyActivated = false // 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 { 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.onMessagesControllerQueue { [self] in + 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 await windowCoordinator.reset(window) + try await windowCoordinator.userManuallyActivated(app) + } } } catch { log.error("couldn't unhide messages window caused by user activation: \(error)") } } - private func deactivateMessages() { + private func deactivateMessages() async { 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.onMessagesControllerQueue { [self] in + 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 await windowCoordinator.userManuallyDeactivated(app) + } + try? closeAllNonMainWindows() + if window != nil { + resetWindow() + } } } catch { log.error("couldn't hide messages window caused by user activation: \(error)") @@ -1491,45 +1443,30 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) ) } - func notifyAnyway(threadID: String) throws { + func notifyAnyway(threadID: String) async throws { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - try prepareForAutomation() - defer { finishedAutomation() } - - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - try elements.notifyAnywayButton.press() + try await withAutomation { + try withActivation(openBefore: url) { + try assertSelectedThread(threadID: threadID) + try elements.notifyAnywayButton.press() + } } } - func activityStatus(threadID: String) throws -> ThreadActivityObservation { + func activityStatus(threadID: String) async throws -> ThreadActivityObservation { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - try prepareForAutomation() - defer { finishedAutomation() } - var observation = ThreadActivityObservation.unknown - try withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) - observation = activityObservation() + try await withAutomation { + try withActivation(openBefore: url) { + try assertSelectedThread(threadID: threadID) + observation = 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) { let beganWaiting = Date() @@ -1547,9 +1484,9 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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) { + /// returns a callback meant to be assigned to the passive-aware controller queue that observes a single thread once + /// the passive-aware queue should call the returned callback repeatedly + func idleCallback(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> ((Quiescence) async throws -> Void) { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() return { [weak self] _ in @@ -1576,18 +1513,14 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if lastThreadIDOpenedForObservation.read() != threadID { log.debug("activity: entered idle state or thread id changed, opening deep link") - try prepareForAutomation() - defer { finishedAutomation() } - - try openDeepLink(url) - log.debug("activity: opened deep link, waiting for layout change") - lastThreadIDOpenedForObservation.withLock { $0 = threadID } - waitForLayoutChange(timeout: 0.5) + try await withAutomation { + _ = try self.openDeepLink(url) + log.debug("activity: opened deep link, waiting for layout change") + self.lastThreadIDOpenedForObservation.withLock { $0 = threadID } + self.waitForLayoutChange(timeout: 0.5) + } } - guard activityLock.tryLock() else { return } - defer { activityLock.unlock() } - let observationToSend = activityObservation() guard lastSentActivityObservation != observationToSend || (observationToSend.activityType == .typing && lastSentActivityObservationTime.map { $0.timeIntervalSinceNow * -1 > 30 } == true) else { #if DEBUG @@ -1613,6 +1546,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) guard !isDisposed else { return } NotificationCenter.default.removeObserver(self, name: .CNContactStoreDidChange, object: nil) isDisposed = true + afterAutomationTask?.cancel() lifecycleConveyor?.cancel() lifecycleEventsTask?.cancel() diff --git a/src/IMessage/Sources/IMessage/OnboardingManager.swift b/src/IMessage/Sources/IMessage/OnboardingManager.swift index 2407547e..6f2a52ec 100644 --- a/src/IMessage/Sources/IMessage/OnboardingManager.swift +++ b/src/IMessage/Sources/IMessage/OnboardingManager.swift @@ -5,6 +5,7 @@ import Logging private let log = Logger(imessageLabel: "onboarding-manager") +@MainActor final class OnboardingManager { private var onboardingWindow: NSWindow? private var pollingTimer: Timer? @@ -61,38 +62,36 @@ final class OnboardingManager { } func createWindow() { - DispatchQueue.main.async { - self.pollingTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in + pollingTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + 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/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index ac4472d8..bb872d65 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -19,7 +19,7 @@ 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() @@ -36,7 +36,7 @@ private actor MessagesControllerCoordinator { 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") @@ -155,12 +155,12 @@ 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) + static let messagesControllerQueue = PassivelyAwareTaskQueue(label: "messages-controller-platform-queue", idleDelay: 1) 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, @@ -175,18 +175,14 @@ extension PlatformAPI { } static func onMessagesControllerQueue( - _ action: @escaping @Sendable () throws -> T + _ action: @escaping @Sendable () async throws -> T ) async throws -> T { - try await withCheckedThrowingContinuation { continuation in - messagesControllerQueue.async { - continuation.resume(with: Result { try action() }) - } - } + try await messagesControllerQueue.async(action) } static func makeMessagesController(reportErrorMessage: ReportErrorMessage?) async throws -> MessagesController { try await Self.onMessagesControllerQueue { - try MessagesController(reportErrorMessage: { txt in + try await MessagesController(reportErrorMessage: { txt in platformMessagesControllerLog.error(" report to sentry: \(txt)") try? reportErrorMessage?(txt) }) diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index bf4ea085..f0af8de4 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -264,7 +264,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 +277,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 +290,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 +310,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 +344,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,7 +366,7 @@ 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() } @@ -375,7 +375,7 @@ public final class PlatformAPI { 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 +392,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 +421,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, @@ -491,17 +491,17 @@ 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( @@ -607,7 +607,7 @@ public final class PlatformAPI { // 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() @@ -677,7 +677,7 @@ public final class PlatformAPI { Self.messagesControllerQueue.setIdleCallback { quiescence in guard threadObserveRequestToken.read() == requestID else { return } do { - try observe(quiescence) + try await observe(quiescence) } catch { platformLog.error("failed to observe activity: \(error)") } @@ -689,7 +689,7 @@ public final class PlatformAPI { // unnecessarily running guard threadObserveRequestToken.read() == requestID else { return } - try observe(.began) + try await observe(.began) } } @@ -698,13 +698,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 @@ -720,7 +720,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/SystemSettingsOnboarding.swift b/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift index dc65ce91..541b4d96 100644 --- a/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift +++ b/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift @@ -1,6 +1,8 @@ public enum SystemSettingsOnboarding { + @MainActor static var onboardingManager: OnboardingManager? + @MainActor public static func start() { guard onboardingManager == nil else { return } let onboardingManager = OnboardingManager() @@ -8,6 +10,7 @@ public enum SystemSettingsOnboarding { onboardingManager.createWindow() } + @MainActor public static func stop() { onboardingManager?.closeWindow() onboardingManager = nil 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..55363499 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,7 +38,8 @@ final class EclipsingWindowCoordinator: WindowCoordinator { hideDebouncer = HideDebouncer(debouncingFor: Self.debouncingPeriod) } - func makeAutomatable(_ messagesWindow: Accessibility.Element) throws { + @MainActor + func makeAutomatable(_ messagesWindow: Accessibility.Element) async 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 { @@ -123,11 +125,13 @@ final class EclipsingWindowCoordinator: WindowCoordinator { } } - func automationDidComplete(_: Accessibility.Element) throws { + @MainActor + func automationDidComplete(_: Accessibility.Element) async throws { hideDebouncer.requestHide() } - func reset(_ window: Accessibility.Element) throws { + @MainActor + func reset(_ window: Accessibility.Element) async throws { hideDebouncer.immediatelyUnhide() guard let originalFrame = windowFramePreEclipse else { @@ -144,11 +148,13 @@ final class EclipsingWindowCoordinator: WindowCoordinator { try window.setFrame(originalFrame) } - func userManuallyActivated(_: NSRunningApplication) throws { + @MainActor + func userManuallyActivated(_: NSRunningApplication) async throws { hideDebouncer.immediatelyUnhide() } - func userManuallyDeactivated(_: NSRunningApplication) throws { + @MainActor + func userManuallyDeactivated(_: NSRunningApplication) async throws { hideDebouncer.requestHide() } } @@ -169,6 +175,7 @@ private extension EclipsingWindowCoordinator { let debugLabel: String let description: String + @MainActor static func electron(_ window: NSWindow) -> AnchorWindow { let frame = EclipsingWindowCoordinator.screenFrame(for: window) let screen = EclipsingWindowCoordinator.screen(containing: frame) @@ -182,6 +189,7 @@ private extension EclipsingWindowCoordinator { ) } + @MainActor 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" @@ -213,33 +221,22 @@ private extension EclipsingWindowCoordinator { // Accurate as of macOS 15.3.2. static let messagesAppMinimumSize = NSSize(width: 660.0, height: 320.0) + @MainActor 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) - } - - 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 - } - - throw WindowCoordinatorError.generic(message: "Couldn't find an eclipsing anchor window") + 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") } + @MainActor static func screenFrame(for window: NSWindow) -> NSRect { if window.screen == nil { log.warning("can't determine which screen the Electron window is on; the eclipse position may be unexpected") @@ -254,6 +251,7 @@ private extension EclipsingWindowCoordinator { /// front (the frontmost app's topmost window, else the topmost window overall), /// but it can't guarantee z-order for a non-Beeper anchor, so the eclipse may /// not fully hide Messages. + @MainActor static func externalEclipsingAnchorWindow(messagesPID: pid_t) -> Window.Description? { let excludedPIDs: Set = [getpid(), messagesPID] let candidates = externalAnchorWindows(excludingPIDs: excludedPIDs) // front-to-back z-order @@ -283,6 +281,7 @@ private extension EclipsingWindowCoordinator { } } + @MainActor static func screen(containing screenFrame: NSRect) -> NSScreen? { // screenFrame is in screen/AX space (origin top-left); NSScreen frames are // Cocoa space (origin bottom-left), so flip the center point before testing. @@ -311,6 +310,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..2d684d93 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,15 @@ final class SpacesWindowCoordinator { extension SpacesWindowCoordinator: WindowCoordinator { var canReuseExtantInstance: Bool { true } - func makeAutomatable(_ window: Accessibility.Element) throws { + @MainActor + func makeAutomatable(_ window: Accessibility.Element) async throws { guard app?.isActive == false else { return } lastKnownWindow = window try moveLastKnownWindowToHiddenSpace() } - func reset(_ window: Accessibility.Element) throws { + @MainActor + 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,11 +82,13 @@ extension SpacesWindowCoordinator: WindowCoordinator { try (window.window()).moveToSpace(currentSpace) } - func automationDidComplete(_: Accessibility.Element) throws { + @MainActor + func automationDidComplete(_: Accessibility.Element) async throws { // after automating, keep the window on the hidden space } - func userManuallyActivated(_: NSRunningApplication) throws { + @MainActor + func userManuallyActivated(_: NSRunningApplication) async throws { lastManualActivation = Date() } } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift index 87dc4aa5..b20da429 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. */ @@ -16,10 +17,10 @@ protocol WindowCoordinator { * * This is called right before the app needs to be automated. */ - 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 + func automationDidComplete(_ window: Accessibility.Element) async throws /** * Reverts the manipulations performed in `makeAutomatable`. @@ -27,21 +28,21 @@ protocol WindowCoordinator { * For example, this is called when the user manually activates the app. Coordination should quiesce until the user * resigns manual control. */ - 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. */ - func userManuallyActivated(_ app: NSRunningApplication) throws + func userManuallyActivated(_ app: NSRunningApplication) async throws /** Called when the user finishes manual control over the app. */ - func userManuallyDeactivated(_ app: NSRunningApplication) throws + func userManuallyDeactivated(_ app: NSRunningApplication) async throws } extension WindowCoordinator { - func userManuallyActivated(_: NSRunningApplication) throws { + func userManuallyActivated(_: NSRunningApplication) async throws { // make this method optional } - func userManuallyDeactivated(_: NSRunningApplication) throws { + func userManuallyDeactivated(_: NSRunningApplication) async throws { // make this method optional } } diff --git a/src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift b/src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift new file mode 100644 index 00000000..bdf38e82 --- /dev/null +++ b/src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift @@ -0,0 +1,149 @@ +import Dispatch +import Foundation +import Logging + +private let taskQueueLog = Logger(imessageLabel: "idle-aware-task-queue") + +public final class PassivelyAwareTaskQueue: @unchecked Sendable { + public typealias PassiveCallback = @Sendable (Quiescence) async -> Void + + public private(set) var idleDelay: TimeInterval + + private let label: String + private let idleScheduler: DispatchQueue + private let serialQueue = SerialTaskQueue() + private var activityState = Protected(ActivityState()) + private var uponIdle = Protected() + + public init(label: String, idleDelay: TimeInterval) { + self.label = label + self.idleDelay = idleDelay + self.idleScheduler = DispatchQueue(label: "\(label)-idle-scheduler") + } + + public func setIdleCallback(_ callback: PassiveCallback?) { + uponIdle.withLock { $0 = callback } + } + + public func async(_ activeWork: @Sendable @escaping () async throws -> T) async throws -> T { + bumpStateInResponseToWorkSubmission() + + do { + let result = try await serialQueue.run(activeWork) + finishWork() + return result + } catch { + finishWork() + throw error + } + } +} + +private extension PassivelyAwareTaskQueue { + struct ActivityState { + var pending = 0 + var epoch: UInt = 0 + } + + func bumpStateInResponseToWorkSubmission() { + let newCount = activityState.withLock { state in + state.epoch += 1 + state.pending += 1 + return state.pending + } + #if DEBUG + taskQueueLog.debug("\(label): enqueuing async work, pending is now \(newCount)") + #endif + } + + func finishWork() { + let (pendingPostDecrement, currentEpoch) = activityState.withLock { state in + state.pending -= 1 + return (state.pending, state.epoch) + } + + #if DEBUG + taskQueueLog.debug("\(label): finished async work, pending is now \(pendingPostDecrement)") + #endif + + if pendingPostDecrement == 0 { + armPassive(expectingEpoch: currentEpoch, quiescence: .began) + } + } + + func armPassive(expectingEpoch expectedEpoch: UInt, quiescence: Quiescence) { + idleScheduler.asyncAfter(deadline: .now() + idleDelay) { [weak self] in + guard let self else { return } + Task { + await self.runPassive(expectingEpoch: expectedEpoch, quiescence: quiescence) + } + } + } + + func runPassive(expectingEpoch expectedEpoch: UInt, quiescence: Quiescence) async { + let shouldRun = activityState.withLock { state in + state.pending == 0 && state.epoch == expectedEpoch + } + guard shouldRun else { + #if DEBUG + taskQueueLog.debug("\(label): backing out of passive async work before enqueue") + #endif + return + } + + await serialQueue.run { + let shouldStillRun = self.activityState.withLock { state in + state.pending == 0 && state.epoch == expectedEpoch + } + guard shouldStillRun else { + #if DEBUG + taskQueueLog.debug("\(self.label): backing out of passive async work after enqueue") + #endif + return + } + + await self.uponIdle.read()?(quiescence) + + let shouldContinue = self.activityState.withLock { state in + state.pending == 0 && state.epoch == expectedEpoch + } + if shouldContinue { + self.armPassive(expectingEpoch: expectedEpoch, quiescence: .continuing) + } + } + } +} + +private actor SerialTaskQueue { + private var tail: Task? + + func run(_ operation: @Sendable @escaping () async throws -> T) async throws -> T { + let task = enqueue(operation) + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + func run(_ operation: @Sendable @escaping () async -> Void) async { + let task = enqueue { + await operation() + } + _ = try? await task.value + } + + private func enqueue(_ operation: @Sendable @escaping () async throws -> T) -> Task { + let previous = tail + let task = Task { + await previous?.value + try Task.checkCancellation() + return try await operation() + } + + tail = Task { + _ = try? await task.value + } + return task + } +} From 3623f1c536f2867066c17d4fee73a9b86a5d9336 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 13:54:46 +0530 Subject: [PATCH 02/59] - --- .../Messages/MessagesController.swift | 4 +- .../PlatformAPI+MessagesController.swift | 142 ++++++++++++++++- .../Sources/IMessage/PlatformAPI.swift | 6 +- .../PassivelyAwareTaskQueue.swift | 149 ------------------ 4 files changed, 143 insertions(+), 158 deletions(-) delete mode 100644 src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index d1eebd6f..dbeb8a24 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1484,8 +1484,8 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) log.error("didn't observe a layout change within \(timeout)s, continuing anyways") } - /// returns a callback meant to be assigned to the passive-aware controller queue that observes a single thread once - /// the passive-aware queue should call the returned callback repeatedly + /// Returns a callback that observes one thread while controller work is idle. + /// The platform-level idle observer calls this repeatedly after active automation drains. func idleCallback(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> ((Quiescence) async throws -> Void) { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index bb872d65..2cec2e64 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 @@ -10,6 +11,120 @@ private enum MessagesControllerCoordinatorError: Error { case pendingControllerInvalidated } +private actor MessagesControllerSerializer { + private var tail: Task? + + func run(_ action: @Sendable @escaping () async throws -> T) async throws -> T { + let task = enqueue(action) + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + func run(_ action: @Sendable @escaping () async -> Void) async { + let task = enqueue { + await action() + } + await withTaskCancellationHandler { + _ = try? await task.value + } onCancel: { + task.cancel() + } + } + + 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 action() + } + + tail = Task { + _ = try? await task.value + } + return task + } +} + +private actor MessagesControllerIdleObservation { + typealias Callback = @Sendable (Quiescence) async -> Void + typealias IdleWorkRunner = @Sendable (@escaping @Sendable () async -> Void) async -> Void + + private let idleDelay: TimeInterval + private var activeWorkCount = 0 + private var epoch: UInt = 0 + private var callback: Callback? + private var idleTask: Task? + + init(idleDelay: TimeInterval) { + self.idleDelay = idleDelay + } + + func setCallback(_ callback: Callback?) { + self.callback = callback + epoch += 1 + idleTask?.cancel() + idleTask = nil + } + + func activeWorkWillBegin() { + epoch += 1 + activeWorkCount += 1 + idleTask?.cancel() + idleTask = nil + } + + func activeWorkDidFinish(runIdleWork: @escaping IdleWorkRunner) { + activeWorkCount = max(0, activeWorkCount - 1) + guard activeWorkCount == 0 else { return } + scheduleIdleCallback(.began, runIdleWork: runIdleWork) + } +} + +private extension MessagesControllerIdleObservation { + func scheduleIdleCallback(_ quiescence: Quiescence, runIdleWork: @escaping IdleWorkRunner) { + guard callback != nil else { return } + + let expectedEpoch = epoch + let idleDelay = idleDelay + idleTask = Task { + do { + try await Task.sleep(forTimeInterval: idleDelay) + } catch { + return + } + + await runIdleWork { + guard await self.shouldRunIdleCallback(expectedEpoch: expectedEpoch) else { + return + } + + await self.invokeCallback(quiescence) + + guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { + return + } + await self.scheduleIdleCallback(.continuing, runIdleWork: runIdleWork) + } + } + } + + func shouldRunIdleCallback(expectedEpoch: UInt) -> Bool { + activeWorkCount == 0 && epoch == expectedEpoch && callback != nil && idleTask?.isCancelled == false + } + + func invokeCallback(_ quiescence: Quiescence) async { + await callback?(quiescence) + } + + func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { + activeWorkCount == 0 && epoch == expectedEpoch && callback != nil + } +} + private actor MessagesControllerCoordinator { private var current: MessagesControllerEntry? // Actors are reentrant across awaits, so concurrent callers share one in-flight construction. @@ -146,7 +261,7 @@ private extension MessagesControllerCoordinator { func dispose(_ entry: MessagesControllerEntry) async throws { Log.default.notice("[PlatformAPI] disposing MessagesController") try await PlatformAPI.onMessagesControllerQueue { - PlatformAPI.messagesControllerQueue.setIdleCallback(nil) + await PlatformAPI.setMessagesControllerIdleObservation(nil) entry.value.dispose() } } @@ -154,8 +269,9 @@ 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 = PassivelyAwareTaskQueue(label: "messages-controller-platform-queue", idleDelay: 1) + // one MessagesController and one async serializer for Messages.app automation. + private static let messagesControllerSerializer = MessagesControllerSerializer() + private static let messagesControllerIdleObservation = MessagesControllerIdleObservation(idleDelay: 1) fileprivate static let messagesControllerCoordinator = MessagesControllerCoordinator() func withMessagesController( @@ -177,7 +293,25 @@ extension PlatformAPI { static func onMessagesControllerQueue( _ action: @escaping @Sendable () async throws -> T ) async throws -> T { - try await messagesControllerQueue.async(action) + await messagesControllerIdleObservation.activeWorkWillBegin() + do { + let result = try await messagesControllerSerializer.run(action) + await messagesControllerIdleObservation.activeWorkDidFinish(runIdleWork: runMessagesControllerIdleWork) + return result + } catch { + await messagesControllerIdleObservation.activeWorkDidFinish(runIdleWork: runMessagesControllerIdleWork) + throw error + } + } + + static func setMessagesControllerIdleObservation( + _ callback: (@Sendable (Quiescence) async -> Void)? + ) async { + await messagesControllerIdleObservation.setCallback(callback) + } + + private static let runMessagesControllerIdleWork: MessagesControllerIdleObservation.IdleWorkRunner = { action in + await messagesControllerSerializer.run(action) } static func makeMessagesController(reportErrorMessage: ReportErrorMessage?) async throws -> MessagesController { diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index f0af8de4..c6cb2f4c 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -627,8 +627,8 @@ public final class PlatformAPI { return } - // reset the idle callback in case we fail and bail out - Self.messagesControllerQueue.setIdleCallback(nil) + // reset the idle observer in case we fail and bail out + await Self.setMessagesControllerIdleObservation(nil) let requestID = UUID() let threadObserveRequestToken = threadObserveRequestToken @@ -674,7 +674,7 @@ public final class PlatformAPI { guard threadObserveRequestToken.read() == requestID else { return } let observe = try controller.idleCallback(observingThreadID: threadID, statusSender: sendStatus) - Self.messagesControllerQueue.setIdleCallback { quiescence in + await Self.setMessagesControllerIdleObservation { quiescence in guard threadObserveRequestToken.read() == requestID else { return } do { try await observe(quiescence) diff --git a/src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift b/src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift deleted file mode 100644 index bdf38e82..00000000 --- a/src/IMessage/Sources/IMessageCore/PassivelyAwareTaskQueue.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Dispatch -import Foundation -import Logging - -private let taskQueueLog = Logger(imessageLabel: "idle-aware-task-queue") - -public final class PassivelyAwareTaskQueue: @unchecked Sendable { - public typealias PassiveCallback = @Sendable (Quiescence) async -> Void - - public private(set) var idleDelay: TimeInterval - - private let label: String - private let idleScheduler: DispatchQueue - private let serialQueue = SerialTaskQueue() - private var activityState = Protected(ActivityState()) - private var uponIdle = Protected() - - public init(label: String, idleDelay: TimeInterval) { - self.label = label - self.idleDelay = idleDelay - self.idleScheduler = DispatchQueue(label: "\(label)-idle-scheduler") - } - - public func setIdleCallback(_ callback: PassiveCallback?) { - uponIdle.withLock { $0 = callback } - } - - public func async(_ activeWork: @Sendable @escaping () async throws -> T) async throws -> T { - bumpStateInResponseToWorkSubmission() - - do { - let result = try await serialQueue.run(activeWork) - finishWork() - return result - } catch { - finishWork() - throw error - } - } -} - -private extension PassivelyAwareTaskQueue { - struct ActivityState { - var pending = 0 - var epoch: UInt = 0 - } - - func bumpStateInResponseToWorkSubmission() { - let newCount = activityState.withLock { state in - state.epoch += 1 - state.pending += 1 - return state.pending - } - #if DEBUG - taskQueueLog.debug("\(label): enqueuing async work, pending is now \(newCount)") - #endif - } - - func finishWork() { - let (pendingPostDecrement, currentEpoch) = activityState.withLock { state in - state.pending -= 1 - return (state.pending, state.epoch) - } - - #if DEBUG - taskQueueLog.debug("\(label): finished async work, pending is now \(pendingPostDecrement)") - #endif - - if pendingPostDecrement == 0 { - armPassive(expectingEpoch: currentEpoch, quiescence: .began) - } - } - - func armPassive(expectingEpoch expectedEpoch: UInt, quiescence: Quiescence) { - idleScheduler.asyncAfter(deadline: .now() + idleDelay) { [weak self] in - guard let self else { return } - Task { - await self.runPassive(expectingEpoch: expectedEpoch, quiescence: quiescence) - } - } - } - - func runPassive(expectingEpoch expectedEpoch: UInt, quiescence: Quiescence) async { - let shouldRun = activityState.withLock { state in - state.pending == 0 && state.epoch == expectedEpoch - } - guard shouldRun else { - #if DEBUG - taskQueueLog.debug("\(label): backing out of passive async work before enqueue") - #endif - return - } - - await serialQueue.run { - let shouldStillRun = self.activityState.withLock { state in - state.pending == 0 && state.epoch == expectedEpoch - } - guard shouldStillRun else { - #if DEBUG - taskQueueLog.debug("\(self.label): backing out of passive async work after enqueue") - #endif - return - } - - await self.uponIdle.read()?(quiescence) - - let shouldContinue = self.activityState.withLock { state in - state.pending == 0 && state.epoch == expectedEpoch - } - if shouldContinue { - self.armPassive(expectingEpoch: expectedEpoch, quiescence: .continuing) - } - } - } -} - -private actor SerialTaskQueue { - private var tail: Task? - - func run(_ operation: @Sendable @escaping () async throws -> T) async throws -> T { - let task = enqueue(operation) - return try await withTaskCancellationHandler { - try await task.value - } onCancel: { - task.cancel() - } - } - - func run(_ operation: @Sendable @escaping () async -> Void) async { - let task = enqueue { - await operation() - } - _ = try? await task.value - } - - private func enqueue(_ operation: @Sendable @escaping () async throws -> T) -> Task { - let previous = tail - let task = Task { - await previous?.value - try Task.checkCancellation() - return try await operation() - } - - tail = Task { - _ = try? await task.value - } - return task - } -} From bed64182f4d87df728a80f6ed6a8a95d0d8a0c70 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 13:56:27 +0530 Subject: [PATCH 03/59] - --- .../IMDatabaseTestBench/TestBench.swift | 53 +----- .../Messages/MessagesController.swift | 4 +- .../PlatformAPI+MessagesController.swift | 16 +- .../Sources/IMessage/PlatformAPI.swift | 6 +- .../PassivelyAwareDispatchQueue.swift | 109 ------------- .../PassivelyAwareDispatchQueueTests.swift | 153 ------------------ todos.md | 1 - 7 files changed, 14 insertions(+), 328 deletions(-) delete mode 100644 src/IMessage/Sources/IMessageCore/PassivelyAwareDispatchQueue.swift delete mode 100644 src/IMessage/Sources/IMessageTests/PassivelyAwareDispatchQueueTests.swift 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/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index dbeb8a24..554ee26c 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1486,10 +1486,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) /// Returns a callback that observes one thread while controller work is idle. /// The platform-level idle observer calls this repeatedly after active automation drains. - func idleCallback(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> ((Quiescence) async throws -> Void) { + func idleCallback(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> (() async throws -> Void) { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - return { [weak self] _ in + return { [weak self] in guard let self else { return } guard !Defaults.shouldCoordinateWindow && !messagesIsManuallyActivated else { diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 2cec2e64..6e401825 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -50,7 +50,7 @@ private actor MessagesControllerSerializer { } private actor MessagesControllerIdleObservation { - typealias Callback = @Sendable (Quiescence) async -> Void + typealias Callback = @Sendable () async -> Void typealias IdleWorkRunner = @Sendable (@escaping @Sendable () async -> Void) async -> Void private let idleDelay: TimeInterval @@ -80,12 +80,12 @@ private actor MessagesControllerIdleObservation { func activeWorkDidFinish(runIdleWork: @escaping IdleWorkRunner) { activeWorkCount = max(0, activeWorkCount - 1) guard activeWorkCount == 0 else { return } - scheduleIdleCallback(.began, runIdleWork: runIdleWork) + scheduleIdleCallback(runIdleWork: runIdleWork) } } private extension MessagesControllerIdleObservation { - func scheduleIdleCallback(_ quiescence: Quiescence, runIdleWork: @escaping IdleWorkRunner) { + func scheduleIdleCallback(runIdleWork: @escaping IdleWorkRunner) { guard callback != nil else { return } let expectedEpoch = epoch @@ -102,12 +102,12 @@ private extension MessagesControllerIdleObservation { return } - await self.invokeCallback(quiescence) + await self.invokeCallback() guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { return } - await self.scheduleIdleCallback(.continuing, runIdleWork: runIdleWork) + await self.scheduleIdleCallback(runIdleWork: runIdleWork) } } } @@ -116,8 +116,8 @@ private extension MessagesControllerIdleObservation { activeWorkCount == 0 && epoch == expectedEpoch && callback != nil && idleTask?.isCancelled == false } - func invokeCallback(_ quiescence: Quiescence) async { - await callback?(quiescence) + func invokeCallback() async { + await callback?() } func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { @@ -305,7 +305,7 @@ extension PlatformAPI { } static func setMessagesControllerIdleObservation( - _ callback: (@Sendable (Quiescence) async -> Void)? + _ callback: (@Sendable () async -> Void)? ) async { await messagesControllerIdleObservation.setCallback(callback) } diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index c6cb2f4c..709f8473 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -674,10 +674,10 @@ public final class PlatformAPI { guard threadObserveRequestToken.read() == requestID else { return } let observe = try controller.idleCallback(observingThreadID: threadID, statusSender: sendStatus) - await Self.setMessagesControllerIdleObservation { quiescence in + await Self.setMessagesControllerIdleObservation { guard threadObserveRequestToken.read() == requestID else { return } do { - try await observe(quiescence) + try await observe() } catch { platformLog.error("failed to observe activity: \(error)") } @@ -689,7 +689,7 @@ public final class PlatformAPI { // unnecessarily running guard threadObserveRequestToken.read() == requestID else { return } - try await observe(.began) + try await observe() } } 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/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/todos.md b/todos.md index e6677a20..ea3fb31c 100644 --- a/todos.md +++ b/todos.md @@ -21,7 +21,6 @@ - concurrency - [ ] review for races, `PlatformAPI.messagesController` is mutated without isolation - - [ ] kill `PassivelyAwareDispatchQueue` - [ ] 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) From 3c3368e9ad8284b4ec734eecef79cc3c5ef8c6ce Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 14:12:16 +0530 Subject: [PATCH 04/59] - --- .../Messages/MessagesController.swift | 53 +------ .../PlatformAPI+MessagesController.swift | 144 ++++++++---------- .../Sources/IMessage/PlatformAPI.swift | 4 +- .../EclipsingWindowCoordinator.swift | 5 - .../SpacesWindowCoordinator.swift | 4 - .../WindowCoordinator.swift | 7 + 6 files changed, 79 insertions(+), 138 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 554ee26c..ffda8257 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -442,19 +442,14 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func withAutomation(_ operation: () throws -> T) async throws -> T { try await prepareForAutomation() - do { - let result = try operation() - await finishedAutomation() - return result - } catch { - await finishedAutomation() - throw error - } + let result = Result(catching: operation) + await finishedAutomation() + return try result.get() } private func prepareForAutomation() async throws { log.info("prepareForAutomation") - afterAutomationTask?.cancel() + cancelReplyTranscriptViewTask?.cancel() log.debug("prepareForAutomation: making the app automatable") if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { @@ -476,10 +471,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) scheduleCancelReplyTranscriptView() } - private var afterAutomationTask: Task? + private var cancelReplyTranscriptViewTask: Task? private func scheduleCancelReplyTranscriptView() { - afterAutomationTask = Task { [weak self] in + cancelReplyTranscriptViewTask = Task { [weak self] in do { try await Task.sleep(forTimeInterval: 1.5) try await PlatformAPI.onMessagesControllerQueue { [weak self] in @@ -912,40 +907,6 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - #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 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 { @@ -1546,7 +1507,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) guard !isDisposed else { return } NotificationCenter.default.removeObserver(self, name: .CNContactStoreDidChange, object: nil) isDisposed = true - afterAutomationTask?.cancel() + cancelReplyTranscriptViewTask?.cancel() lifecycleConveyor?.cancel() lifecycleEventsTask?.cancel() diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 6e401825..3846d4c9 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -11,27 +11,42 @@ private enum MessagesControllerCoordinatorError: Error { case pendingControllerInvalidated } -private actor MessagesControllerSerializer { +private actor MessagesControllerAutomationLane { + typealias IdleCallback = @Sendable () async -> Void + + private let idleDelay: TimeInterval private var tail: Task? + private var activeWorkCount = 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 { + activeWorkWillBegin() let task = enqueue(action) - return try await withTaskCancellationHandler { - try await task.value - } onCancel: { - task.cancel() + do { + let result = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + activeWorkDidFinish() + return result + } catch { + activeWorkDidFinish() + throw error } } - func run(_ action: @Sendable @escaping () async -> Void) async { - let task = enqueue { - await action() - } - 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 { @@ -47,48 +62,24 @@ private actor MessagesControllerSerializer { } return task } -} - -private actor MessagesControllerIdleObservation { - typealias Callback = @Sendable () async -> Void - typealias IdleWorkRunner = @Sendable (@escaping @Sendable () async -> Void) async -> Void - - private let idleDelay: TimeInterval - private var activeWorkCount = 0 - private var epoch: UInt = 0 - private var callback: Callback? - private var idleTask: Task? - - init(idleDelay: TimeInterval) { - self.idleDelay = idleDelay - } - func setCallback(_ callback: Callback?) { - self.callback = callback - epoch += 1 - idleTask?.cancel() - idleTask = nil - } - - func activeWorkWillBegin() { - epoch += 1 + private func activeWorkWillBegin() { + idleEpoch += 1 activeWorkCount += 1 idleTask?.cancel() idleTask = nil } - func activeWorkDidFinish(runIdleWork: @escaping IdleWorkRunner) { + private func activeWorkDidFinish() { activeWorkCount = max(0, activeWorkCount - 1) guard activeWorkCount == 0 else { return } - scheduleIdleCallback(runIdleWork: runIdleWork) + scheduleIdleCallback() } -} -private extension MessagesControllerIdleObservation { - func scheduleIdleCallback(runIdleWork: @escaping IdleWorkRunner) { - guard callback != nil else { return } + private func scheduleIdleCallback() { + guard idleCallback != nil else { return } - let expectedEpoch = epoch + let expectedEpoch = idleEpoch let idleDelay = idleDelay idleTask = Task { do { @@ -97,31 +88,35 @@ private extension MessagesControllerIdleObservation { return } - await runIdleWork { - guard await self.shouldRunIdleCallback(expectedEpoch: expectedEpoch) else { - return - } + let task = self.enqueueIdleCallback(expectedEpoch: expectedEpoch) + _ = try? await task.value + } + } + + private func enqueueIdleCallback(expectedEpoch: UInt) -> Task { + enqueue { + guard let callback = await self.idleCallbackToRun(expectedEpoch: expectedEpoch) else { + return + } - await self.invokeCallback() + await callback() - guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { - return - } - await self.scheduleIdleCallback(runIdleWork: runIdleWork) + guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { + return } + await self.scheduleIdleCallback() } } - func shouldRunIdleCallback(expectedEpoch: UInt) -> Bool { - activeWorkCount == 0 && epoch == expectedEpoch && callback != nil && idleTask?.isCancelled == false - } - - func invokeCallback() async { - await callback?() + private func idleCallbackToRun(expectedEpoch: UInt) -> IdleCallback? { + guard activeWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { + return nil + } + return idleCallback } - func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { - activeWorkCount == 0 && epoch == expectedEpoch && callback != nil + private func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { + activeWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil } } @@ -261,7 +256,7 @@ private extension MessagesControllerCoordinator { func dispose(_ entry: MessagesControllerEntry) async throws { Log.default.notice("[PlatformAPI] disposing MessagesController") try await PlatformAPI.onMessagesControllerQueue { - await PlatformAPI.setMessagesControllerIdleObservation(nil) + await PlatformAPI.setMessagesControllerIdleCallback(nil) entry.value.dispose() } } @@ -269,9 +264,8 @@ private extension MessagesControllerCoordinator { extension PlatformAPI { // IMessageHost is singleton-only within a process; PlatformAPI wrappers share - // one MessagesController and one async serializer for Messages.app automation. - private static let messagesControllerSerializer = MessagesControllerSerializer() - private static let messagesControllerIdleObservation = MessagesControllerIdleObservation(idleDelay: 1) + // one MessagesController and one async lane for Messages.app automation. + private static let messagesControllerAutomationLane = MessagesControllerAutomationLane(idleDelay: 1) fileprivate static let messagesControllerCoordinator = MessagesControllerCoordinator() func withMessagesController( @@ -293,25 +287,13 @@ extension PlatformAPI { static func onMessagesControllerQueue( _ action: @escaping @Sendable () async throws -> T ) async throws -> T { - await messagesControllerIdleObservation.activeWorkWillBegin() - do { - let result = try await messagesControllerSerializer.run(action) - await messagesControllerIdleObservation.activeWorkDidFinish(runIdleWork: runMessagesControllerIdleWork) - return result - } catch { - await messagesControllerIdleObservation.activeWorkDidFinish(runIdleWork: runMessagesControllerIdleWork) - throw error - } + try await messagesControllerAutomationLane.run(action) } - static func setMessagesControllerIdleObservation( + static func setMessagesControllerIdleCallback( _ callback: (@Sendable () async -> Void)? ) async { - await messagesControllerIdleObservation.setCallback(callback) - } - - private static let runMessagesControllerIdleWork: MessagesControllerIdleObservation.IdleWorkRunner = { action in - await messagesControllerSerializer.run(action) + await messagesControllerAutomationLane.setIdleCallback(callback) } static func makeMessagesController(reportErrorMessage: ReportErrorMessage?) async throws -> MessagesController { diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index 709f8473..a576536d 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -628,7 +628,7 @@ public final class PlatformAPI { } // reset the idle observer in case we fail and bail out - await Self.setMessagesControllerIdleObservation(nil) + await Self.setMessagesControllerIdleCallback(nil) let requestID = UUID() let threadObserveRequestToken = threadObserveRequestToken @@ -674,7 +674,7 @@ public final class PlatformAPI { guard threadObserveRequestToken.read() == requestID else { return } let observe = try controller.idleCallback(observingThreadID: threadID, statusSender: sendStatus) - await Self.setMessagesControllerIdleObservation { + await Self.setMessagesControllerIdleCallback { guard threadObserveRequestToken.read() == requestID else { return } do { try await observe() diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift index 55363499..4ea06514 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift @@ -38,7 +38,6 @@ final class EclipsingWindowCoordinator: WindowCoordinator { hideDebouncer = HideDebouncer(debouncingFor: Self.debouncingPeriod) } - @MainActor func makeAutomatable(_ messagesWindow: Accessibility.Element) async 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). @@ -125,12 +124,10 @@ final class EclipsingWindowCoordinator: WindowCoordinator { } } - @MainActor func automationDidComplete(_: Accessibility.Element) async throws { hideDebouncer.requestHide() } - @MainActor func reset(_ window: Accessibility.Element) async throws { hideDebouncer.immediatelyUnhide() @@ -148,12 +145,10 @@ final class EclipsingWindowCoordinator: WindowCoordinator { try window.setFrame(originalFrame) } - @MainActor func userManuallyActivated(_: NSRunningApplication) async throws { hideDebouncer.immediatelyUnhide() } - @MainActor func userManuallyDeactivated(_: NSRunningApplication) async throws { hideDebouncer.requestHide() } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift index 2d684d93..c99a4584 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift @@ -65,14 +65,12 @@ final class SpacesWindowCoordinator { extension SpacesWindowCoordinator: WindowCoordinator { var canReuseExtantInstance: Bool { true } - @MainActor func makeAutomatable(_ window: Accessibility.Element) async throws { guard app?.isActive == false else { return } lastKnownWindow = window try moveLastKnownWindowToHiddenSpace() } - @MainActor 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") @@ -82,12 +80,10 @@ extension SpacesWindowCoordinator: WindowCoordinator { try (window.window()).moveToSpace(currentSpace) } - @MainActor func automationDidComplete(_: Accessibility.Element) async throws { // after automating, keep the window on the hidden space } - @MainActor func userManuallyActivated(_: NSRunningApplication) async throws { lastManualActivation = Date() } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift index b20da429..2084564e 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift @@ -17,9 +17,11 @@ protocol WindowCoordinator: AnyObject { * * This is called right before the app needs to be automated. */ + @MainActor 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. */ + @MainActor func automationDidComplete(_ window: Accessibility.Element) async throws /** @@ -28,20 +30,25 @@ protocol WindowCoordinator: AnyObject { * For example, this is called when the user manually activates the app. Coordination should quiesce until the user * resigns manual control. */ + @MainActor 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) async throws /** Called when the user finishes manual control over the app. */ + @MainActor func userManuallyDeactivated(_ app: NSRunningApplication) async throws } extension WindowCoordinator { + @MainActor func userManuallyActivated(_: NSRunningApplication) async throws { // make this method optional } + @MainActor func userManuallyDeactivated(_: NSRunningApplication) async throws { // make this method optional } From 299dbba00684ece2d0983283f750b4be59b15a83 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 14:20:53 +0530 Subject: [PATCH 05/59] - --- .../Messages/MessagesController.swift | 6 +- .../PlatformAPI+MessagesController.swift | 37 ++++---- .../EclipsingWindowCoordinator.swift | 84 +++++++++---------- .../SpacesWindowCoordinator.swift | 8 +- .../WindowCoordinator.swift | 14 ++-- 5 files changed, 72 insertions(+), 77 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index ffda8257..701f3b03 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -477,7 +477,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) cancelReplyTranscriptViewTask = Task { [weak self] in do { try await Task.sleep(forTimeInterval: 1.5) - try await PlatformAPI.onMessagesControllerQueue { [weak self] in + try await PlatformAPI.runOnMessagesControllerLane { [weak self] in try Task.checkCancellation() guard let self else { return } try closeReplyTranscriptView(wait: false) @@ -1318,7 +1318,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // we want to actually show the app private func activateMessages() async { do { - try await PlatformAPI.onMessagesControllerQueue { [self] in + try await PlatformAPI.runOnMessagesControllerLane { [self] in lastActivate = Date() messagesIsManuallyActivated = true log.debug("activateMessages") @@ -1335,7 +1335,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func deactivateMessages() async { do { - try await PlatformAPI.onMessagesControllerQueue { [self] in + try await PlatformAPI.runOnMessagesControllerLane { [self] in lastActivate.map { log.debug("used messages.app for \($0.timeIntervalSinceNow * -1)s") } messagesIsManuallyActivated = false log.debug("deactivateMessages") diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 3846d4c9..880e1df6 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -16,7 +16,7 @@ private actor MessagesControllerAutomationLane { private let idleDelay: TimeInterval private var tail: Task? - private var activeWorkCount = 0 + private var queuedActiveWorkCount = 0 private var idleEpoch: UInt = 0 private var idleCallback: IdleCallback? private var idleTask: Task? @@ -28,17 +28,12 @@ private actor MessagesControllerAutomationLane { func run(_ action: @Sendable @escaping () async throws -> T) async throws -> T { activeWorkWillBegin() let task = enqueue(action) - do { - let result = try await withTaskCancellationHandler { - try await task.value - } onCancel: { - task.cancel() - } - activeWorkDidFinish() - return result - } catch { - activeWorkDidFinish() - throw error + defer { activeWorkDidFinish() } + + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() } } @@ -65,14 +60,14 @@ private actor MessagesControllerAutomationLane { private func activeWorkWillBegin() { idleEpoch += 1 - activeWorkCount += 1 + queuedActiveWorkCount += 1 idleTask?.cancel() idleTask = nil } private func activeWorkDidFinish() { - activeWorkCount = max(0, activeWorkCount - 1) - guard activeWorkCount == 0 else { return } + queuedActiveWorkCount = max(0, queuedActiveWorkCount - 1) + guard queuedActiveWorkCount == 0 else { return } scheduleIdleCallback() } @@ -109,14 +104,14 @@ private actor MessagesControllerAutomationLane { } private func idleCallbackToRun(expectedEpoch: UInt) -> IdleCallback? { - guard activeWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { + guard queuedActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { return nil } return idleCallback } private func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { - activeWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil + queuedActiveWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil } } @@ -139,7 +134,7 @@ private actor MessagesControllerCoordinator { 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") } @@ -255,7 +250,7 @@ private extension MessagesControllerCoordinator { func dispose(_ entry: MessagesControllerEntry) async throws { Log.default.notice("[PlatformAPI] disposing MessagesController") - try await PlatformAPI.onMessagesControllerQueue { + try await PlatformAPI.runOnMessagesControllerLane { await PlatformAPI.setMessagesControllerIdleCallback(nil) entry.value.dispose() } @@ -284,7 +279,7 @@ extension PlatformAPI { try await Self.messagesControllerCoordinator.disposeCachedController() } - static func onMessagesControllerQueue( + static func runOnMessagesControllerLane( _ action: @escaping @Sendable () async throws -> T ) async throws -> T { try await messagesControllerAutomationLane.run(action) @@ -297,7 +292,7 @@ extension PlatformAPI { } static func makeMessagesController(reportErrorMessage: ReportErrorMessage?) async throws -> MessagesController { - try await Self.onMessagesControllerQueue { + try await Self.runOnMessagesControllerLane { try await MessagesController(reportErrorMessage: { txt in platformMessagesControllerLog.error(" report to sentry: \(txt)") try? reportErrorMessage?(txt) diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift index 4ea06514..0bee4145 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift @@ -38,7 +38,7 @@ final class EclipsingWindowCoordinator: WindowCoordinator { hideDebouncer = HideDebouncer(debouncingFor: Self.debouncingPeriod) } - func makeAutomatable(_ messagesWindow: Accessibility.Element) async throws { + 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 { @@ -124,11 +124,11 @@ final class EclipsingWindowCoordinator: WindowCoordinator { } } - func automationDidComplete(_: Accessibility.Element) async throws { + func automationDidComplete(_: Accessibility.Element) throws { hideDebouncer.requestHide() } - func reset(_ window: Accessibility.Element) async throws { + func reset(_ window: Accessibility.Element) throws { hideDebouncer.immediatelyUnhide() guard let originalFrame = windowFramePreEclipse else { @@ -145,59 +145,30 @@ final class EclipsingWindowCoordinator: WindowCoordinator { try window.setFrame(originalFrame) } - func userManuallyActivated(_: NSRunningApplication) async throws { + func userManuallyActivated(_: NSRunningApplication) throws { hideDebouncer.immediatelyUnhide() } - func userManuallyDeactivated(_: NSRunningApplication) async throws { + func userManuallyDeactivated(_: NSRunningApplication) throws { hideDebouncer.requestHide() } } 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. + /// eagerly at construction (on the main actor, see `eclipsingAnchorWindow`), + /// so consumers only read plain values. struct AnchorWindow { /// 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 - - @MainActor - 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" - ) - } - - @MainActor - 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)) } @@ -215,8 +186,40 @@ private extension EclipsingWindowCoordinator { // Accurate as of macOS 15.3.2. static let messagesAppMinimumSize = NSSize(width: 660.0, height: 320.0) +} - @MainActor +@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" + ) + } + + 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))" + ) + } +} + +@MainActor +private extension EclipsingWindowCoordinator { static func eclipsingAnchorWindow(messagesPID: pid_t) throws -> AnchorWindow { if let window = NSApplication.shared.largestElectronWindow { return AnchorWindow.electron(window) @@ -231,7 +234,6 @@ private extension EclipsingWindowCoordinator { throw WindowCoordinatorError.generic(message: "Couldn't find an eclipsing anchor window") } - @MainActor static func screenFrame(for window: NSWindow) -> NSRect { if window.screen == nil { log.warning("can't determine which screen the Electron window is on; the eclipse position may be unexpected") @@ -246,7 +248,6 @@ private extension EclipsingWindowCoordinator { /// front (the frontmost app's topmost window, else the topmost window overall), /// but it can't guarantee z-order for a non-Beeper anchor, so the eclipse may /// not fully hide Messages. - @MainActor static func externalEclipsingAnchorWindow(messagesPID: pid_t) -> Window.Description? { let excludedPIDs: Set = [getpid(), messagesPID] let candidates = externalAnchorWindows(excludingPIDs: excludedPIDs) // front-to-back z-order @@ -276,7 +277,6 @@ private extension EclipsingWindowCoordinator { } } - @MainActor static func screen(containing screenFrame: NSRect) -> NSScreen? { // screenFrame is in screen/AX space (origin top-left); NSScreen frames are // Cocoa space (origin bottom-left), so flip the center point before testing. diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift index c99a4584..22039321 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift @@ -65,13 +65,13 @@ final class SpacesWindowCoordinator { extension SpacesWindowCoordinator: WindowCoordinator { var canReuseExtantInstance: Bool { true } - func makeAutomatable(_ window: Accessibility.Element) async throws { + func makeAutomatable(_ window: Accessibility.Element) throws { guard app?.isActive == false else { return } lastKnownWindow = window try moveLastKnownWindowToHiddenSpace() } - func reset(_ window: Accessibility.Element) async throws { + func reset(_ window: Accessibility.Element) 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 @@ -80,11 +80,11 @@ extension SpacesWindowCoordinator: WindowCoordinator { try (window.window()).moveToSpace(currentSpace) } - func automationDidComplete(_: Accessibility.Element) async throws { + func automationDidComplete(_: Accessibility.Element) throws { // after automating, keep the window on the hidden space } - func userManuallyActivated(_: NSRunningApplication) async throws { + func userManuallyActivated(_: NSRunningApplication) throws { lastManualActivation = Date() } } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift index 2084564e..e76f7ff4 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift @@ -18,11 +18,11 @@ protocol WindowCoordinator: AnyObject { * This is called right before the app needs to be automated. */ @MainActor - func makeAutomatable(_ window: Accessibility.Element) async throws + func makeAutomatable(_ window: Accessibility.Element) throws /** Signals to the coordinator that automation has completed; if desired, it may now e.g. hide the window. */ @MainActor - func automationDidComplete(_ window: Accessibility.Element) async throws + func automationDidComplete(_ window: Accessibility.Element) throws /** * Reverts the manipulations performed in `makeAutomatable`. @@ -31,25 +31,25 @@ protocol WindowCoordinator: AnyObject { * resigns manual control. */ @MainActor - func reset(_ window: Accessibility.Element) async throws + func reset(_ window: Accessibility.Element) throws /** Called when the user manually activates the app. `reset` is also called in this case. */ @MainActor - func userManuallyActivated(_ app: NSRunningApplication) async throws + func userManuallyActivated(_ app: NSRunningApplication) throws /** Called when the user finishes manual control over the app. */ @MainActor - func userManuallyDeactivated(_ app: NSRunningApplication) async throws + func userManuallyDeactivated(_ app: NSRunningApplication) throws } extension WindowCoordinator { @MainActor - func userManuallyActivated(_: NSRunningApplication) async throws { + func userManuallyActivated(_: NSRunningApplication) throws { // make this method optional } @MainActor - func userManuallyDeactivated(_: NSRunningApplication) async throws { + func userManuallyDeactivated(_: NSRunningApplication) throws { // make this method optional } } From 57d8a626fdd3c547bcbb08151aaf84499c31aa42 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 14:24:16 +0530 Subject: [PATCH 06/59] - --- .../Messages/MessagesController.swift | 20 +++++++------------ .../Sources/IMessage/PlatformAPI.swift | 2 +- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 701f3b03..deef48eb 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -441,13 +441,6 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } private func withAutomation(_ operation: () throws -> T) async throws -> T { - try await prepareForAutomation() - let result = Result(catching: operation) - await finishedAutomation() - return try result.get() - } - - private func prepareForAutomation() async throws { log.info("prepareForAutomation") cancelReplyTranscriptViewTask?.cancel() log.debug("prepareForAutomation: making the app automatable") @@ -455,11 +448,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { try await windowCoordinator.makeAutomatable(mainWindow) } - } - private func finishedAutomation() async { + let result = Result(catching: operation) + log.info("finishedAutomation") - // this isn't propagated to make finishedAutomation callable inside of defer { … } if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { do { try await windowCoordinator.automationDidComplete(mainWindow) @@ -469,6 +461,8 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } // 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 cancelReplyTranscriptViewTask: Task? @@ -1445,9 +1439,9 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) log.error("didn't observe a layout change within \(timeout)s, continuing anyways") } - /// Returns a callback that observes one thread while controller work is idle. - /// The platform-level idle observer calls this repeatedly after active automation drains. - func idleCallback(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> (() async throws -> Void) { + /// Returns an observer that checks one thread while controller work is idle. + /// The platform-level idle observer calls it repeatedly after active automation drains. + func makeIdleActivityObserver(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> (() async throws -> Void) { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() return { [weak self] in diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index a576536d..75bdce58 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -673,7 +673,7 @@ public final class PlatformAPI { guard threadObserveRequestToken.read() == requestID else { return } - let observe = try controller.idleCallback(observingThreadID: threadID, statusSender: sendStatus) + let observe = try controller.makeIdleActivityObserver(observingThreadID: threadID, statusSender: sendStatus) await Self.setMessagesControllerIdleCallback { guard threadObserveRequestToken.read() == requestID else { return } do { From 87e68321b6b313bdfd4f2190a45f000902424e9e Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 14:27:37 +0530 Subject: [PATCH 07/59] - --- .../PlatformAPI+MessagesController.swift | 29 +++++++------------ .../IMessage/SystemSettingsOnboarding.swift | 4 +-- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 880e1df6..9505e890 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -83,14 +83,14 @@ private actor MessagesControllerAutomationLane { return } - let task = self.enqueueIdleCallback(expectedEpoch: expectedEpoch) + let task = self.enqueuePassiveIdleCallback(expectedEpoch: expectedEpoch) _ = try? await task.value } } - private func enqueueIdleCallback(expectedEpoch: UInt) -> Task { + private func enqueuePassiveIdleCallback(expectedEpoch: UInt) -> Task { enqueue { - guard let callback = await self.idleCallbackToRun(expectedEpoch: expectedEpoch) else { + guard let callback = await self.idleCallbackIfStillCurrent(expectedEpoch: expectedEpoch) else { return } @@ -103,7 +103,7 @@ private actor MessagesControllerAutomationLane { } } - private func idleCallbackToRun(expectedEpoch: UInt) -> IdleCallback? { + private func idleCallbackIfStillCurrent(expectedEpoch: UInt) -> IdleCallback? { guard queuedActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { return nil } @@ -197,7 +197,13 @@ 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) + }) + } + return MessagesControllerEntry(controller) } pendingController = task return task @@ -235,11 +241,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 @@ -291,12 +292,4 @@ extension PlatformAPI { await messagesControllerAutomationLane.setIdleCallback(callback) } - static func makeMessagesController(reportErrorMessage: ReportErrorMessage?) async throws -> MessagesController { - try await Self.runOnMessagesControllerLane { - try await MessagesController(reportErrorMessage: { txt in - platformMessagesControllerLog.error(" report to sentry: \(txt)") - try? reportErrorMessage?(txt) - }) - } - } } diff --git a/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift b/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift index 541b4d96..b5943293 100644 --- a/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift +++ b/src/IMessage/Sources/IMessage/SystemSettingsOnboarding.swift @@ -1,8 +1,7 @@ +@MainActor public enum SystemSettingsOnboarding { - @MainActor static var onboardingManager: OnboardingManager? - @MainActor public static func start() { guard onboardingManager == nil else { return } let onboardingManager = OnboardingManager() @@ -10,7 +9,6 @@ public enum SystemSettingsOnboarding { onboardingManager.createWindow() } - @MainActor public static func stop() { onboardingManager?.closeWindow() onboardingManager = nil From fb8607d3462f331d90f11775f62c134faa675ac8 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 14:57:06 +0530 Subject: [PATCH 08/59] - --- src/IMessage/Sources/IMessageCLI/IMessageCLI.swift | 1 + 1 file changed, 1 insertion(+) 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: From 46e6f05c48b054a4114d65a12844a2bd66b50a2a Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 15:28:16 +0530 Subject: [PATCH 09/59] - --- .../Sources/IMessageNode/IMessageNodeExports.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 [ From ea4da6836b438af392cd004d795f3be2ca4665e5 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 15:44:44 +0530 Subject: [PATCH 10/59] - --- .../Messages/MessagesController.swift | 8 +- .../PlatformAPI+MessagesController.swift | 17 +- .../EclipsingWindowCoordinator.swift | 41 +++-- .../SpacesWindowCoordinator.swift | 6 +- .../WindowCoordinator.swift | 13 +- ...essagesControllerAutomationLaneTests.swift | 158 ++++++++++++++++++ 6 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index deef48eb..ea4015d5 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -364,9 +364,12 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 { @@ -380,6 +383,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } for await event in observer.events.subscribe() { + guard let self else { return } func printLifecycle(event: String) { lifecycleLog.info("@@ AX: \(event) [\(debuggingStatus())]") } diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 9505e890..3c44b6e4 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -11,9 +11,15 @@ private enum MessagesControllerCoordinatorError: Error { case pendingControllerInvalidated } -private actor MessagesControllerAutomationLane { +// Internal (not `private`) so `@testable import IMessage` can exercise it. +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 assertion in `run` catches it. + @TaskLocal private static var isExecutingOnLane = false + private let idleDelay: TimeInterval private var tail: Task? private var queuedActiveWorkCount = 0 @@ -26,6 +32,11 @@ private actor MessagesControllerAutomationLane { } func run(_ action: @Sendable @escaping () async throws -> T) async throws -> T { + assert( + !Self.isExecutingOnLane, + "re-entrant runOnMessagesControllerLane would deadlock the serial lane: " + + "a lane action must not call back into the lane. Run the work inline instead." + ) activeWorkWillBegin() let task = enqueue(action) defer { activeWorkDidFinish() } @@ -49,7 +60,9 @@ private actor MessagesControllerAutomationLane { let task = Task { await previous?.value try Task.checkCancellation() - return try await action() + return try await Self.$isExecutingOnLane.withValue(true) { + try await action() + } } tail = Task { diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift index 0bee4145..81b6ca7d 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift @@ -38,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 { @@ -76,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!") @@ -106,20 +111,21 @@ 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)) + } } } } @@ -128,8 +134,8 @@ final class EclipsingWindowCoordinator: WindowCoordinator { 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") @@ -157,8 +163,9 @@ final class EclipsingWindowCoordinator: WindowCoordinator { private extension EclipsingWindowCoordinator { /// A fully-resolved eclipse anchor. All AppKit-derived geometry is captured /// eagerly at construction (on the main actor, see `eclipsingAnchorWindow`), - /// so consumers only read plain values. - struct AnchorWindow { + /// 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. diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift index 22039321..7edb1916 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift @@ -65,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 diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift index e76f7ff4..0d785981 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift @@ -16,9 +16,13 @@ protocol WindowCoordinator: AnyObject { * 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. */ - @MainActor - 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. */ @MainActor @@ -29,9 +33,10 @@ protocol WindowCoordinator: AnyObject { * * 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`. */ - @MainActor - 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 diff --git a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift new file mode 100644 index 00000000..3e7a95ab --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift @@ -0,0 +1,158 @@ +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 {} + +/// Poll until `predicate` holds or `timeout` elapses. Used instead of fixed +/// sleeps so the idle-callback tests aren't flaky under load. +private func eventually(timeout: TimeInterval = 2, _ predicate: @Sendable () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if predicate() { return true } + try? await Task.sleep(nanoseconds: 5_000_000) // 5ms + } + return predicate() +} + +@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.02) + let idleCount = Protected(0) + await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } + + try await lane.run {} + + #expect(await eventually { idleCount.read() >= 1 }) +} + +@Test func laneIdleRepeatsWhileQuiet() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 0.02) + let idleCount = Protected(0) + await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } + + try await lane.run {} + + #expect(await eventually { idleCount.read() >= 2 }) +} + +@Test func laneIdleDoesNotFireWhileWorkInFlight() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 0.02) + let idleFiredDuringWork = Protected(false) + let workRunning = Protected(false) + await lane.setIdleCallback { + if workRunning.read() { idleFiredDuringWork.withLock { $0 = true } } + } + + // work runs far longer than idleDelay; the idle callback must not interleave + try await lane.run { + workRunning.withLock { $0 = true } + try await Task.sleep(nanoseconds: 80_000_000) // 80ms >> 20ms idleDelay + workRunning.withLock { $0 = false } + } + + #expect(idleFiredDuringWork.read() == false) +} + +@Test func laneClearingIdleCallbackStopsFurtherIdleWork() async throws { + let lane = MessagesControllerAutomationLane(idleDelay: 0.02) + let idleCount = Protected(0) + await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } + + try await lane.run {} + _ = await eventually { idleCount.read() >= 1 } + + await lane.setIdleCallback(nil) + let countAtClear = idleCount.read() + + // several idle periods elapse; nothing more should fire + try? await Task.sleep(nanoseconds: 120_000_000) // 120ms + #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 { 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) +} From c608f7dbdc948923c7d3d77bf14cf47aa24f1763 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 16:43:53 +0530 Subject: [PATCH 11/59] - --- .../Messages/MessagesAppElements.swift | 2 +- .../Messages/MessagesController.swift | 204 ++++++++++-------- .../Utilities/NSWorkspace+AsyncOpen.swift | 64 ++++++ 3 files changed, 184 insertions(+), 86 deletions(-) create mode 100644 src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index d8a67eb1..03ca9c23 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -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 (URL) throws -> Void = { try MessagesController.requestDeepLinkOpen($0) }) { self.runningApp = runningApp self.openDeepLink = openDeepLink app = Accessibility.Element(pid: runningApp.processIdentifier) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index ea4015d5..c032fd16 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -117,10 +117,13 @@ final class MessagesController { _ url: URL, activating: Bool = false, hiding: Bool = true, - targeting app: NSRunningApplication? = nil - ) throws -> NSRunningApplication { + targeting app: NSRunningApplication? = nil, + timeout: TimeInterval = 5 + ) async throws -> NSRunningApplication { let shouldHide = hiding && Defaults.shouldCoordinateWindow + logDeepLinkOpen(url, activating: activating, hiding: shouldHide) if Preferences.useSecondaryMessagesInstance, let app { + try Task.checkCancellation() try MessagesInstanceTarget.sendDeepLink(url, to: app) if activating { app.activate() @@ -128,6 +131,7 @@ final class MessagesController { if shouldHide { app.hide() } + try Task.checkCancellation() return app } @@ -135,35 +139,51 @@ final class MessagesController { openOptions.activates = activating openOptions.hides = shouldHide - 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))") - } + return try await NSWorkspace.shared.open(url, configuration: openOptions, timeout: timeout) + } - if let error { - result = .failure(error) - } else { - result = .success(running!) + static func requestDeepLinkOpen( + _ url: URL, + activating: Bool = false, + hiding: Bool = true, + targeting app: NSRunningApplication? = nil + ) throws { + let shouldHide = hiding && Defaults.shouldCoordinateWindow + logDeepLinkOpen(url, activating: activating, hiding: shouldHide) + if Preferences.useSecondaryMessagesInstance, let app { + try MessagesInstanceTarget.sendDeepLink(url, to: app) + if activating { + app.activate() } - horribleWaiter.signal() + if shouldHide { + app.hide() + } + return } - horribleWaiter.wait() - return try result!.get() + let openOptions = NSWorkspace.OpenConfiguration() + openOptions.activates = activating + openOptions.hides = shouldHide + + NSWorkspace.shared.open(url, configuration: openOptions) + } + + 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(_ url: URL, activating: Bool = false, hiding: Bool = true) async throws -> NSRunningApplication { + try await Self.openDeepLink(url, activating: activating, hiding: hiding, targeting: app) } func isSameContact(_ a: String?, _ b: String?) -> Bool { @@ -257,9 +277,9 @@ final class MessagesController { } } - private func openThread(_ threadID: String) throws { + private func openThread(_ threadID: String) async throws { try? self.clearTypingStatus() - try openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil).url()) + try await openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil).url()) try assertSelectedThread(threadID: threadID) } @@ -272,13 +292,13 @@ final class MessagesController { let coordinator = try getBestWindowCoordinator() windowCoordinator = coordinator - let launchMessages = { [coordinator] (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 !coordinator.canReuseExtantInstance && Defaults.shouldCoordinateWindow { - Thread.sleep(forTimeInterval: 0.1) + try await Task.sleep(forTimeInterval: 0.1) } log.info("launching messages... (without activation? \(withoutActivation))") - return try Self.openDeepLink(MessagesDeepLink.compose.url(), activating: !withoutActivation) + return try await Self.openDeepLink(MessagesDeepLink.compose.url(), activating: !withoutActivation) } if Preferences.useSecondaryMessagesInstance { @@ -305,10 +325,10 @@ 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) } } @@ -321,7 +341,7 @@ final class MessagesController { try selectedApp.waitForLaunch() elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in - try Self.openDeepLink(url, targeting: selectedApp) + try Self.requestDeepLinkOpen(url, targeting: selectedApp) }) keyPresser = KeyPresser(pid: selectedApp.processIdentifier) @@ -444,7 +464,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) !app.isTerminated && (try? elements.mainWindow.isFrameValid) != nil && isMessagesAppResponsive } - private func withAutomation(_ operation: () throws -> T) async throws -> T { + private func withAutomation(_ operation: () async throws -> T) async throws -> T { log.info("prepareForAutomation") cancelReplyTranscriptViewTask?.cancel() log.debug("prepareForAutomation: making the app automatable") @@ -453,7 +473,12 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await windowCoordinator.makeAutomatable(mainWindow) } - let result = Result(catching: operation) + let result: Result + do { + result = .success(try await operation()) + } catch { + result = .failure(error) + } log.info("finishedAutomation") if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { @@ -542,8 +567,8 @@ 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) } @@ -578,7 +603,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") } @@ -590,7 +615,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if selectedCell.isInViewport { return selectedCell } try selectNextThreadAndScroll() - try openThread(threadID) + try await openThread(threadID) let selectedCellAfterScroll = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if selectedCellAfterScroll.isInViewport { return selectedCellAfterScroll } @@ -600,28 +625,28 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // performs `perform` while the Messages window is unhidden private func withActivation( openBefore: URL?, openAfter: URL? = nil, - perform: () throws -> Void - ) throws { + perform: () async throws -> Void + ) async throws { if let openBefore { #if DEBUG log.debug("withActivation: opening before performing: \(openBefore)") #endif - try openDeepLink(openBefore) + try await openDeepLink(openBefore) } - try perform() + try await perform() if let openAfter { if openAfter != openBefore { #if DEBUG debugLog("withActivation: opening after performing: \(openAfter)") #endif - try openDeepLink(openAfter) + try await openDeepLink(openAfter) } } } - 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(guid: messageCell.messageGUID, overlay: messageCell.overlay).url() @@ -631,7 +656,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try? closeReplyTranscriptView(wait: false) } - try withActivation(openBefore: url) { + try await withActivation(openBefore: url) { try assertSelectedThread(threadID: threadID) // we don't close transcript view here because when reacting, closing it will undo the reaction @@ -691,7 +716,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) throw ErrorMessage("Cell id mismatch") } } - try action(targetCell) + try await action(targetCell) } } @@ -732,7 +757,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) defer { log.debug("setReaction took \(startTime.timeIntervalSinceNow * -1000)ms") } try await withAutomation { - try withMessageCell(threadID: threadID, messageCell: messageCell) { + try await withMessageCell(threadID: threadID, messageCell: messageCell) { if let directAction = try directReactionAction(messageCell: $0, reaction: reaction) { try directAction() return @@ -752,18 +777,18 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try elements.characterPickerSearchField } try searchField.value(assign: search.query) - Thread.sleep(forTimeInterval: 0.75) // wait for search + try await Task.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.. NSRunningApplication { + typealias OpenContinuation = CheckedContinuation + let state = Protected<(continuation: OpenContinuation?, completed: Bool)>((nil, false)) + + let installContinuation: @Sendable (OpenContinuation) -> Bool = { continuation in + state.withLock { state in + guard !state.completed else { + return false + } + state.continuation = continuation + return true + } + } + + let finish: @Sendable (Result) -> Void = { result in + let continuation = state.withLock { state -> OpenContinuation? in + guard !state.completed else { + return nil + } + state.completed = true + defer { state.continuation = nil } + return state.continuation + } + continuation?.resume(with: result) + } + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: OpenContinuation) in + guard installContinuation(continuation) else { + continuation.resume(throwing: CancellationError()) + return + } + + let timeoutTask = Task { + try? await Task.sleep(forTimeInterval: timeout) + guard !Task.isCancelled else { return } + finish(.failure(ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s"))) + } + + open(url, configuration: configuration) { running, error in + timeoutTask.cancel() + if let error { + finish(.failure(error)) + } else if let running { + finish(.success(running)) + } else { + finish(.failure(ErrorMessage("LaunchServices completed without returning an app"))) + } + } + } + } onCancel: { + finish(.failure(CancellationError())) + } + } +} From 9f6ffee5f958bed92a926784067f7bfed1c15951 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 16:45:04 +0530 Subject: [PATCH 12/59] - --- .../Sources/IMessage/Messages/MessagesController.swift | 7 +------ src/IMessage/Sources/IMessageCore/Result+Async.swift | 9 +++++++++ 2 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 src/IMessage/Sources/IMessageCore/Result+Async.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index c032fd16..aad29b8e 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -473,12 +473,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await windowCoordinator.makeAutomatable(mainWindow) } - let result: Result - do { - result = .success(try await operation()) - } catch { - result = .failure(error) - } + let result = await Result(catching: operation) log.info("finishedAutomation") if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { 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) + } + } +} From e3b348c6647b0f45d24ec324c354b2969bfd10f9 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 16:48:24 +0530 Subject: [PATCH 13/59] Update NSWorkspace+AsyncOpen.swift --- .../Utilities/NSWorkspace+AsyncOpen.swift | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift index 0183f794..fd288def 100644 --- a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift +++ b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift @@ -11,17 +11,7 @@ extension NSWorkspace { typealias OpenContinuation = CheckedContinuation let state = Protected<(continuation: OpenContinuation?, completed: Bool)>((nil, false)) - let installContinuation: @Sendable (OpenContinuation) -> Bool = { continuation in - state.withLock { state in - guard !state.completed else { - return false - } - state.continuation = continuation - return true - } - } - - let finish: @Sendable (Result) -> Void = { result in + let finish: @Sendable (@Sendable () -> Result) -> Void = { makeResult in let continuation = state.withLock { state -> OpenContinuation? in guard !state.completed else { return nil @@ -30,12 +20,18 @@ extension NSWorkspace { defer { state.continuation = nil } return state.continuation } - continuation?.resume(with: result) + continuation?.resume(with: makeResult()) } return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: OpenContinuation) in - guard installContinuation(continuation) else { + guard state.withLock({ + guard !$0.completed else { + return false + } + $0.continuation = continuation + return true + }) else { continuation.resume(throwing: CancellationError()) return } @@ -43,22 +39,22 @@ extension NSWorkspace { let timeoutTask = Task { try? await Task.sleep(forTimeInterval: timeout) guard !Task.isCancelled else { return } - finish(.failure(ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s"))) + finish { .failure(ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s")) } } open(url, configuration: configuration) { running, error in timeoutTask.cancel() if let error { - finish(.failure(error)) + finish { .failure(error) } } else if let running { - finish(.success(running)) + finish { .success(running) } } else { - finish(.failure(ErrorMessage("LaunchServices completed without returning an app"))) + finish { .failure(ErrorMessage("LaunchServices completed without returning an app")) } } } } } onCancel: { - finish(.failure(CancellationError())) + finish { .failure(CancellationError()) } } } } From 339e1c5b39604dcfe8f669062be7036c18654ed0 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <216979840+indent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 11:22:10 +0000 Subject: [PATCH 14/59] toggleThreadRead: run restorePinsIfNecessary if the initial pin action throws too, so a partial pin state can still be cleaned up. Generated with [Indent](https://indent.com) Co-Authored-By: KishanBagaria --- .../Sources/IMessage/Messages/MessagesController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index aad29b8e..15480c18 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -981,10 +981,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) reportErrorMessage?("couldn't restore pins \(Defaults.pinnedThreadsCount() ?? -1) != \(pinnedCount)") } } - 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 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) } From a8f6134c20af1899cff0b3fc8f8e93a6ea000083 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 17:09:21 +0530 Subject: [PATCH 15/59] - --- .../Messages/MessagesController.swift | 68 +++++++++++-------- .../Sources/IMessage/OnboardingManager.swift | 4 +- .../Utilities/NSWorkspace+AsyncOpen.swift | 14 ++-- 3 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 15480c18..2702f3ff 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -112,18 +112,22 @@ 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, - timeout: TimeInterval = 5 - ) async 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 Task.checkCancellation() try MessagesInstanceTarget.sendDeepLink(url, to: app) if activating { app.activate() @@ -131,15 +135,31 @@ final class MessagesController { if shouldHide { app.hide() } - try Task.checkCancellation() - return app + return .handledBySecondaryInstance(app) } let openOptions = NSWorkspace.OpenConfiguration() openOptions.activates = activating openOptions.hides = shouldHide + return .open(openOptions) + } - return try await NSWorkspace.shared.open(url, configuration: openOptions, timeout: timeout) + @discardableResult + static func openDeepLink( + _ url: URL, + activating: Bool = false, + hiding: Bool = true, + targeting app: NSRunningApplication? = nil, + timeout: TimeInterval = 5 + ) async throws -> NSRunningApplication { + try Task.checkCancellation() + switch try planDeepLinkOpen(url, activating: activating, hiding: hiding, targeting: app) { + case .handledBySecondaryInstance(let app): + try Task.checkCancellation() + return app + case .open(let openOptions): + return try await NSWorkspace.shared.open(url, configuration: openOptions, timeout: timeout) + } } static func requestDeepLinkOpen( @@ -148,24 +168,12 @@ final class MessagesController { hiding: Bool = true, targeting app: NSRunningApplication? = nil ) throws { - let shouldHide = hiding && Defaults.shouldCoordinateWindow - logDeepLinkOpen(url, activating: activating, hiding: shouldHide) - if Preferences.useSecondaryMessagesInstance, let app { - try MessagesInstanceTarget.sendDeepLink(url, to: app) - if activating { - app.activate() - } - if shouldHide { - app.hide() - } + switch try planDeepLinkOpen(url, activating: activating, hiding: hiding, targeting: app) { + case .handledBySecondaryInstance: return + case .open(let openOptions): + NSWorkspace.shared.open(url, configuration: openOptions) } - - let openOptions = NSWorkspace.OpenConfiguration() - openOptions.activates = activating - openOptions.hides = shouldHide - - NSWorkspace.shared.open(url, configuration: openOptions) } private static func logDeepLinkOpen(_ url: URL, activating: Bool, hiding: Bool) { @@ -465,9 +473,9 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } private func withAutomation(_ operation: () async throws -> T) async throws -> T { - log.info("prepareForAutomation") + log.info("withAutomation: preparing") cancelReplyTranscriptViewTask?.cancel() - log.debug("prepareForAutomation: making the app automatable") + log.debug("withAutomation: making the app automatable") if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { try await windowCoordinator.makeAutomatable(mainWindow) @@ -475,7 +483,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let result = await Result(catching: operation) - log.info("finishedAutomation") + log.info("withAutomation: finished") if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { do { try await windowCoordinator.automationDidComplete(mainWindow) diff --git a/src/IMessage/Sources/IMessage/OnboardingManager.swift b/src/IMessage/Sources/IMessage/OnboardingManager.swift index 6f2a52ec..d4dacdeb 100644 --- a/src/IMessage/Sources/IMessage/OnboardingManager.swift +++ b/src/IMessage/Sources/IMessage/OnboardingManager.swift @@ -62,8 +62,10 @@ final class OnboardingManager { } func createWindow() { + // 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 - Task { @MainActor [weak self] in + MainActor.assumeIsolated { guard let self else { return } guard let bounds = Self.getPrefsWindowBounds() else { self.onboardingWindow?.setIsVisible(false) diff --git a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift index fd288def..f4712693 100644 --- a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift +++ b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift @@ -11,7 +11,7 @@ extension NSWorkspace { typealias OpenContinuation = CheckedContinuation let state = Protected<(continuation: OpenContinuation?, completed: Bool)>((nil, false)) - let finish: @Sendable (@Sendable () -> Result) -> Void = { makeResult in + let finish: @Sendable (Result) -> Void = { result in let continuation = state.withLock { state -> OpenContinuation? in guard !state.completed else { return nil @@ -20,7 +20,7 @@ extension NSWorkspace { defer { state.continuation = nil } return state.continuation } - continuation?.resume(with: makeResult()) + continuation?.resume(with: result) } return try await withTaskCancellationHandler { @@ -39,22 +39,22 @@ extension NSWorkspace { let timeoutTask = Task { try? await Task.sleep(forTimeInterval: timeout) guard !Task.isCancelled else { return } - finish { .failure(ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s")) } + finish(.failure(ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s"))) } open(url, configuration: configuration) { running, error in timeoutTask.cancel() if let error { - finish { .failure(error) } + finish(.failure(error)) } else if let running { - finish { .success(running) } + finish(.success(running)) } else { - finish { .failure(ErrorMessage("LaunchServices completed without returning an app")) } + finish(.failure(ErrorMessage("LaunchServices completed without returning an app"))) } } } } onCancel: { - finish { .failure(CancellationError()) } + finish(.failure(CancellationError())) } } } From fa711753d88a405bfbe23f2f0cbbb8de7f1066de Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 19:03:38 +0530 Subject: [PATCH 16/59] Update MacPermissions.swift --- src/IMessage/Sources/IMessage/MacPermissions.swift | 1 + 1 file changed, 1 insertion(+) 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() } From 7d149e81d41c1aaa3202283d9f625fc039de4e10 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <216979840+indent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 13:35:33 +0000 Subject: [PATCH 17/59] MessagesAppElements: sleep 0.5s after fire-and-forget deep link request so the _mainWindowReally retry path doesn't iterate before LaunchServices has processed the open. Generated with [Indent](https://indent.com) Co-Authored-By: KishanBagaria --- .../Sources/IMessage/Messages/MessagesAppElements.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 03ca9c23..9cdb3aee 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -145,7 +145,14 @@ final class MessagesAppElements { private var cachedMainWindow: Accessibility.Element? - init(runningApp: NSRunningApplication, openDeepLink: @escaping (URL) throws -> Void = { try MessagesController.requestDeepLinkOpen($0) }) { + init(runningApp: NSRunningApplication, openDeepLink: @escaping (URL) throws -> Void = { url in + try MessagesController.requestDeepLinkOpen(url) + // `requestDeepLinkOpen` is fire-and-forget; give LaunchServices a moment to + // process the open so the next `getMainWindow()` attempt has a chance to see + // the new window. Without this, the `_mainWindowReally` retry can iterate + // (or exit) before the deep link actually takes effect. + Thread.sleep(forTimeInterval: 0.5) + }) { self.runningApp = runningApp self.openDeepLink = openDeepLink app = Accessibility.Element(pid: runningApp.processIdentifier) From 498e4fb840fab2987c4ab29b13774f6645f2e175 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 19:23:32 +0530 Subject: [PATCH 18/59] - --- .../Sources/IMessage/KeyPresser.swift | 45 ++++++++-------- .../IMessageTests/KeyPresserTests.swift | 54 +++++++++++++++++++ 2 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 src/IMessage/Sources/IMessageTests/KeyPresserTests.swift diff --git a/src/IMessage/Sources/IMessage/KeyPresser.swift b/src/IMessage/Sources/IMessage/KeyPresser.swift index 2141b131..39efeb30 100644 --- a/src/IMessage/Sources/IMessage/KeyPresser.swift +++ b/src/IMessage/Sources/IMessage/KeyPresser.swift @@ -6,12 +6,15 @@ import Logging private let log = Logger(imessageLabel: "key-presser") -// TODO: refactor class KeyPresser { let pid: pid_t + private let postKeyEvents: (CGKeyCode, CGEventFlags?) throws -> Void - init(pid: pid_t) { + init(pid: pid_t, postKeyEvents: ((CGKeyCode, CGEventFlags?) throws -> Void)? = nil) { self.pid = pid + self.postKeyEvents = postKeyEvents ?? { key, flags in + try Self.post(key: key, flags: flags, to: pid) + } } static let src = CGEventSource(stateID: .hidSystemState) @@ -28,7 +31,7 @@ class KeyPresser { 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 +39,75 @@ 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 { + private func pressMappedKey(_ key: Character, flags: CGEventFlags? = nil, onMainThread: Bool) throws { try perform(onMainThread: onMainThread) { guard let keyCode = KeyMap.shared[key] else { return } - try post(key: CGKeyCode(keyCode), flags: flags) + try postKeyEvents(CGKeyCode(keyCode), flags) } } - 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/IMessageTests/KeyPresserTests.swift b/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift new file mode 100644 index 00000000..7e784357 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift @@ -0,0 +1,54 @@ +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) +} From af2a75ff421f03edc9959dd739f771bc9949f66d Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 19:40:47 +0530 Subject: [PATCH 19/59] Update MessagesController.swift --- .../Sources/IMessage/Messages/MessagesController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 2702f3ff..025baa1d 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -500,7 +500,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private var cancelReplyTranscriptViewTask: Task? private func scheduleCancelReplyTranscriptView() { - cancelReplyTranscriptViewTask = Task { [weak self] in + // 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 From 39d5074da5217a911d6050d2067e0f3227bb194d Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 19:46:03 +0530 Subject: [PATCH 20/59] Update MessagesController.swift --- .../Messages/MessagesController.swift | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 025baa1d..e5ff8374 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1350,21 +1350,28 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - 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() async { do { - try await PlatformAPI.runOnMessagesControllerLane { [self] in - 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 await windowCoordinator.reset(window) - try await windowCoordinator.userManuallyActivated(app) - } + manualActivationState.withLock { + $0.lastActivate = Date() + $0.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 await windowCoordinator.reset(window) + try await windowCoordinator.userManuallyActivated(app) } } catch { log.error("couldn't unhide messages window caused by user activation: \(error)") @@ -1373,19 +1380,21 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func deactivateMessages() async { do { - try await PlatformAPI.runOnMessagesControllerLane { [self] in - 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 await windowCoordinator.userManuallyDeactivated(app) - } - try? closeAllNonMainWindows() - if window != nil { - resetWindow() - } + 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") + // we use getMainWindow() instead of mainWindow to not reopen the window if it's not present + let window = elements.getMainWindow() + if Defaults.shouldCoordinateWindow { + try await windowCoordinator.userManuallyDeactivated(app) + } + try? closeAllNonMainWindows() + if window != nil { + resetWindow() } } catch { log.error("couldn't hide messages window caused by user activation: \(error)") From fc70b8c7714afdcb0a1be52fd30aab2c366b8975 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 19:58:43 +0530 Subject: [PATCH 21/59] - --- .../IMessageTests/DatabaseTickWaitTests.swift | 11 ----------- .../Sources/IMessageTests/Eventually.swift | 18 ++++++++++++++++++ ...essagesControllerAutomationLaneTests.swift | 19 ++++--------------- 3 files changed, 22 insertions(+), 26 deletions(-) create mode 100644 src/IMessage/Sources/IMessageTests/Eventually.swift 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/MessagesControllerAutomationLaneTests.swift b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift index 3e7a95ab..a52e3578 100644 --- a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift +++ b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift @@ -11,17 +11,6 @@ import Testing private struct Boom: Error {} -/// Poll until `predicate` holds or `timeout` elapses. Used instead of fixed -/// sleeps so the idle-callback tests aren't flaky under load. -private func eventually(timeout: TimeInterval = 2, _ predicate: @Sendable () -> Bool) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if predicate() { return true } - try? await Task.sleep(nanoseconds: 5_000_000) // 5ms - } - return predicate() -} - @Test func laneSerializesConcurrentWork() async throws { // idleDelay is irrelevant here; keep it long so idle work doesn't interfere. let lane = MessagesControllerAutomationLane(idleDelay: 10) @@ -66,7 +55,7 @@ private func eventually(timeout: TimeInterval = 2, _ predicate: @Sendable () -> try await lane.run {} - #expect(await eventually { idleCount.read() >= 1 }) + #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 1 }) } @Test func laneIdleRepeatsWhileQuiet() async throws { @@ -76,7 +65,7 @@ private func eventually(timeout: TimeInterval = 2, _ predicate: @Sendable () -> try await lane.run {} - #expect(await eventually { idleCount.read() >= 2 }) + #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 2 }) } @Test func laneIdleDoesNotFireWhileWorkInFlight() async throws { @@ -103,7 +92,7 @@ private func eventually(timeout: TimeInterval = 2, _ predicate: @Sendable () -> await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } try await lane.run {} - _ = await eventually { idleCount.read() >= 1 } + _ = await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 1 } await lane.setIdleCallback(nil) let countAtClear = idleCount.read() @@ -125,7 +114,7 @@ private func eventually(timeout: TimeInterval = 2, _ predicate: @Sendable () -> } } - #expect(await eventually { firstStarted.read() }) + #expect(await eventually(timeout: 2, pollInterval: 0.005) { firstStarted.read() }) first.cancel() // a queued action behind the cancelled one must still execute From f0e7c4ddb980d0ba50a8568b98aa1467b1234706 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 21:00:41 +0530 Subject: [PATCH 22/59] - --- .../Sources/IMessage/IMessageHost.swift | 2 +- .../Messages/MessagesAppElements.swift | 9 +- .../Messages/MessagesController.swift | 191 +++++++++--------- .../Sources/IMessage/Pasteboard+Backup.swift | 12 ++ .../Sources/IMessage/PromptAutomation.swift | 6 +- src/IMessage/Sources/IMessageCore/Retry.swift | 33 +++ 6 files changed, 150 insertions(+), 103 deletions(-) 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/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 9cdb3aee..8933b1c9 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -145,14 +145,7 @@ final class MessagesAppElements { private var cachedMainWindow: Accessibility.Element? - init(runningApp: NSRunningApplication, openDeepLink: @escaping (URL) throws -> Void = { url in - try MessagesController.requestDeepLinkOpen(url) - // `requestDeepLinkOpen` is fire-and-forget; give LaunchServices a moment to - // process the open so the next `getMainWindow()` attempt has a chance to see - // the new window. Without this, the `_mainWindowReally` retry can iterate - // (or exit) before the deep link actually takes effect. - Thread.sleep(forTimeInterval: 0.5) - }) { + init(runningApp: NSRunningApplication, openDeepLink: @escaping (URL) throws -> Void) { self.runningApp = runningApp self.openDeepLink = openDeepLink app = Accessibility.Element(pid: runningApp.processIdentifier) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index e5ff8374..e8418d03 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -97,7 +97,7 @@ final class MessagesController { private func resetWindow() { try? elements.searchField.cancel() try? expandSplitter() - try? closeReplyTranscriptView(wait: false) + try? closeReplyTranscriptView() } private static func terminateApp(_ app: NSRunningApplication) throws { @@ -210,7 +210,7 @@ final class MessagesController { } // 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)") @@ -240,7 +240,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) @@ -270,7 +270,7 @@ 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 @@ -288,7 +288,7 @@ final class MessagesController { private func openThread(_ threadID: String) async throws { try? self.clearTypingStatus() try await openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil).url()) - try assertSelectedThread(threadID: threadID) + try await assertSelectedThread(threadID: threadID) } init(reportErrorMessage: @escaping (_ txt: String) -> Void) async throws { @@ -509,7 +509,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await PlatformAPI.runOnMessagesControllerLane { [weak self] in try Task.checkCancellation() guard let self else { return } - try closeReplyTranscriptView(wait: false) + try closeReplyTranscriptView() } } catch is CancellationError { return @@ -540,22 +540,22 @@ 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 await openReactionPicker(messageCell: messageCell) try elements.addCustomEmojiReactionButton.press() } @@ -578,11 +578,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try triggerThreadCellAction(threadCell: threadCell, action: action) } - private func selectNextThreadAndScroll() throws { + private func selectNextThreadAndScroll() async throws { let selectedThreadCell = 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 + try await retry(withTimeout: 0.5, interval: 0.05) { // wait for hotkey to switch threads let nextThreadCell = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if let selectedThreadCell, nextThreadCell == selectedThreadCell { throw ErrorMessage("diff thread not selected") @@ -620,7 +620,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let selectedCell = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if selectedCell.isInViewport { return selectedCell } - try selectNextThreadAndScroll() + try await selectNextThreadAndScroll() try await openThread(threadID) let selectedCellAfterScroll = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) @@ -659,23 +659,23 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // without closing reply transcript, non-overlay deep link won't select the message if !messageCell.overlay { - try? closeReplyTranscriptView(wait: false) + try? closeReplyTranscriptView() } try await withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + 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 selected = (try await 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) @@ -771,7 +771,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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) + try await openCustomEmojiReactionPicker(messageCell: $0) // TODO: support being able to pick a skin tone let search: CharacterPickerSearch do { @@ -779,7 +779,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } catch { throw ErrorMessage("Can't react with \"\(emoji)\": \(String(describing: error))") } - let searchField = try retry(withTimeout: 1.0, interval: 0.05) { + let searchField = try await retry(withTimeout: 1.0, interval: 0.05) { try elements.characterPickerSearchField } try searchField.value(assign: search.query) @@ -800,7 +800,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) return } - try openReactionPicker(messageCell: $0) + try await openReactionPicker(messageCell: $0) let btn = try { if isSequoiaOrUp { @@ -822,7 +822,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) return buttons[idx] }() - try retry(withTimeout: 1.2, interval: 0.1) { + try await retry(withTimeout: 1.2, interval: 0.1) { let isSelected = try btn.isSelected() if isSelected != on { try btn.press() @@ -872,51 +872,52 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let startTime = Date() defer { log.debug("editMessage took \(startTime.timeIntervalSinceNow * -1000)ms") } - func tryPressingCancelEditButton() { + func tryPressingCancelEditButton() async throws { if let cancelEditButton = try? 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)) + func assignAndCommitEdit() async throws { + try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeReplacing)) let editableMessageField = try elements.editableMessageField - try assignToMessageField(editableMessageField, text: newText) + 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() } try await withAutomation { - tryPressingCancelEditButton() + try await tryPressingCancelEditButton() 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 await retry(withTimeout: 6.0, interval: 2.0, { try editAction() - try assignAndCommitEdit() + try await assignAndCommitEdit() }, onError: onError) return @@ -926,11 +927,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // 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 await retry(withTimeout: 6.0, interval: 2.0, { + try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforePressingMenuItem)) try elements.menuEditItem.press() - try assignAndCommitEdit() + try await assignAndCommitEdit() }, onError: onError) } } @@ -947,11 +948,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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) } /* @@ -970,7 +971,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withAutomation { try await withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + try await assertSelectedThread(threadID: threadID) if isVenturaOrUp { return try keyPresser.commandShiftU() } @@ -1022,7 +1023,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withAutomation { try await withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + try await assertSelectedThread(threadID: threadID) let selectedThreadCell = try await scrollAndGetSelectedThreadCell(threadID: threadID) if muted { try triggerThreadCellAction(threadCell: selectedThreadCell, action: .hideAlerts) @@ -1048,7 +1049,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withAutomation { try await withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + try await assertSelectedThread(threadID: threadID) try await triggerThreadCellAction(threadID: threadID, action: .delete) try elements.alertSheetDeleteButton.press() } @@ -1072,14 +1073,20 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try 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) { + // 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") + } } + } catch let error as CancellationError { + throw error + } catch { + return } } @@ -1098,8 +1105,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) { 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() } @@ -1115,16 +1122,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) { let message = try messageFieldValue(messageField) if !message.isEmpty { let hasNewline = message.hasSuffix("\n") @@ -1138,7 +1145,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)") @@ -1149,6 +1156,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 @@ -1161,25 +1170,28 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - private func closeReplyTranscriptView(wait: Bool) throws { - guard let rtv = try? elements.replyTranscriptView else { return } + @discardableResult + private func closeReplyTranscriptView() throws -> Bool { + guard let rtv = try? 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 closeReplyTranscriptView() else { return } + try await 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") } - 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) { + try await retry(withTimeout: 1.2, interval: 0.1) { guard let pValue = try? elements.messageBodyField.placeholderValue(), pValue != LocalizedStrings.imessage && pValue != LocalizedStrings.textMessage else { throw ErrorMessage("replyTranscriptView not visible") @@ -1193,10 +1205,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try replyAction() let messageField = try 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) } } } @@ -1258,13 +1270,13 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 await withActivation(openBefore: url) { - if let threadID { try assertSelectedThread(threadID: threadID) } + if let threadID { try await assertSelectedThread(threadID: threadID) } if quotedMessage != nil { - try waitUntilReplyTranscriptVisible() + try await 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 @@ -1275,11 +1287,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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) + try await assignToMessageField(messageField, text: text) } - try sendMessageInField(messageField) + try await sendMessageInField(messageField) } else if let filePath { - try pasteFileInBodyFieldAndSend(messageField, filePath: filePath) + try await pasteFileInBodyFieldAndSend(messageField, filePath: filePath) } } } @@ -1325,16 +1337,16 @@ 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) { let charCountResult = Result { try messageField.noOfChars() } guard case let .success(charCount) = charCountResult else { messageField = try elements.messageBodyField @@ -1346,7 +1358,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) throw ErrorMessage("file was not pasted: \(charCountResult)") } } - try sendMessageInField(messageField) + try await sendMessageInField(messageField) } } @@ -1456,7 +1468,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withAutomation { try await withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + try await assertSelectedThread(threadID: threadID) try elements.notifyAnywayButton.press() } } @@ -1468,25 +1480,22 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) var observation = ThreadActivityObservation.unknown try await withAutomation { try await withActivation(openBefore: url) { - try assertSelectedThread(threadID: threadID) + try await assertSelectedThread(threadID: threadID) observation = activityObservation() } } return observation } - 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") @@ -1525,7 +1534,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) _ = try await self.openDeepLink(url) log.debug("activity: opened deep link, waiting for layout change") self.lastThreadIDOpenedForObservation.withLock { $0 = threadID } - self.waitForLayoutChange(timeout: 0.5) + try await self.waitForLayoutChange(timeout: 0.5) } } diff --git a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift index 260bc52f..8f6fd060 100644 --- a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift +++ b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift @@ -25,4 +25,16 @@ extension NSPasteboard { self.prepareForNewContents(with: .currentHostOnly) // currentHostOnly disables universal clipboard try perform() } + + func withRestoration(perform: () async throws -> Void) async rethrows { + let backup = self.backup() + defer { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) { + self.prepareForNewContents() + if let backup { self.writeObjects(backup) } + } + } + self.prepareForNewContents(with: .currentHostOnly) // currentHostOnly disables universal clipboard + try await perform() + } } diff --git a/src/IMessage/Sources/IMessage/PromptAutomation.swift b/src/IMessage/Sources/IMessage/PromptAutomation.swift index c477fcff..f2c27c4a 100644 --- a/src/IMessage/Sources/IMessage/PromptAutomation.swift +++ b/src/IMessage/Sources/IMessage/PromptAutomation.swift @@ -65,14 +65,14 @@ enum PromptAutomation { } } - static func disableNotificationsForApp(named appName: String) throws -> Bool { + static func disableNotificationsForApp(named appName: String) async throws -> Bool { let app = try 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: [:] ) try app.waitForLaunch() - return try retry(withTimeout: 3, interval: 0.1) { + 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 +112,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/IMessageCore/Retry.swift b/src/IMessage/Sources/IMessageCore/Retry.swift index 90a6db3e..d8aa59b6 100644 --- a/src/IMessage/Sources/IMessageCore/Retry.swift +++ b/src/IMessage/Sources/IMessageCore/Retry.swift @@ -53,6 +53,39 @@ 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 { + 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, From e8a5411c976a2de11daaf7d0861c4e9cffa55681 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 21:06:18 +0530 Subject: [PATCH 23/59] - --- .../Sources/IMessage/Extensions.swift | 6 ++-- .../Messages/MessagesController.swift | 28 +++++++++---------- .../Messages/MessagesInstanceTarget.swift | 11 ++++++-- .../Sources/IMessage/PromptAutomation.swift | 2 +- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Extensions.swift b/src/IMessage/Sources/IMessage/Extensions.swift index 8eb2daa7..7ba44e12 100644 --- a/src/IMessage/Sources/IMessage/Extensions.swift +++ b/src/IMessage/Sources/IMessage/Extensions.swift @@ -14,11 +14,11 @@ extension NSApplication { } extension NSRunningApplication { - func waitForLaunch(interval: TimeInterval = 0.05, timeout seconds: TimeInterval = 5) throws { + func waitForLaunch(interval: TimeInterval = 0.05, timeout seconds: TimeInterval = 5) async 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) + try await Task.sleep(forTimeInterval: interval) if self.isTerminated { throw ErrorMessage("\(String(describing: self.localizedName)) terminated") } @@ -27,7 +27,7 @@ extension NSRunningApplication { break } } - Thread.sleep(forTimeInterval: 0.01) + try await Task.sleep(forTimeInterval: 0.01) } } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index e8418d03..5ec496c9 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -97,7 +97,7 @@ final class MessagesController { private func resetWindow() { try? elements.searchField.cancel() try? expandSplitter() - try? closeReplyTranscriptView() + _ = try? closeReplyTranscriptView() } private static func terminateApp(_ app: NSRunningApplication) throws { @@ -311,7 +311,7 @@ final class MessagesController { 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 @@ -346,7 +346,7 @@ final class MessagesController { } // without sleeping, appElement.observe applicationActivated/applicationDeactivated doesn't fire - try selectedApp.waitForLaunch() + try await selectedApp.waitForLaunch() elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in try Self.requestDeepLinkOpen(url, targeting: selectedApp) @@ -582,7 +582,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let selectedThreadCell = 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 await retry(withTimeout: 0.5, interval: 0.05) { // wait for hotkey to switch threads + try await retry(withTimeout: 0.5, interval: 0.05) { () async throws in // wait for hotkey to switch threads let nextThreadCell = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if let selectedThreadCell, nextThreadCell == selectedThreadCell { throw ErrorMessage("diff thread not selected") @@ -659,7 +659,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // without closing reply transcript, non-overlay deep link won't select the message if !messageCell.overlay { - try? closeReplyTranscriptView() + _ = try? closeReplyTranscriptView() } try await withActivation(openBefore: url) { @@ -675,7 +675,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if messageCell.overlay { try await waitUntilReplyTranscriptVisible() } - guard let selected = (try await retry(withTimeout: 1, interval: 0.2) { () -> Accessibility.Element? in + guard let selected = (try await retry(withTimeout: 1, interval: 0.2) { () async throws -> Accessibility.Element? in guard let cell = try messageCell.overlay ? MessagesAppElements.firstMessageCell(in: elements.replyTranscriptView) : MessagesAppElements.firstSelectedMessageCell(in: elements.transcriptView) @@ -779,7 +779,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } catch { throw ErrorMessage("Can't react with \"\(emoji)\": \(String(describing: error))") } - let searchField = try await retry(withTimeout: 1.0, interval: 0.05) { + let searchField = try await retry(withTimeout: 1.0, interval: 0.05) { () async throws in try elements.characterPickerSearchField } try searchField.value(assign: search.query) @@ -822,7 +822,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) return buttons[idx] }() - try await retry(withTimeout: 1.2, interval: 0.1) { + try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in let isSelected = try btn.isSelected() if isSelected != on { try btn.press() @@ -1075,7 +1075,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func focusMessageField(_ messageField: Accessibility.Element) async throws { do { - try await retry(withTimeout: 0.8, interval: 0.1) { + 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 isComposeThreadSelected() { return } @@ -1106,7 +1106,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } private func assignToMessageField(_ messageField: Accessibility.Element, text: String) async throws { - try await retry(withTimeout: 1, interval: 0.1) { + 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() } @@ -1131,7 +1131,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) do { log.debug("\(#function): will now attempt to verify the send") - try await 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") @@ -1180,7 +1180,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func closeReplyTranscriptViewAndWait() async throws { guard try closeReplyTranscriptView() else { return } - try await retry(withTimeout: 1.2, interval: 0.1) { + try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in guard let pValue = try? elements.messageBodyField.placeholderValue(), pValue == LocalizedStrings.imessage || pValue == LocalizedStrings.textMessage else { throw ErrorMessage("replyTranscriptView visible") @@ -1191,7 +1191,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func waitUntilReplyTranscriptVisible() async throws { log.debug("waitUntilReplyTranscriptVisible") - try await retry(withTimeout: 1.2, interval: 0.1) { + try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in guard let pValue = try? elements.messageBodyField.placeholderValue(), pValue != LocalizedStrings.imessage && pValue != LocalizedStrings.textMessage else { throw ErrorMessage("replyTranscriptView not visible") @@ -1346,7 +1346,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await pasteboard.withRestoration { pasteboard.setString(fileURL.relativeString, forType: .fileURL) try keyPresser.commandV() - try await 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 diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift index 201e6e35..3542a32d 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") } @@ -68,12 +68,17 @@ enum MessagesInstanceTarget { waiter.signal() } - guard waiter.wait(timeout: .now() + timeout) == .success else { + let didLaunch = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: waiter.wait(timeout: .now() + timeout) == .success) + } + } + guard didLaunch else { throw ErrorMessage("Timed out waiting for secondary Messages.app launch after \(timeout)s") } let app = try result.orThrow(ErrorMessage("Messages.app launch did not complete")).get() - try app.waitForLaunch(timeout: timeout) + try await app.waitForLaunch(timeout: timeout) if let initialDeepLink { try sendDeepLink(initialDeepLink, to: app) diff --git a/src/IMessage/Sources/IMessage/PromptAutomation.swift b/src/IMessage/Sources/IMessage/PromptAutomation.swift index f2c27c4a..76f4e520 100644 --- a/src/IMessage/Sources/IMessage/PromptAutomation.swift +++ b/src/IMessage/Sources/IMessage/PromptAutomation.swift @@ -71,7 +71,7 @@ enum PromptAutomation { options: [.withoutActivation], // .andHide shows a gray background and doesn't render the UI configuration: [:] ) - try app.waitForLaunch() + 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() From 6d07601a838413d07b7040c1fd125fe34f3cfae5 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 21:22:09 +0530 Subject: [PATCH 24/59] - --- .../Messages/MessagesInstanceTarget.swift | 34 +++++--------- .../Utilities/NSWorkspace+AsyncOpen.swift | 45 +++++++++++++------ 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift index 3542a32d..7abd5b58 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift @@ -54,30 +54,20 @@ enum MessagesInstanceTarget { configuration.messagesInstanceLaunchWithoutRestoringState = true configuration.messagesInstanceWaitForApplicationToCheckIn = 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() - } - - let didLaunch = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume(returning: waiter.wait(timeout: .now() + timeout) == .success) + let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: timeout, + timeoutError: { ErrorMessage("Timed out waiting for secondary Messages.app launch after \(timeout)s") } + ) { finish in + NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration) { app, error in + if let error { + finish(.failure(error)) + } else if let app { + finish(.success(app)) + } else { + finish(.failure(ErrorMessage("LaunchServices completed without returning Messages.app"))) + } } } - guard didLaunch else { - throw ErrorMessage("Timed out waiting for secondary Messages.app launch after \(timeout)s") - } - - let app = try result.orThrow(ErrorMessage("Messages.app launch did not complete")).get() try await app.waitForLaunch(timeout: timeout) if let initialDeepLink { diff --git a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift index f4712693..4ecf598f 100644 --- a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift +++ b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift @@ -3,15 +3,17 @@ import Foundation import IMessageCore extension NSWorkspace { - func open( - _ url: URL, - configuration: OpenConfiguration, - timeout: TimeInterval + typealias RunningApplicationOpenCompletion = @Sendable (Result) -> Void + + func waitForRunningApplicationOpen( + timeout: TimeInterval, + timeoutError: @escaping @Sendable () -> Error, + start: (@escaping RunningApplicationOpenCompletion) -> Void ) async throws -> NSRunningApplication { typealias OpenContinuation = CheckedContinuation let state = Protected<(continuation: OpenContinuation?, completed: Bool)>((nil, false)) - let finish: @Sendable (Result) -> Void = { result in + let finish: RunningApplicationOpenCompletion = { result in let continuation = state.withLock { state -> OpenContinuation? in guard !state.completed else { return nil @@ -39,22 +41,37 @@ extension NSWorkspace { let timeoutTask = Task { try? await Task.sleep(forTimeInterval: timeout) guard !Task.isCancelled else { return } - finish(.failure(ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s"))) + finish(.failure(timeoutError())) } - open(url, configuration: configuration) { running, error in + start { result in timeoutTask.cancel() - if let error { - finish(.failure(error)) - } else if let running { - finish(.success(running)) - } else { - finish(.failure(ErrorMessage("LaunchServices completed without returning an app"))) - } + finish(result) } } } onCancel: { finish(.failure(CancellationError())) } } + + func open( + _ url: URL, + configuration: OpenConfiguration, + timeout: TimeInterval + ) async throws -> NSRunningApplication { + try await waitForRunningApplicationOpen( + timeout: timeout, + timeoutError: { ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s") } + ) { finish in + open(url, configuration: configuration) { running, error in + if let error { + finish(.failure(error)) + } else if let running { + finish(.success(running)) + } else { + finish(.failure(ErrorMessage("LaunchServices completed without returning an app"))) + } + } + } + } } From 6e9618ccf31060267fbba3416cb444410944664e Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 21:29:41 +0530 Subject: [PATCH 25/59] - --- .../Messages/MessagesInstanceTarget.swift | 15 +++----- .../Utilities/NSWorkspace+AsyncOpen.swift | 35 +++++++++---------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift index 7829a46f..c4a84847 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift @@ -56,17 +56,10 @@ enum MessagesInstanceTarget { let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( timeout: timeout, - timeoutError: { ErrorMessage("Timed out waiting for secondary Messages.app launch after \(timeout)s") } - ) { finish in - NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration) { app, error in - if let error { - finish(.failure(error)) - } else if let app { - finish(.success(app)) - } else { - finish(.failure(ErrorMessage("LaunchServices completed without returning Messages.app"))) - } - } + timeoutMessage: "Timed out waiting for secondary Messages.app launch after \(timeout)s", + missingApplicationMessage: "LaunchServices completed without returning Messages.app" + ) { completion in + NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration, completionHandler: completion) } try await app.waitForLaunch(timeout: timeout) diff --git a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift index 4ecf598f..120ba30d 100644 --- a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift +++ b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift @@ -3,17 +3,18 @@ import Foundation import IMessageCore extension NSWorkspace { - typealias RunningApplicationOpenCompletion = @Sendable (Result) -> Void + typealias RunningApplicationOpenHandler = @Sendable (NSRunningApplication?, Error?) -> Void func waitForRunningApplicationOpen( timeout: TimeInterval, - timeoutError: @escaping @Sendable () -> Error, - start: (@escaping RunningApplicationOpenCompletion) -> Void + timeoutMessage: String, + missingApplicationMessage: String = "LaunchServices completed without returning an app", + start: (@escaping RunningApplicationOpenHandler) -> Void ) async throws -> NSRunningApplication { typealias OpenContinuation = CheckedContinuation let state = Protected<(continuation: OpenContinuation?, completed: Bool)>((nil, false)) - let finish: RunningApplicationOpenCompletion = { result in + let finish: @Sendable (Result) -> Void = { result in let continuation = state.withLock { state -> OpenContinuation? in guard !state.completed else { return nil @@ -41,12 +42,18 @@ extension NSWorkspace { let timeoutTask = Task { try? await Task.sleep(forTimeInterval: timeout) guard !Task.isCancelled else { return } - finish(.failure(timeoutError())) + finish(.failure(ErrorMessage(timeoutMessage))) } - start { result in + start { running, error in timeoutTask.cancel() - finish(result) + if let error { + finish(.failure(error)) + } else if let running { + finish(.success(running)) + } else { + finish(.failure(ErrorMessage(missingApplicationMessage))) + } } } } onCancel: { @@ -61,17 +68,9 @@ extension NSWorkspace { ) async throws -> NSRunningApplication { try await waitForRunningApplicationOpen( timeout: timeout, - timeoutError: { ErrorMessage("Timed out opening URL via LaunchServices after \(timeout)s") } - ) { finish in - open(url, configuration: configuration) { running, error in - if let error { - finish(.failure(error)) - } else if let running { - finish(.success(running)) - } else { - finish(.failure(ErrorMessage("LaunchServices completed without returning an app"))) - } - } + timeoutMessage: "Timed out opening URL via LaunchServices after \(timeout)s" + ) { completion in + open(url, configuration: configuration, completionHandler: completion) } } } From e86f9d96b4587ab9952e843c7640c202c1161338 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 23:04:00 +0530 Subject: [PATCH 26/59] - --- .../Messages/MessagesController.swift | 65 ++++++++++++------- .../PlatformAPI+MessagesController.swift | 20 +++++- todos.md | 2 + 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 5ec496c9..02d669fd 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1374,16 +1374,26 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // when the user manually cmd+tab's or clicks the Messages dock icon, // we want to actually show the app 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. do { - manualActivationState.withLock { - $0.lastActivate = Date() - $0.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 await windowCoordinator.reset(window) - try await windowCoordinator.userManuallyActivated(app) + try await PlatformAPI.runOnMessagesControllerLane { [weak self] in + guard let self else { 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)") @@ -1391,22 +1401,29 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } 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. do { - 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") - // we use getMainWindow() instead of mainWindow to not reopen the window if it's not present - let window = elements.getMainWindow() - if Defaults.shouldCoordinateWindow { - try await windowCoordinator.userManuallyDeactivated(app) - } - try? closeAllNonMainWindows() - if window != nil { - resetWindow() + try await PlatformAPI.runOnMessagesControllerLane { [weak self] in + guard let self else { 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 { + self.resetWindow() + } } } catch { log.error("couldn't hide messages window caused by user activation: \(error)") diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 3c44b6e4..15ad13e5 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -17,7 +17,15 @@ actor MessagesControllerAutomationLane { // 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 assertion in `run` catches it. + // 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 @@ -32,10 +40,16 @@ actor MessagesControllerAutomationLane { } func run(_ action: @Sendable @escaping () async throws -> T) async throws -> T { - assert( + // `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. Run the work inline instead." + "a lane action must not call back into the lane." ) activeWorkWillBegin() let task = enqueue(action) diff --git a/todos.md b/todos.md index ea3fb31c..5882556e 100644 --- a/todos.md +++ b/todos.md @@ -21,6 +21,8 @@ - concurrency - [ ] review for races, `PlatformAPI.messagesController` is mutated without isolation + - [ ] 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) From f0c19b17530268801a4cf63feb88c693dfd5c74f Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 23:14:24 +0530 Subject: [PATCH 27/59] - --- .../PlatformAPI+MessagesController.swift | 41 +++++--- .../Utilities/NSWorkspace+AsyncOpen.swift | 24 +++-- .../NSWorkspaceAsyncOpenTests.swift | 97 +++++++++++++++++++ .../Sources/IMessageTests/RetryTests.swift | 97 +++++++++++++++++++ 4 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift create mode 100644 src/IMessage/Sources/IMessageTests/RetryTests.swift diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 15ad13e5..01ac292f 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -93,6 +93,9 @@ actor MessagesControllerAutomationLane { } private func activeWorkDidFinish() { + // Every activeWorkDidFinish must pair with a prior activeWorkWillBegin. + // The assert surfaces an imbalance in debug; the max(0,…) keeps release safe. + assert(queuedActiveWorkCount > 0, "activeWorkDidFinish without a matching activeWorkWillBegin") queuedActiveWorkCount = max(0, queuedActiveWorkCount - 1) guard queuedActiveWorkCount == 0 else { return } scheduleIdleCallback() @@ -185,23 +188,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 } } diff --git a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift index 120ba30d..fbe66165 100644 --- a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift +++ b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift @@ -12,17 +12,27 @@ extension NSWorkspace { start: (@escaping RunningApplicationOpenHandler) -> Void ) async throws -> NSRunningApplication { typealias OpenContinuation = CheckedContinuation - let state = Protected<(continuation: OpenContinuation?, completed: Bool)>((nil, false)) + let state = Protected<(continuation: OpenContinuation?, completed: Bool, timeoutTask: Task?)>((nil, false, nil)) + // Single completion point. Whoever finishes first (LaunchServices callback, + // the timeout, or caller cancellation) resumes the continuation and tears down + // the timeout task; everyone else is a no-op via the `completed` flag. Cancelling + // the timeout task here (rather than only in the success callback) means a + // cancelled or errored open doesn't leave the timeout task sleeping with its + // captured state retained until the deadline. let finish: @Sendable (Result) -> Void = { result in - let continuation = state.withLock { state -> OpenContinuation? in + let (continuation, timeoutTask) = state.withLock { state -> (OpenContinuation?, Task?) in guard !state.completed else { - return nil + return (nil, nil) } state.completed = true - defer { state.continuation = nil } - return state.continuation + let continuation = state.continuation + let timeoutTask = state.timeoutTask + state.continuation = nil + state.timeoutTask = nil + return (continuation, timeoutTask) } + timeoutTask?.cancel() continuation?.resume(with: result) } @@ -44,9 +54,11 @@ extension NSWorkspace { guard !Task.isCancelled else { return } finish(.failure(ErrorMessage(timeoutMessage))) } + // Publish the handle before calling `start` so a synchronous completion + // can still cancel the timeout task through `finish`. + state.withLock { $0.timeoutTask = timeoutTask } start { running, error in - timeoutTask.cancel() if let error { finish(.failure(error)) } else if let running { diff --git a/src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift b/src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift new file mode 100644 index 00000000..47a3c1e1 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift @@ -0,0 +1,97 @@ +import AppKit +import Foundation +@testable import IMessage +@testable import IMessageCore +import Testing + +// Covers NSWorkspace.waitForRunningApplicationOpen — the continuation bridge that +// replaced the DispatchSemaphore LaunchServices opens. The `start` closure is +// injected, so every completion path is exercised without launching an app. +// NSRunningApplication.current stands in for a real returned app (identity check +// via processIdentifier). + +private struct OpenTestError: Error, Equatable {} + +@Test func asyncOpenReturnsRunningApplicationOnSuccess() async throws { + let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: 5, + timeoutMessage: "should not time out" + ) { completion in + completion(NSRunningApplication.current, nil) + } + #expect(app.processIdentifier == NSRunningApplication.current.processIdentifier) +} + +@Test func asyncOpenPropagatesStartError() async throws { + await #expect(throws: OpenTestError.self) { + try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: 5, + timeoutMessage: "should not time out" + ) { completion in + completion(nil, OpenTestError()) + } + } +} + +@Test func asyncOpenThrowsMissingApplicationWhenNeitherReturned() async throws { + let missing = "no app returned (test)" + do { + _ = try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: 5, + timeoutMessage: "should not time out", + missingApplicationMessage: missing + ) { completion in + completion(nil, nil) + } + Issue.record("expected waitForRunningApplicationOpen to throw") + } catch let error as ErrorMessage { + #expect(error.description == missing) + } +} + +@Test func asyncOpenTimesOutWhenStartNeverCompletes() async throws { + let timeoutMessage = "timed out (test)" + do { + _ = try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: 0.05, + timeoutMessage: timeoutMessage + ) { _ in + // intentionally never completes + } + Issue.record("expected waitForRunningApplicationOpen to time out") + } catch let error as ErrorMessage { + #expect(error.description == timeoutMessage) + } +} + +@Test func asyncOpenThrowsCancellationWhenCallerCancelled() async throws { + let started = Protected(false) + let task = Task { + try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: 10, + timeoutMessage: "should not time out" + ) { _ in + started.withLock { $0 = true } + // never completes; resolution must come from cancellation + } + } + + #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 asyncOpenIgnoresSecondCompletion() async throws { + // First completion wins; a later completion must be a no-op (the `completed` + // flag guards the checked continuation against a fatal double-resume). + let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( + timeout: 5, + timeoutMessage: "should not time out" + ) { completion in + completion(NSRunningApplication.current, nil) + completion(nil, OpenTestError()) // must be ignored, not crash + } + #expect(app.processIdentifier == NSRunningApplication.current.processIdentifier) +} diff --git a/src/IMessage/Sources/IMessageTests/RetryTests.swift b/src/IMessage/Sources/IMessageTests/RetryTests.swift new file mode 100644 index 00000000..8cb434c6 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/RetryTests.swift @@ -0,0 +1,97 @@ +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 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 +} From 7d52d8094b5f5cc11288ea0dfa455c2187844925 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 23:41:09 +0530 Subject: [PATCH 28/59] - --- .../MessagesControllerAutomationLane.swift | 142 ++++++++++++++++++ .../PlatformAPI+MessagesController.swift | 133 ---------------- ...essagesControllerAutomationLaneTests.swift | 63 ++++++++ todos.md | 2 + 4 files changed, 207 insertions(+), 133 deletions(-) create mode 100644 src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift diff --git a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift new file mode 100644 index 00000000..5505582c --- /dev/null +++ b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift @@ -0,0 +1,142 @@ +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 queuedActiveWorkCount = 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." + ) + activeWorkWillBegin() + let task = enqueue(action) + defer { activeWorkDidFinish() } + + 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 activeWorkWillBegin() { + idleEpoch += 1 + queuedActiveWorkCount += 1 + idleTask?.cancel() + idleTask = nil + } + + private func activeWorkDidFinish() { + // Every activeWorkDidFinish must pair with a prior activeWorkWillBegin. + // The assert surfaces an imbalance in debug; the max(0,…) keeps release safe. + assert(queuedActiveWorkCount > 0, "activeWorkDidFinish without a matching activeWorkWillBegin") + queuedActiveWorkCount = max(0, queuedActiveWorkCount - 1) + guard queuedActiveWorkCount == 0 else { return } + scheduleIdleCallback() + } + + private func scheduleIdleCallback() { + guard idleCallback != nil else { return } + + let expectedEpoch = idleEpoch + let idleDelay = idleDelay + idleTask = Task { + do { + try await Task.sleep(forTimeInterval: idleDelay) + } catch { + return + } + + let task = 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 queuedActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { + return nil + } + return idleCallback + } + + private func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { + queuedActiveWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil + } +} diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 01ac292f..202f190a 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -11,139 +11,6 @@ private enum MessagesControllerCoordinatorError: Error { case pendingControllerInvalidated } -// Internal (not `private`) so `@testable import IMessage` can exercise it. -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 queuedActiveWorkCount = 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." - ) - activeWorkWillBegin() - let task = enqueue(action) - defer { activeWorkDidFinish() } - - 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 activeWorkWillBegin() { - idleEpoch += 1 - queuedActiveWorkCount += 1 - idleTask?.cancel() - idleTask = nil - } - - private func activeWorkDidFinish() { - // Every activeWorkDidFinish must pair with a prior activeWorkWillBegin. - // The assert surfaces an imbalance in debug; the max(0,…) keeps release safe. - assert(queuedActiveWorkCount > 0, "activeWorkDidFinish without a matching activeWorkWillBegin") - queuedActiveWorkCount = max(0, queuedActiveWorkCount - 1) - guard queuedActiveWorkCount == 0 else { return } - scheduleIdleCallback() - } - - private func scheduleIdleCallback() { - guard idleCallback != nil else { return } - - let expectedEpoch = idleEpoch - let idleDelay = idleDelay - idleTask = Task { - do { - try await Task.sleep(forTimeInterval: idleDelay) - } catch { - return - } - - let task = 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 queuedActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { - return nil - } - return idleCallback - } - - private func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { - queuedActiveWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil - } -} private actor MessagesControllerCoordinator { private var current: MessagesControllerEntry? diff --git a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift index a52e3578..8eaf253f 100644 --- a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift +++ b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift @@ -145,3 +145,66 @@ private struct Boom: Error {} 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.02) + 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/todos.md b/todos.md index 5882556e..779e6c65 100644 --- a/todos.md +++ b/todos.md @@ -21,8 +21,10 @@ - concurrency - [ ] review for races, `PlatformAPI.messagesController` is mutated without isolation + - [ ] 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). + - [ ] **(gated on measurement)** async-migrate the `MessagesAppElements` element accessors (`mainWindow`, `messageBodyField`, `searchField`, `reactionsView`, `menu`, `menuEditItem`, `conversationsList`) + `PromptAutomation` from sync `retry`+`Thread.sleep` to async `retry`+`Task.sleep`, so lane actions stop blocking the cooperative thread during element resolution (up to 5s on `mainWindow`). NOT a clean mechanical swap: the accessors are `get throws` computed properties feeding ~70+ call sites, and the cascade reaches sync hot-path code — `MessagesController.isValid` (read by the coordinator every `withController`), `clearTypingStatus()`, and `debuggingStatus()` (called inside a log-string interpolation, can't `await`). Only do this if the Issue 4 cooperative-pool-blocking measurement shows real starvation/latency under load. Surfaced by /plan-eng-review on kb/modern-concurrency (CQ Issue 5 / T3); deferred after scoping revealed the blast radius. - [ ] 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) From d31bd2474f8987c1bc1e7d7de5fe803df82873cc Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 23:57:51 +0530 Subject: [PATCH 29/59] - --- .../Messages/MessagesAppElements.swift | 212 ++++++++++++------ .../Messages/MessagesController.swift | 157 +++++++------ .../Sources/IMessage/PlatformAPI.swift | 2 +- 3 files changed, 224 insertions(+), 147 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 8933b1c9..386cfe79 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -198,6 +198,47 @@ final class MessagesAppElements { throw ElementSearchError(name: name, underlyingErrors: errors, dumpID: dumpID) } + func find( + _ name: String, + logTime: Bool = false, + dumpOnError: Bool = false, + in root: Accessibility.Element? = nil, + _ search: () async throws -> Accessibility.Element? + ) async throws -> Accessibility.Element { + let startTime = logTime ? Date() : nil + + defer { + if let startTime { + log.debug("\(name) took \(startTime.timeIntervalSinceNow * -1000)ms") + } + } + + var errors: [Error] = [] + do { + if let result = try await search() { + return result + } + } catch { + errors.append(error) + } + + var dumpID: String? + if dumpOnError { + let id = String(UUID().uuidString.prefix(8)).lowercased() + dumpID = id + do { + var buffer = "" + try (root ?? app).dumpXML(to: &buffer, maxDepth: 10, excludingPII: true, includeActions: false, includeSections: true) + log.error("[\(id)] AX dump for \(name):\n\(buffer)") + } catch { + log.error("[\(id)] failed to dump AX tree for \(name): \(error)") + errors.append(error) + } + } + + throw ElementSearchError(name: name, underlyingErrors: errors, dumpID: dumpID) + } + var allWindows: [Accessibility.Element] { // takes ~0ms get { // after a window is moved to the new space, AX doesn't list the window in appWindows or children @@ -232,6 +273,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,11 +312,11 @@ 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 { @@ -323,9 +371,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 +386,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 +407,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") } @@ -386,37 +436,40 @@ final class MessagesAppElements { (try? el.identifier()) == "TranscriptCollectionView" && isReplyTranscriptView(el) == replyTranscript } // takes ~8ms - if let transcriptView = try? mainWindowSections.first(where: predicate) { return transcriptView } + if let transcriptView = try? await 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 + 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 +477,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 +491,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 +522,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 +543,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 +557,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 +589,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 +603,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 +611,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 +622,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 +657,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 +669,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 +702,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.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 02d669fd..07fca9ef 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -88,16 +88,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() + private func resetWindow() async { + try? await elements.searchField.cancel() + try? await expandSplitter() + _ = try? await closeReplyTranscriptView() } private static func terminateApp(_ app: NSRunningApplication) throws { @@ -199,14 +199,14 @@ 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 @@ -253,7 +253,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") } @@ -274,7 +274,7 @@ final class MessagesController { } } 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 } @@ -286,7 +286,7 @@ final class MessagesController { } private func openThread(_ threadID: String) async throws { - try? self.clearTypingStatus() + try? await self.clearTypingStatus() try await openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil).url()) try await assertSelectedThread(threadID: threadID) } @@ -367,16 +367,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) { @@ -387,7 +388,8 @@ 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)") } @@ -401,7 +403,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let app = self.app do { - let window = try self.elements.mainWindow + let window = try self.elements.currentMainWindow.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)" @@ -440,7 +442,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) } @@ -469,7 +472,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } var isValid: Bool { - !app.isTerminated && (try? elements.mainWindow.isFrameValid) != nil && isMessagesAppResponsive + !app.isTerminated && elements.currentMainWindow?.isFrameValid == true && isMessagesAppResponsive } private func withAutomation(_ operation: () async throws -> T) async throws -> T { @@ -509,7 +512,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await PlatformAPI.runOnMessagesControllerLane { [weak self] in try Task.checkCancellation() guard let self else { return } - try closeReplyTranscriptView() + try await closeReplyTranscriptView() } } catch is CancellationError { return @@ -556,7 +559,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } try await openReactionPicker(messageCell: messageCell) - try elements.addCustomEmojiReactionButton.press() + try await elements.addCustomEmojiReactionButton.press() } private func threadCellAction(threadCell: Accessibility.Element, namePrefix: String) throws -> Accessibility.Action? { @@ -579,11 +582,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } private func selectNextThreadAndScroll() async throws { - let selectedThreadCell = elements.selectedThreadCell + 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 await retry(withTimeout: 0.5, interval: 0.05) { () async throws in // wait for hotkey to switch threads - let nextThreadCell = try elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) + let nextThreadCell = try await elements.selectedThreadCell.orThrow(ErrorMessage("selectedThreadCell nil")) if let selectedThreadCell, nextThreadCell == selectedThreadCell { throw ErrorMessage("diff thread not selected") } @@ -617,13 +620,13 @@ 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 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") } @@ -659,7 +662,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // without closing reply transcript, non-overlay deep link won't select the message if !messageCell.overlay { - _ = try? closeReplyTranscriptView() + _ = try? await closeReplyTranscriptView() } try await withActivation(openBefore: url) { @@ -676,10 +679,15 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await waitUntilReplyTranscriptVisible() } guard let selected = (try await retry(withTimeout: 1, interval: 0.2) { () async throws -> Accessibility.Element? in - guard let cell = try messageCell.overlay - ? MessagesAppElements.firstMessageCell(in: elements.replyTranscriptView) - : MessagesAppElements.firstSelectedMessageCell(in: elements.transcriptView) - else { + let cell: Accessibility.Element? + if messageCell.overlay { + let transcript = try await elements.replyTranscriptView + cell = try MessagesAppElements.firstMessageCell(in: transcript) + } else { + let 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") } @@ -693,8 +701,14 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } else { let containerCell = try selected.parent() let containerFrame = try containerCell.frame() + let transcript: Accessibility.Element + if messageCell.overlay { + transcript = try await elements.replyTranscriptView + } else { + transcript = try await elements.transcriptView + } 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 { @@ -780,7 +794,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) throw ErrorMessage("Can't react with \"\(emoji)\": \(String(describing: error))") } let searchField = try await retry(withTimeout: 1.0, interval: 0.05) { () async throws in - try elements.characterPickerSearchField + try await elements.characterPickerSearchField } try searchField.value(assign: search.query) try await Task.sleep(forTimeInterval: 0.75) // wait for search @@ -802,9 +816,9 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await openReactionPicker(messageCell: $0) - let btn = try { + let btn = try await { () async throws -> Accessibility.Element in if isSequoiaOrUp { - return try elements.tapbackPickerCollectionView.children() + return try await elements.tapbackPickerCollectionView.children() .first { // standard: "ha", "thumbsUp", etc. custom: emoji string let identifier = try? $0.identifier() @@ -814,7 +828,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } let idx = reaction.index! - let buttons = try elements.reactButtons + let buttons = try await elements.reactButtons guard buttons.count > idx else { throw ErrorMessage("reactButtons count=\(buttons.count)") } @@ -873,7 +887,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) defer { log.debug("editMessage took \(startTime.timeIntervalSinceNow * -1000)ms") } func tryPressingCancelEditButton() async throws { - if let cancelEditButton = try? elements.cancelEditButton { + if let cancelEditButton = try? await elements.cancelEditButton { // this is seemingly always available, even when you're not editing log.debug("pressing cancel edit button") @@ -891,7 +905,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) func assignAndCommitEdit() async throws { try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeReplacing)) - let editableMessageField = try elements.editableMessageField + let editableMessageField = try await elements.editableMessageField try await assignToMessageField(editableMessageField, text: newText) try await Task.sleep(forTimeInterval: Defaults.imessage.double(forKey: DefaultsKeys.editingDelayBeforeFocusing)) @@ -929,7 +943,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // 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 elements.menuEditItem.press() + try await elements.menuEditItem.press() try await assignAndCommitEdit() }, onError: onError) @@ -1051,7 +1065,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withActivation(openBefore: url) { try await assertSelectedThread(threadID: threadID) try await triggerThreadCellAction(threadID: threadID, action: .delete) - try elements.alertSheetDeleteButton.press() + try await elements.alertSheetDeleteButton.press() } } } @@ -1069,8 +1083,8 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - func clearTypingStatus() throws { - try elements.messageBodyField.value(assign: "") + func clearTypingStatus() async throws { + try await elements.messageBodyField.value(assign: "") } private func focusMessageField(_ messageField: Accessibility.Element) async throws { @@ -1078,7 +1092,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 isComposeThreadSelected() { return } + if await isComposeThreadSelected() { return } guard try messageField.isFocused() else { throw ErrorMessage("Could not focus message field") } @@ -1171,17 +1185,17 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } @discardableResult - private func closeReplyTranscriptView() throws -> Bool { - guard let rtv = try? elements.replyTranscriptView else { return false } + private func closeReplyTranscriptView() async throws -> Bool { + guard let rtv = try? await elements.replyTranscriptView else { return false } log.debug("calling replyTranscriptView.cancel()") try rtv.cancel() return true } private func closeReplyTranscriptViewAndWait() async throws { - guard try closeReplyTranscriptView() else { return } + guard try await closeReplyTranscriptView() else { return } try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in - guard let pValue = try? elements.messageBodyField.placeholderValue(), + guard let pValue = try? await elements.messageBodyField.placeholderValue(), pValue == LocalizedStrings.imessage || pValue == LocalizedStrings.textMessage else { throw ErrorMessage("replyTranscriptView visible") } @@ -1192,7 +1206,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func waitUntilReplyTranscriptVisible() async throws { log.debug("waitUntilReplyTranscriptVisible") try await retry(withTimeout: 1.2, interval: 0.1) { () async throws in - guard let pValue = try? elements.messageBodyField.placeholderValue(), + guard let pValue = try? await elements.messageBodyField.placeholderValue(), pValue != LocalizedStrings.imessage && pValue != LocalizedStrings.textMessage else { throw ErrorMessage("replyTranscriptView not visible") } @@ -1203,7 +1217,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 await assignToMessageField(messageField, text: text) try await sendMessageInField(messageField) @@ -1227,7 +1241,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 } @@ -1235,7 +1249,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 } @@ -1278,13 +1292,13 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if quotedMessage != nil { try await waitUntilReplyTranscriptVisible() } - if isComposeThreadSelected() { + 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 + 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) @@ -1306,18 +1320,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) @@ -1327,7 +1341,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 @@ -1349,12 +1363,12 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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)") } } @@ -1422,7 +1436,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } try? self.closeAllNonMainWindows() if window != nil { - self.resetWindow() + await self.resetWindow() } } } catch { @@ -1430,15 +1444,12 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - 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 @@ -1486,7 +1497,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withAutomation { try await withActivation(openBefore: url) { try await assertSelectedThread(threadID: threadID) - try elements.notifyAnywayButton.press() + try await elements.notifyAnywayButton.press() } } } @@ -1498,7 +1509,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) try await withAutomation { try await withActivation(openBefore: url) { try await assertSelectedThread(threadID: threadID) - observation = activityObservation() + observation = await activityObservation() } } return observation @@ -1555,7 +1566,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } - let observationToSend = activityObservation() + let observationToSend = await 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") diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index 75bdce58..1f670a9c 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -368,7 +368,7 @@ public final class PlatformAPI { if type == "typing" { try await controller.sendTypingStatus(threadID: threadID) } else { - try controller.clearTypingStatus() + try await controller.clearTypingStatus() } } } From 8feab6d625e0fce3ddb9f1af33f978ef27c81fed Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 23:58:00 +0530 Subject: [PATCH 30/59] Update MessagesAppElements.swift --- .../Messages/MessagesAppElements.swift | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 386cfe79..bad32b77 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -157,47 +157,6 @@ final class MessagesAppElements { /// - dumpOnError: If true, dumps the AX tree from `root` into the log when the element is not found /// - root: The element to dump from on failure. Defaults to `app`. /// - search: Closure that performs the actual lookup. Return `nil` or throw to indicate not found. - func find( - _ name: String, - logTime: Bool = false, - dumpOnError: Bool = false, - in root: Accessibility.Element? = nil, - _ search: () throws -> Accessibility.Element? - ) throws -> Accessibility.Element { - let startTime = logTime ? Date() : nil - - defer { - if let startTime { - log.debug("\(name) took \(startTime.timeIntervalSinceNow * -1000)ms") - } - } - - var errors: [Error] = [] - do { - if let result = try search() { - return result - } - } catch { - errors.append(error) - } - - var dumpID: String? - if dumpOnError { - let id = String(UUID().uuidString.prefix(8)).lowercased() - dumpID = id - do { - var buffer = "" - try (root ?? app).dumpXML(to: &buffer, maxDepth: 10, excludingPII: true, includeActions: false, includeSections: true) - log.error("[\(id)] AX dump for \(name):\n\(buffer)") - } catch { - log.error("[\(id)] failed to dump AX tree for \(name): \(error)") - errors.append(error) - } - } - - throw ElementSearchError(name: name, underlyingErrors: errors, dumpID: dumpID) - } - func find( _ name: String, logTime: Bool = false, From e997b100476f9e24f17d1ba65adf5255fb7db6e9 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 31 May 2026 23:58:59 +0530 Subject: [PATCH 31/59] Update MessagesController.swift --- src/IMessage/Sources/IMessage/Messages/MessagesController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 07fca9ef..d919bde6 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -472,7 +472,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } var isValid: Bool { - !app.isTerminated && elements.currentMainWindow?.isFrameValid == true && isMessagesAppResponsive + !app.isTerminated && (elements.currentMainWindow?.isFrameValid) != nil && isMessagesAppResponsive } private func withAutomation(_ operation: () async throws -> T) async throws -> T { From e49acf94397cf7a3707451884d9c15c8f89ef864 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:11:12 +0530 Subject: [PATCH 32/59] Update todos.md --- todos.md | 1 - 1 file changed, 1 deletion(-) diff --git a/todos.md b/todos.md index 779e6c65..9f8dd437 100644 --- a/todos.md +++ b/todos.md @@ -24,7 +24,6 @@ - [ ] 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). - - [ ] **(gated on measurement)** async-migrate the `MessagesAppElements` element accessors (`mainWindow`, `messageBodyField`, `searchField`, `reactionsView`, `menu`, `menuEditItem`, `conversationsList`) + `PromptAutomation` from sync `retry`+`Thread.sleep` to async `retry`+`Task.sleep`, so lane actions stop blocking the cooperative thread during element resolution (up to 5s on `mainWindow`). NOT a clean mechanical swap: the accessors are `get throws` computed properties feeding ~70+ call sites, and the cascade reaches sync hot-path code — `MessagesController.isValid` (read by the coordinator every `withController`), `clearTypingStatus()`, and `debuggingStatus()` (called inside a log-string interpolation, can't `await`). Only do this if the Issue 4 cooperative-pool-blocking measurement shows real starvation/latency under load. Surfaced by /plan-eng-review on kb/modern-concurrency (CQ Issue 5 / T3); deferred after scoping revealed the blast radius. - [ ] 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) From 4beb33623eb5281c2462aceb4415f3875f28d667 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:31:08 +0530 Subject: [PATCH 33/59] fix crash --- .../Sources/IMessage/KeyPresser.swift | 22 ++++++------ .../IMessageTests/KeyPresserTests.swift | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/IMessage/Sources/IMessage/KeyPresser.swift b/src/IMessage/Sources/IMessage/KeyPresser.swift index 39efeb30..17911504 100644 --- a/src/IMessage/Sources/IMessage/KeyPresser.swift +++ b/src/IMessage/Sources/IMessage/KeyPresser.swift @@ -9,26 +9,30 @@ private let log = Logger(imessageLabel: "key-presser") class KeyPresser { let pid: pid_t private let postKeyEvents: (CGKeyCode, CGEventFlags?) throws -> Void + private let keyCodeForCharacter: (Character) -> UInt16? - init(pid: pid_t, postKeyEvents: ((CGKeyCode, CGEventFlags?) throws -> Void)? = nil) { + 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 static func post(key: CGKeyCode, flags: CGEventFlags? = nil, to pid: pid_t) throws { @@ -54,10 +58,8 @@ class KeyPresser { } private func pressMappedKey(_ key: Character, flags: CGEventFlags? = nil, onMainThread: Bool) throws { - try perform(onMainThread: onMainThread) { - guard let keyCode = KeyMap.shared[key] else { return } - try postKeyEvents(CGKeyCode(keyCode), flags) - } + guard let keyCode = perform(onMainThread: true, { keyCodeForCharacter(key) }) else { return } + try press(key: CGKeyCode(keyCode), flags: flags, onMainThread: onMainThread) } func `return`(onMainThread: Bool = false) throws { diff --git a/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift b/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift index 7e784357..0123fd7a 100644 --- a/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift +++ b/src/IMessage/Sources/IMessageTests/KeyPresserTests.swift @@ -52,3 +52,37 @@ private func runOffMainThread(_ operation: @escaping () -> Void) async { 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) +} From 7a5d64f833fbc0ff732e83a38474eaf9d866a3d8 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:45:29 +0530 Subject: [PATCH 34/59] - --- .../MessagesControllerAutomationLane.swift | 24 +++++++++---------- .../Sources/IMessage/Pasteboard+Backup.swift | 12 ---------- src/IMessage/Sources/IMessageCore/Retry.swift | 1 + 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift index 5505582c..8f94d137 100644 --- a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift +++ b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift @@ -26,7 +26,7 @@ actor MessagesControllerAutomationLane { private let idleDelay: TimeInterval private var tail: Task? - private var queuedActiveWorkCount = 0 + private var pendingActiveWorkCount = 0 private var idleEpoch: UInt = 0 private var idleCallback: IdleCallback? private var idleTask: Task? @@ -47,9 +47,9 @@ actor MessagesControllerAutomationLane { "re-entrant runOnMessagesControllerLane would deadlock the serial lane: " + "a lane action must not call back into the lane." ) - activeWorkWillBegin() + activeWorkSubmitted() let task = enqueue(action) - defer { activeWorkDidFinish() } + defer { activeWorkCompleted() } return try await withTaskCancellationHandler { try await task.value @@ -81,19 +81,19 @@ actor MessagesControllerAutomationLane { return task } - private func activeWorkWillBegin() { + private func activeWorkSubmitted() { idleEpoch += 1 - queuedActiveWorkCount += 1 + pendingActiveWorkCount += 1 idleTask?.cancel() idleTask = nil } - private func activeWorkDidFinish() { - // Every activeWorkDidFinish must pair with a prior activeWorkWillBegin. + 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(queuedActiveWorkCount > 0, "activeWorkDidFinish without a matching activeWorkWillBegin") - queuedActiveWorkCount = max(0, queuedActiveWorkCount - 1) - guard queuedActiveWorkCount == 0 else { return } + assert(pendingActiveWorkCount > 0, "activeWorkCompleted without a matching activeWorkSubmitted") + pendingActiveWorkCount = max(0, pendingActiveWorkCount - 1) + guard pendingActiveWorkCount == 0 else { return } scheduleIdleCallback() } @@ -130,13 +130,13 @@ actor MessagesControllerAutomationLane { } private func idleCallbackIfStillCurrent(expectedEpoch: UInt) -> IdleCallback? { - guard queuedActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { + guard pendingActiveWorkCount == 0, idleEpoch == expectedEpoch, idleTask?.isCancelled == false else { return nil } return idleCallback } private func shouldContinueIdleCallbacks(expectedEpoch: UInt) -> Bool { - queuedActiveWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil + pendingActiveWorkCount == 0 && idleEpoch == expectedEpoch && idleCallback != nil } } diff --git a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift index 8f6fd060..e039dc9e 100644 --- a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift +++ b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift @@ -14,18 +14,6 @@ extension NSPasteboard { } } - func withRestoration(perform: () throws -> Void) rethrows { - let backup = self.backup() - defer { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) { - self.prepareForNewContents() - if let backup { self.writeObjects(backup) } - } - } - self.prepareForNewContents(with: .currentHostOnly) // currentHostOnly disables universal clipboard - try perform() - } - func withRestoration(perform: () async throws -> Void) async rethrows { let backup = self.backup() defer { diff --git a/src/IMessage/Sources/IMessageCore/Retry.swift b/src/IMessage/Sources/IMessageCore/Retry.swift index d8aa59b6..cbde36f3 100644 --- a/src/IMessage/Sources/IMessageCore/Retry.swift +++ b/src/IMessage/Sources/IMessageCore/Retry.swift @@ -63,6 +63,7 @@ public func retry( var res: Result! var attempt = 0 repeat { + await Task.yield() try Task.checkCancellation() do { return try await perform() From 39d1e43976d83a5e056b19e8cbd7ae98f568da5e Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:32:45 +0530 Subject: [PATCH 35/59] - --- .../Messages/MessagesAppElements.swift | 8 +- .../Messages/MessagesController.swift | 85 +++++++++++++------ .../MessagesControllerAutomationLane.swift | 7 +- .../PlatformAPI+MessagesController.swift | 14 +++ .../EclipsingWindowCoordinator.swift | 2 +- .../SpacesWindowCoordinator.swift | 2 +- .../WindowCoordinator.swift | 10 ++- ...essagesControllerAutomationLaneTests.swift | 78 ++++++++++++----- .../Sources/IMessageTests/RetryTests.swift | 27 ++++++ 9 files changed, 177 insertions(+), 56 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index bad32b77..b2192412 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: (URL) 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) { + init(runningApp: NSRunningApplication, openDeepLink: @escaping (URL) async throws -> Void) { self.runningApp = runningApp self.openDeepLink = openDeepLink app = Accessibility.Element(pid: runningApp.processIdentifier) @@ -280,7 +280,9 @@ final class MessagesAppElements { } 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.url()) } else if attempt == 1 { if self.isPromptVisibleInMessagesApp() { log.notice("mainWindow: some prompts are visible, attempting to reset") diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index d919bde6..22a808a2 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -162,20 +162,6 @@ final class MessagesController { } } - static func requestDeepLinkOpen( - _ url: URL, - activating: Bool = false, - hiding: Bool = true, - targeting app: NSRunningApplication? = nil - ) throws { - switch try planDeepLinkOpen(url, activating: activating, hiding: hiding, targeting: app) { - case .handledBySecondaryInstance: - return - case .open(let openOptions): - NSWorkspace.shared.open(url, configuration: openOptions) - } - } - private static func logDeepLinkOpen(_ url: URL, activating: Bool, hiding: Bool) { #if DEBUG let builtForDebugging = true @@ -306,7 +292,10 @@ final class MessagesController { try await Task.sleep(forTimeInterval: 0.1) } log.info("launching messages... (without activation? \(withoutActivation))") - return try await 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.url(), activating: !withoutActivation, timeout: 30) } if Preferences.useSecondaryMessagesInstance { @@ -349,7 +338,9 @@ final class MessagesController { try await selectedApp.waitForLaunch() elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in - try Self.requestDeepLinkOpen(url, targeting: selectedApp) + // Awaitable (no longer fire-and-forget): callers can await the open completing + // rather than relying on a fixed sleep to paper over the race. + try await Self.openDeepLink(url, targeting: selectedApp) }) keyPresser = KeyPresser(pid: selectedApp.processIdentifier) @@ -403,7 +394,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) let app = self.app do { - let window = try self.elements.currentMainWindow.orThrow(ErrorMessage("main window not found")) + // 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)" @@ -480,26 +475,49 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) cancelReplyTranscriptViewTask?.cancel() log.debug("withAutomation: making the app automatable") + // If makeAutomatable partially succeeds then throws, its window manipulation + // (e.g. eclipsing) would otherwise be left in place because the matching + // automationDidComplete (window restore) cleanup below would be skipped. Run the + // cleanup on the throw path before rethrowing so window state is always restored. + // A `defer` can't be used because the cleanup is `async`. + var madeAutomatable = false if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { - try await windowCoordinator.makeAutomatable(mainWindow) + do { + try await windowCoordinator.makeAutomatable(mainWindow) + madeAutomatable = true + } catch { + await automationDidComplete() + scheduleCancelReplyTranscriptView() + throw error + } } + _ = madeAutomatable // set for clarity / future use; cleanup is keyed on the catch above let result = await Result(catching: operation) log.info("withAutomation: finished") - if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { - do { - try await windowCoordinator.automationDidComplete(mainWindow) - } catch { - log.error("failed to call automationDidComplete on window coordinator: \(String(reflecting: error))") - } - } + 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() } + /// Runs the window coordinator's automation-complete (window restore) step. + /// Factored out so both the success path and the `makeAutomatable`-failed cleanup + /// path call it identically. + private func automationDidComplete() async { + // Preserve the original guard (only run cleanup when coordinating and a main + // window is present), but don't pass the non-Sendable AX element 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 var cancelReplyTranscriptViewTask: Task? private func scheduleCancelReplyTranscriptView() { @@ -1400,9 +1418,18 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // 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 { try await PlatformAPI.runOnMessagesControllerLane { [weak self] in guard let self else { return } + 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) @@ -1426,9 +1453,17 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // 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 { 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 { diff --git a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift index 8f94d137..8e1900a8 100644 --- a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift +++ b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift @@ -102,14 +102,17 @@ actor MessagesControllerAutomationLane { let expectedEpoch = idleEpoch let idleDelay = idleDelay - idleTask = Task { + idleTask = Task { [weak self] in do { try await Task.sleep(forTimeInterval: idleDelay) } catch { return } - let task = self.enqueuePassiveIdleCallback(expectedEpoch: expectedEpoch) + 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 } } diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 202f190a..91668b2b 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -27,7 +27,12 @@ private actor MessagesControllerCoordinator { 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 { @@ -43,7 +48,16 @@ private actor MessagesControllerCoordinator { } 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 } } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift index 81b6ca7d..d8ca216d 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/EclipsingWindowCoordinator.swift @@ -130,7 +130,7 @@ final class EclipsingWindowCoordinator: WindowCoordinator { } } - func automationDidComplete(_: Accessibility.Element) throws { + func automationDidComplete() throws { hideDebouncer.requestHide() } diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift index 7edb1916..441e370e 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/SpacesWindowCoordinator.swift @@ -80,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 0d785981..c76ed800 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/WindowCoordinator.swift @@ -24,9 +24,15 @@ protocol WindowCoordinator: AnyObject { */ 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. */ + /** + * 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(_ window: Accessibility.Element) throws + func automationDidComplete() throws /** * Reverts the manipulations performed in `makeAutomatable`. diff --git a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift index 8eaf253f..516191cb 100644 --- a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift +++ b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift @@ -49,7 +49,8 @@ private struct Boom: Error {} } @Test func laneIdleFiresAfterWorkDrains() async throws { - let lane = MessagesControllerAutomationLane(idleDelay: 0.02) + // idleDelay raised to 0.1 to de-flake (wide margin under the 2s eventually timeout). + let lane = MessagesControllerAutomationLane(idleDelay: 0.1) let idleCount = Protected(0) await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } @@ -59,7 +60,8 @@ private struct Boom: Error {} } @Test func laneIdleRepeatsWhileQuiet() async throws { - let lane = MessagesControllerAutomationLane(idleDelay: 0.02) + // idleDelay raised to 0.1 to de-flake (wide margin under the 2s eventually timeout). + let lane = MessagesControllerAutomationLane(idleDelay: 0.1) let idleCount = Protected(0) await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } @@ -68,37 +70,68 @@ private struct Boom: Error {} #expect(await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 2 }) } -@Test func laneIdleDoesNotFireWhileWorkInFlight() async throws { - let lane = MessagesControllerAutomationLane(idleDelay: 0.02) - let idleFiredDuringWork = Protected(false) - let workRunning = Protected(false) - await lane.setIdleCallback { - if workRunning.read() { idleFiredDuringWork.withLock { $0 = true } } - } - - // work runs far longer than idleDelay; the idle callback must not interleave - try await lane.run { - workRunning.withLock { $0 = true } - try await Task.sleep(nanoseconds: 80_000_000) // 80ms >> 20ms idleDelay - workRunning.withLock { $0 = false } - } +@Test func laneStaleIdleIsSuppressedByNewWorkEpochBump() async throws { + // Exercises the epoch guard (`idleCallbackIfStillCurrent`) and the epoch bump in + // `activeWorkSubmitted`. Sequence: + // 1. drain work -> an idle callback is scheduled for epoch E (sleeps idleDelay) + // 2. before idleDelay elapses, submit new work -> bumps idleEpoch past E and + // cancels the in-flight idle, so the epoch-E idle is now stale + // 3. drain that work -> a fresh idle is scheduled for the new epoch + // 4. wait idleDelay * 2 -> only the fresh idle should ever fire; the stale + // epoch-E idle must have been suppressed (no double-counting from two cycles) + let idleDelay: TimeInterval = 0.1 // de-flaked: wide margin under the 2s timeout + 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() - #expect(idleFiredDuringWork.read() == false) + // (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 lane = MessagesControllerAutomationLane(idleDelay: 0.02) + // idleDelay raised to 0.1 to de-flake; instead of a fixed sleep we wait for several + // idle cycles, clear the callback, then wait several more idle periods and assert the + // count is frozen. + 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 {} - _ = await eventually(timeout: 2, pollInterval: 0.005) { idleCount.read() >= 1 } + + // 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 idle periods elapse; nothing more should fire - try? await Task.sleep(nanoseconds: 120_000_000) // 120ms + // 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) } @@ -187,7 +220,8 @@ private struct Boom: Error {} @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.02) + // idleDelay raised to 0.1 to de-flake (wide margin under the 2s eventually timeout). + let lane = MessagesControllerAutomationLane(idleDelay: 0.1) let firstCallbackFired = Protected(false) let secondCallbackFired = Protected(false) diff --git a/src/IMessage/Sources/IMessageTests/RetryTests.swift b/src/IMessage/Sources/IMessageTests/RetryTests.swift index 8cb434c6..402609d1 100644 --- a/src/IMessage/Sources/IMessageTests/RetryTests.swift +++ b/src/IMessage/Sources/IMessageTests/RetryTests.swift @@ -70,6 +70,33 @@ private struct RetryTestError: Error, Equatable { let id: Int } #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) { From 38594172a5bb0c2cd7b26e3cf7919f8562113bea Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:45:28 +0530 Subject: [PATCH 36/59] - --- .../Messages/MessagesController.swift | 19 +++++-------------- ...essagesControllerAutomationLaneTests.swift | 18 +++--------------- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 22a808a2..5e2b3310 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -475,23 +475,18 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) cancelReplyTranscriptViewTask?.cancel() log.debug("withAutomation: making the app automatable") - // If makeAutomatable partially succeeds then throws, its window manipulation - // (e.g. eclipsing) would otherwise be left in place because the matching - // automationDidComplete (window restore) cleanup below would be skipped. Run the - // cleanup on the throw path before rethrowing so window state is always restored. - // A `defer` can't be used because the cleanup is `async`. - var madeAutomatable = false + // 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 await windowCoordinator.makeAutomatable(mainWindow) - madeAutomatable = true } catch { await automationDidComplete() scheduleCancelReplyTranscriptView() throw error } } - _ = madeAutomatable // set for clarity / future use; cleanup is keyed on the catch above let result = await Result(catching: operation) @@ -503,13 +498,9 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) return try result.get() } - /// Runs the window coordinator's automation-complete (window restore) step. - /// Factored out so both the success path and the `makeAutomatable`-failed cleanup - /// path call it identically. + /// Window-restore step, shared by the success and makeAutomatable-failed paths. private func automationDidComplete() async { - // Preserve the original guard (only run cleanup when coordinating and a main - // window is present), but don't pass the non-Sendable AX element across the - // actor boundary — the coordinators don't use it. + // 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() diff --git a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift index 516191cb..7af7d9e8 100644 --- a/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift +++ b/src/IMessage/Sources/IMessageTests/MessagesControllerAutomationLaneTests.swift @@ -49,7 +49,6 @@ private struct Boom: Error {} } @Test func laneIdleFiresAfterWorkDrains() async throws { - // idleDelay raised to 0.1 to de-flake (wide margin under the 2s eventually timeout). let lane = MessagesControllerAutomationLane(idleDelay: 0.1) let idleCount = Protected(0) await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } @@ -60,7 +59,6 @@ private struct Boom: Error {} } @Test func laneIdleRepeatsWhileQuiet() async throws { - // idleDelay raised to 0.1 to de-flake (wide margin under the 2s eventually timeout). let lane = MessagesControllerAutomationLane(idleDelay: 0.1) let idleCount = Protected(0) await lane.setIdleCallback { idleCount.withLock { $0 += 1 } } @@ -71,15 +69,9 @@ private struct Boom: Error {} } @Test func laneStaleIdleIsSuppressedByNewWorkEpochBump() async throws { - // Exercises the epoch guard (`idleCallbackIfStillCurrent`) and the epoch bump in - // `activeWorkSubmitted`. Sequence: - // 1. drain work -> an idle callback is scheduled for epoch E (sleeps idleDelay) - // 2. before idleDelay elapses, submit new work -> bumps idleEpoch past E and - // cancels the in-flight idle, so the epoch-E idle is now stale - // 3. drain that work -> a fresh idle is scheduled for the new epoch - // 4. wait idleDelay * 2 -> only the fresh idle should ever fire; the stale - // epoch-E idle must have been suppressed (no double-counting from two cycles) - let idleDelay: TimeInterval = 0.1 // de-flaked: wide margin under the 2s timeout + // 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. @@ -114,9 +106,6 @@ private struct Boom: Error {} } @Test func laneClearingIdleCallbackStopsFurtherIdleWork() async throws { - // idleDelay raised to 0.1 to de-flake; instead of a fixed sleep we wait for several - // idle cycles, clear the callback, then wait several more idle periods and assert the - // count is frozen. let idleDelay: TimeInterval = 0.1 let lane = MessagesControllerAutomationLane(idleDelay: idleDelay) let idleCount = Protected(0) @@ -220,7 +209,6 @@ private struct Boom: Error {} @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. - // idleDelay raised to 0.1 to de-flake (wide margin under the 2s eventually timeout). let lane = MessagesControllerAutomationLane(idleDelay: 0.1) let firstCallbackFired = Protected(false) let secondCallbackFired = Protected(false) From 4ee29402b267231df0657979250e79b082a8a379 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:43:40 +0530 Subject: [PATCH 37/59] - --- src/IMessage/Sources/IMessage/Messages/MessagesController.swift | 2 -- .../Sources/IMessage/PlatformAPI+MessagesController.swift | 1 - 2 files changed, 3 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 5e2b3310..37a0efe6 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -338,8 +338,6 @@ final class MessagesController { try await selectedApp.waitForLaunch() elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in - // Awaitable (no longer fire-and-forget): callers can await the open completing - // rather than relying on a fixed sleep to paper over the race. try await Self.openDeepLink(url, targeting: selectedApp) }) diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 91668b2b..1766782c 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -11,7 +11,6 @@ private enum MessagesControllerCoordinatorError: Error { case pendingControllerInvalidated } - private actor MessagesControllerCoordinator { private var current: MessagesControllerEntry? // Actors are reentrant across awaits, so concurrent callers share one in-flight construction. From 54348356f80f6bf6c969ab657bd3ed8a30b9c284 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:44:05 +0530 Subject: [PATCH 38/59] - --- .../IMessage/Messages/MessagesAppElements.swift | 4 ++-- .../IMessage/Messages/MessagesController.swift | 15 +++++---------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index b2192412..ce06292b 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -396,10 +396,10 @@ final class MessagesAppElements { let predicate = { (el: Accessibility.Element) -> Bool in (try? el.identifier()) == "TranscriptCollectionView" && isReplyTranscriptView(el) == replyTranscript } - // takes ~8ms - if let transcriptView = try? await mainWindowSections.first(where: predicate) { return transcriptView } // takes ~19ms 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") } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 37a0efe6..4e777d38 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -685,20 +685,21 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if messageCell.overlay { try await waitUntilReplyTranscriptVisible() } - guard let selected = (try await retry(withTimeout: 1, interval: 0.2) { () async throws -> Accessibility.Element? in + 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 { - let transcript = try await elements.replyTranscriptView + transcript = try await elements.replyTranscriptView cell = try MessagesAppElements.firstMessageCell(in: transcript) } else { - let transcript = try await elements.transcriptView + 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") } @@ -708,12 +709,6 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } else { let containerCell = try selected.parent() let containerFrame = try containerCell.frame() - let transcript: Accessibility.Element - if messageCell.overlay { - transcript = try await elements.replyTranscriptView - } else { - transcript = try await elements.transcriptView - } let containerCells = try MessagesAppElements.messageContainerCells( in: transcript ) From 2a4da1a4d4c9698275aed86d871cff1f5cca4587 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:14:10 +0530 Subject: [PATCH 39/59] Update MessagesController.swift --- src/IMessage/Sources/IMessage/Messages/MessagesController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index fbf45533..2a4c7cc2 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -156,7 +156,6 @@ final class MessagesController { try Task.checkCancellation() switch try planDeepLinkOpen(url, activating: activating, hiding: hiding, targeting: app) { case .handledBySecondaryInstance(let app): - try Task.checkCancellation() return app case .open(let openOptions): return try await NSWorkspace.shared.open(url, configuration: openOptions, timeout: timeout) From aee5d5a3215d9885053d6c2fb114a1c340173f38 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Wed, 3 Jun 2026 17:04:09 +0530 Subject: [PATCH 40/59] use async method --- .../Sources/IMessage/PromptAutomation.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/IMessage/Sources/IMessage/PromptAutomation.swift b/src/IMessage/Sources/IMessage/PromptAutomation.swift index 76f4e520..047c9eb6 100644 --- a/src/IMessage/Sources/IMessage/PromptAutomation.swift +++ b/src/IMessage/Sources/IMessage/PromptAutomation.swift @@ -66,12 +66,19 @@ enum PromptAutomation { } static func disableNotificationsForApp(named appName: String) async throws -> Bool { - let app = try NSWorkspace.shared.open( + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + + // hides shows a gray background and doesn't render the UI + configuration.hides = true + + 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 await app.waitForLaunch() + return try await retry(withTimeout: 3, interval: 0.1) { let appElement = Accessibility.Element(pid: app.processIdentifier) let windows = try appElement.appWindows() From b82a884c070b94dfdf31824e5703cd71a9df4431 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Wed, 3 Jun 2026 17:04:22 +0530 Subject: [PATCH 41/59] refactor --- .../Messages/CharacterPickerPopover.swift | 73 +++++++++++++++++++ .../Messages/MessagesAppElements.swift | 71 ------------------ 2 files changed, 73 insertions(+), 71 deletions(-) create mode 100644 src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift diff --git a/src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift b/src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift new file mode 100644 index 00000000..f17920da --- /dev/null +++ b/src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift @@ -0,0 +1,73 @@ +import AppKit +import AccessibilityControl +import WindowControl + +struct CharacterPickerPopover { + static let expectedSize = CGSize(width: 346, height: 470) + static let searchFieldTopInset: CGFloat = 38 + static let windowWidthRange: ClosedRange = 300...420 + static let windowHeightRange: ClosedRange = 420...560 + + let app: Accessibility.Element + let ownerPID: pid_t + + func detachedPopover() throws -> Accessibility.Element? { + try windowCandidates() + .lazy + .compactMap(popover(forWindow:)) + .first + } + + func searchField(in popover: Accessibility.Element) -> Accessibility.Element? { + if let childrenInNavigationOrder = try? popover.childrenInNavigationOrder(), + let searchField = childrenInNavigationOrder.first(where: Self.isSearchTextField) { + return searchField + } + + if let searchField = try? popover.children().first(where: Self.isSearchTextField) { + return searchField + } + + return popover.recursiveChildren().lazy.first(where: Self.isSearchTextField) + } + + static func isPopover(_ element: Accessibility.Element) -> Bool { + (try? element.role()) == Accessibility.Role.popover || + (try? element.roleDescription()) == "popover" + } + + private func popover(forWindow window: Window.Description) -> Accessibility.Element? { + let searchPoint = CGPoint(x: window.bounds.midX, y: window.bounds.minY + Self.searchFieldTopInset) + guard let hit = app.elementAtScreenPoint(searchPoint), + Self.isSearchTextField(hit), + let popover = try? hit.parent(), + Self.isPopover(popover) + else { return nil } + + return popover + } + + private func windowCandidates() throws -> [Window.Description] { + try Window.listDescriptions(.all, excludeDesktopElements: true) + .filter(isCandidateWindow) + .sorted { score($0) < score($1) } + } + + private func isCandidateWindow(_ description: Window.Description) -> Bool { + description.owner == ownerPID && + description.isOnscreen == true && + description.alpha > 0 && + Self.windowWidthRange.contains(description.bounds.width) && + Self.windowHeightRange.contains(description.bounds.height) + } + + private func score(_ description: Window.Description) -> CGFloat { + abs(description.bounds.width - Self.expectedSize.width) + + abs(description.bounds.height - Self.expectedSize.height) + } + + private static func isSearchTextField(_ element: Accessibility.Element) -> Bool { + (try? element.subrole()) == Accessibility.Subrole.searchField || + (try? element.roleDescription()) == "search text field" + } +} diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index ce06292b..7e67de62 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -2,80 +2,9 @@ import AppKit import AccessibilityControl import IMessageCore import Logging -import WindowControl private let log = Logger(imessageLabel: "app-elements") -private struct CharacterPickerPopover { - static let expectedSize = CGSize(width: 346, height: 470) - static let searchFieldTopInset: CGFloat = 38 - static let windowWidthRange: ClosedRange = 300...420 - static let windowHeightRange: ClosedRange = 420...560 - - let app: Accessibility.Element - let ownerPID: pid_t - - func detachedPopover() throws -> Accessibility.Element? { - try windowCandidates() - .lazy - .compactMap(popover(forWindow:)) - .first - } - - func searchField(in popover: Accessibility.Element) -> Accessibility.Element? { - if let childrenInNavigationOrder = try? popover.childrenInNavigationOrder(), - let searchField = childrenInNavigationOrder.first(where: Self.isSearchTextField) { - return searchField - } - - if let searchField = try? popover.children().first(where: Self.isSearchTextField) { - return searchField - } - - return popover.recursiveChildren().lazy.first(where: Self.isSearchTextField) - } - - static func isPopover(_ element: Accessibility.Element) -> Bool { - (try? element.role()) == Accessibility.Role.popover || - (try? element.roleDescription()) == "popover" - } - - private func popover(forWindow window: Window.Description) -> Accessibility.Element? { - let searchPoint = CGPoint(x: window.bounds.midX, y: window.bounds.minY + Self.searchFieldTopInset) - guard let hit = app.elementAtScreenPoint(searchPoint), - Self.isSearchTextField(hit), - let popover = try? hit.parent(), - Self.isPopover(popover) - else { return nil } - - return popover - } - - private func windowCandidates() throws -> [Window.Description] { - try Window.listDescriptions(.all, excludeDesktopElements: true) - .filter(isCandidateWindow) - .sorted { score($0) < score($1) } - } - - private func isCandidateWindow(_ description: Window.Description) -> Bool { - description.owner == ownerPID && - description.isOnscreen == true && - description.alpha > 0 && - Self.windowWidthRange.contains(description.bounds.width) && - Self.windowHeightRange.contains(description.bounds.height) - } - - private func score(_ description: Window.Description) -> CGFloat { - abs(description.bounds.width - Self.expectedSize.width) + - abs(description.bounds.height - Self.expectedSize.height) - } - - private static func isSearchTextField(_ element: Accessibility.Element) -> Bool { - (try? element.subrole()) == Accessibility.Subrole.searchField || - (try? element.roleDescription()) == "search text field" - } -} - struct ElementSearchError: Error, CustomStringConvertible { /// What was being searched for let name: String From 04113783ecda5505f3fb4c998307e0065db21eaa Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:05:26 +0530 Subject: [PATCH 42/59] Revert "refactor" This reverts commit b82a884c070b94dfdf31824e5703cd71a9df4431. --- .../Messages/CharacterPickerPopover.swift | 73 ------------------- .../Messages/MessagesAppElements.swift | 71 ++++++++++++++++++ 2 files changed, 71 insertions(+), 73 deletions(-) delete mode 100644 src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift diff --git a/src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift b/src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift deleted file mode 100644 index f17920da..00000000 --- a/src/IMessage/Sources/IMessage/Messages/CharacterPickerPopover.swift +++ /dev/null @@ -1,73 +0,0 @@ -import AppKit -import AccessibilityControl -import WindowControl - -struct CharacterPickerPopover { - static let expectedSize = CGSize(width: 346, height: 470) - static let searchFieldTopInset: CGFloat = 38 - static let windowWidthRange: ClosedRange = 300...420 - static let windowHeightRange: ClosedRange = 420...560 - - let app: Accessibility.Element - let ownerPID: pid_t - - func detachedPopover() throws -> Accessibility.Element? { - try windowCandidates() - .lazy - .compactMap(popover(forWindow:)) - .first - } - - func searchField(in popover: Accessibility.Element) -> Accessibility.Element? { - if let childrenInNavigationOrder = try? popover.childrenInNavigationOrder(), - let searchField = childrenInNavigationOrder.first(where: Self.isSearchTextField) { - return searchField - } - - if let searchField = try? popover.children().first(where: Self.isSearchTextField) { - return searchField - } - - return popover.recursiveChildren().lazy.first(where: Self.isSearchTextField) - } - - static func isPopover(_ element: Accessibility.Element) -> Bool { - (try? element.role()) == Accessibility.Role.popover || - (try? element.roleDescription()) == "popover" - } - - private func popover(forWindow window: Window.Description) -> Accessibility.Element? { - let searchPoint = CGPoint(x: window.bounds.midX, y: window.bounds.minY + Self.searchFieldTopInset) - guard let hit = app.elementAtScreenPoint(searchPoint), - Self.isSearchTextField(hit), - let popover = try? hit.parent(), - Self.isPopover(popover) - else { return nil } - - return popover - } - - private func windowCandidates() throws -> [Window.Description] { - try Window.listDescriptions(.all, excludeDesktopElements: true) - .filter(isCandidateWindow) - .sorted { score($0) < score($1) } - } - - private func isCandidateWindow(_ description: Window.Description) -> Bool { - description.owner == ownerPID && - description.isOnscreen == true && - description.alpha > 0 && - Self.windowWidthRange.contains(description.bounds.width) && - Self.windowHeightRange.contains(description.bounds.height) - } - - private func score(_ description: Window.Description) -> CGFloat { - abs(description.bounds.width - Self.expectedSize.width) + - abs(description.bounds.height - Self.expectedSize.height) - } - - private static func isSearchTextField(_ element: Accessibility.Element) -> Bool { - (try? element.subrole()) == Accessibility.Subrole.searchField || - (try? element.roleDescription()) == "search text field" - } -} diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 7e67de62..ce06292b 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -2,9 +2,80 @@ import AppKit import AccessibilityControl import IMessageCore import Logging +import WindowControl private let log = Logger(imessageLabel: "app-elements") +private struct CharacterPickerPopover { + static let expectedSize = CGSize(width: 346, height: 470) + static let searchFieldTopInset: CGFloat = 38 + static let windowWidthRange: ClosedRange = 300...420 + static let windowHeightRange: ClosedRange = 420...560 + + let app: Accessibility.Element + let ownerPID: pid_t + + func detachedPopover() throws -> Accessibility.Element? { + try windowCandidates() + .lazy + .compactMap(popover(forWindow:)) + .first + } + + func searchField(in popover: Accessibility.Element) -> Accessibility.Element? { + if let childrenInNavigationOrder = try? popover.childrenInNavigationOrder(), + let searchField = childrenInNavigationOrder.first(where: Self.isSearchTextField) { + return searchField + } + + if let searchField = try? popover.children().first(where: Self.isSearchTextField) { + return searchField + } + + return popover.recursiveChildren().lazy.first(where: Self.isSearchTextField) + } + + static func isPopover(_ element: Accessibility.Element) -> Bool { + (try? element.role()) == Accessibility.Role.popover || + (try? element.roleDescription()) == "popover" + } + + private func popover(forWindow window: Window.Description) -> Accessibility.Element? { + let searchPoint = CGPoint(x: window.bounds.midX, y: window.bounds.minY + Self.searchFieldTopInset) + guard let hit = app.elementAtScreenPoint(searchPoint), + Self.isSearchTextField(hit), + let popover = try? hit.parent(), + Self.isPopover(popover) + else { return nil } + + return popover + } + + private func windowCandidates() throws -> [Window.Description] { + try Window.listDescriptions(.all, excludeDesktopElements: true) + .filter(isCandidateWindow) + .sorted { score($0) < score($1) } + } + + private func isCandidateWindow(_ description: Window.Description) -> Bool { + description.owner == ownerPID && + description.isOnscreen == true && + description.alpha > 0 && + Self.windowWidthRange.contains(description.bounds.width) && + Self.windowHeightRange.contains(description.bounds.height) + } + + private func score(_ description: Window.Description) -> CGFloat { + abs(description.bounds.width - Self.expectedSize.width) + + abs(description.bounds.height - Self.expectedSize.height) + } + + private static func isSearchTextField(_ element: Accessibility.Element) -> Bool { + (try? element.subrole()) == Accessibility.Subrole.searchField || + (try? element.roleDescription()) == "search text field" + } +} + struct ElementSearchError: Error, CustomStringConvertible { /// What was being searched for let name: String From 49e129b71b91f8c96159f8dcc458fce655203da1 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Thu, 4 Jun 2026 01:04:38 +0530 Subject: [PATCH 43/59] wip --- .../MessagesApplication+Window.swift | 18 + .../MessagesApplication+WindowElements.swift | 317 ++++++++++++++++++ .../MessagesApplication.swift | 172 ++++++++++ .../MessagesControllerAutomationLane.swift | 1 + .../Sources/IMessage/Pasteboard+Backup.swift | 1 + 5 files changed, 509 insertions(+) create mode 100644 src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift create mode 100644 src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift create mode 100644 src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift new file mode 100644 index 00000000..f0df3a26 --- /dev/null +++ b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift @@ -0,0 +1,18 @@ +import AccessibilityControl +import AppKit + +extension MessagesApplication { + final class Window { + let application: MessagesApplication + let element: Accessibility.Element + + init(parent application: MessagesApplication, element: Accessibility.Element) { + self.application = application + self.element = element + } + + var processIdentifier: pid_t { + application.processIdentifier + } + } +} diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift new file mode 100644 index 00000000..071d7118 --- /dev/null +++ b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift @@ -0,0 +1,317 @@ +import AccessibilityControl +import AppKit +import IMessageCore +import Logging + +private let messagesApplicationWindowLog = Logger(imessageLabel: "messages-window") + +extension MessagesApplication { + struct ElementLookupError: Error, CustomStringConvertible { + let name: String + let underlyingErrors: [Error] + let dumpID: String? + + var description: String { + var desc = "\(name) not found" + if !underlyingErrors.isEmpty { + desc += " - underlying: " + underlyingErrors.map(String.init(describing:)).joined(separator: "; ") + } + if let dumpID { + desc += " (AX dump ID: \(dumpID))" + } + return desc + } + } +} + +extension MessagesApplication.Window { + static func isMessageContainerCell(_ element: Accessibility.Element) throws -> Bool { + let hasDescription = try !element.localizedDescription().isEmpty + guard hasDescription else { return false } + + return try element.children[0].supportedActions().contains { + $0.name.value.hasPrefix("Name:\(LocalizedStrings.react)") + } + } + + static func messageContainerCells(in transcriptView: Accessibility.Element) throws -> [Accessibility.Element] { + try transcriptView.children().filter { (try? isMessageContainerCell($0)) ?? false } + } + + static func firstMessageCell(in transcriptView: Accessibility.Element) throws -> Accessibility.Element? { + try transcriptView.children().first { (try? isMessageContainerCell($0)) ?? false }?.children[0] + } + + static func firstSelectedMessageCell(in transcriptView: Accessibility.Element) throws -> Accessibility.Element? { + try transcriptView.children().first { (try? $0.children[0].isSelected()) == true }?.children[0] + } + + static func threadActivityCells(in transcriptView: Accessibility.Element) throws -> [Accessibility.Element] { + let count = try transcriptView.children.count() + guard count > 0 else { return [] } + + let lastN = isSequoiaOrUp ? 10 : (isMontereyOrUp ? 3 : 1) + return try transcriptView.children(range: (count - min(count, lastN)).. Accessibility.Element? + ) throws -> Accessibility.Element { + let startTime = logTime ? Date() : nil + defer { + if let startTime { + messagesApplicationWindowLog.debug("\(name) took \(startTime.elapsedMilliseconds)ms") + } + } + + var errors: [Error] = [] + do { + if let result = try search() { + return result + } + } catch { + errors.append(error) + } + + var dumpID: String? + if dumpOnError { + let id = String(UUID().uuidString.prefix(8)).lowercased() + dumpID = id + do { + var buffer = "" + try (root ?? element).dumpXML(to: &buffer, maxDepth: 10, excludingPII: true, includeActions: false, includeSections: true) + messagesApplicationWindowLog.error("[\(id)] AX dump for \(name):\n\(buffer)") + } catch { + messagesApplicationWindowLog.error("[\(id)] failed to dump AX tree for \(name): \(error)") + errors.append(error) + } + } + + throw MessagesApplication.ElementLookupError(name: name, underlyingErrors: errors, dumpID: dumpID) + } + + func sectionObjects() throws -> [Accessibility.Element] { + try application.sectionObjects(in: element) + } + + func conversationList() throws -> Accessibility.Element { + try retry(withTimeout: 1, interval: 0.1) { + try application.conversationList(in: element, useFastPath: true) + .orThrow(ErrorMessage("ConversationList not found")) + } onError: { _, _ in + let searchField = try self.searchField() + messagesApplicationWindowLog.error("fetching ConversationList errored, calling searchField.cancel") + try searchField.cancel() + } + } + + func selectedThreadCell() throws -> Accessibility.Element? { + try conversationList().selectedChildren[0] + } + + func transcriptView(replyTranscript: Bool) throws -> Accessibility.Element { + let startTime = Date() + defer { + messagesApplicationWindowLog.debug("transcriptView(replyTranscript: \(replyTranscript)) took \(startTime.elapsedMilliseconds)ms") + } + + func isReplyTranscriptView(_ element: Accessibility.Element) -> Bool { + (try? element.localizedDescription()) == LocalizedStrings.replyTranscript + } + + let predicate = { (element: Accessibility.Element) -> Bool in + (try? element.identifier()) == "TranscriptCollectionView" && isReplyTranscriptView(element) == replyTranscript + } + + if let transcriptView = try? sectionObjects().first(where: predicate) { + return transcriptView + } + if let transcriptView = element.recursiveChildren().lazy.first(where: predicate) { + return transcriptView + } + throw ErrorMessage("TranscriptCollectionView(replyTranscript: \(replyTranscript)) not found") + } + + func transcriptView() throws -> Accessibility.Element { + try transcriptView(replyTranscript: false) + } + + func replyTranscriptView() throws -> Accessibility.Element { + try transcriptView(replyTranscript: true) + } + + func messageBodyField() throws -> Accessibility.Element { + let startTime = Date() + defer { messagesApplicationWindowLog.debug("messageBodyField took \(startTime.elapsedMilliseconds)ms") } + var alternate = false + return try retry(withTimeout: 1.5, interval: 0.1) { + if alternate { + return try element.recursivelyFindChild(withID: "messageBodyField") + .orThrow(ErrorMessage("messageBodyField not found")) + } + + return try sectionObjects() + .first { (try? $0.identifier()) == "messageBodyField" } + .orThrow(ErrorMessage("messageBodyField not found")) + } onError: { attempt, _ in + alternate = attempt % 2 == 0 + } + } + + func searchField() throws -> Accessibility.Element { + let startTime = Date() + defer { messagesApplicationWindowLog.debug("searchField took \(startTime.elapsedMilliseconds)ms") } + return try retry(withTimeout: 1, interval: 0.1) { + let conversationListView = try application.ckConversationListCollectionView(in: element) + .orThrow(ErrorMessage("CKConversationListCollectionView not found")) + return try conversationListView.children() + .first { (try? $0.subrole()) == Accessibility.Subrole.searchField } + .orThrow(ErrorMessage("searchField not found")) + } + } + + func iOSContentGroup() throws -> Accessibility.Element { + try find("iOSContentGroup") { + try element.children() + .first(where: { (try? $0.subrole()) == "iOSContentGroup" && (try? $0.role()) == NSAccessibility.Role.group.rawValue }) + } + } + + func iOSContentGroupFirstChild() throws -> Accessibility.Element { + try find("iOSContentGroupFirstChild", logTime: true) { + try iOSContentGroup().children[0] + } + } + + func addCustomEmojiReactionButton() throws -> Accessibility.Element { + let element = try (try? iOSContentGroupFirstChild())?.children().first { + (try? $0.identifier()) == nil && (try? $0.role()) == "AXButton" + } + return try element.orThrow(ErrorMessage("couldn't find button to add custom emoji reaction")) + } + + func characterPickerPopover() throws -> Accessibility.Element { + try find("characterPickerPopover") { + if let attachedPopover = element.recursiveChildren().lazy.first(where: CharacterPickerPopover.isPopover) { + return attachedPopover + } + + return try CharacterPickerPopover(app: application.accessibilityElement, ownerPID: processIdentifier).detachedPopover() + } + } + + func characterPickerSearchField() throws -> Accessibility.Element { + try find("characterPickerSearchField") { + try CharacterPickerPopover(app: application.accessibilityElement, ownerPID: processIdentifier) + .searchField(in: characterPickerPopover()) + } + } + + func splitter() throws -> Accessibility.Element { + try find("splitter", logTime: true) { + try iOSContentGroupFirstChild().children() + .first(where: { (try? $0.role()) == Accessibility.Role.splitter }) + } + } + + func reactionsView() throws -> Accessibility.Element { + let startTime = Date() + defer { messagesApplicationWindowLog.debug("reactionsView took \(startTime.elapsedMilliseconds)ms") } + return try retry(withTimeout: 1.5, interval: 0.1) { + let view = try iOSContentGroupFirstChild() + guard (try? view.children.count()) ?? 0 > 0 else { + throw ErrorMessage("reactionsView not found") + } + return view + } + } + + func reactButtons() throws -> [Accessibility.Element] { + let startTime = Date() + defer { messagesApplicationWindowLog.debug("reactButtons took \(startTime.elapsedMilliseconds)ms") } + guard let buttons = try? reactionsView().children().filter({ (try? $0.role()) == Accessibility.Role.button }) else { + throw ErrorMessage("reactButtons not found") + } + return buttons + } + + func tapbackPickerCollectionView() throws -> Accessibility.Element { + let startTime = Date() + defer { messagesApplicationWindowLog.debug("tapbackPickerCollectionView took \(startTime.elapsedMilliseconds)ms") } + guard let element = try? reactionsView().children().first(where: { (try? $0.identifier()) == "TapbackPickerCollectionView" }) else { + throw ErrorMessage("tapbackPickerCollectionView not found") + } + return element + } + + func alertSheet() throws -> Accessibility.Element { + try find("alertSheet") { + try element.children().first(where: { try $0.role() == Accessibility.Role.sheet }) + } + } + + func alertSheetDeleteButton() throws -> Accessibility.Element { + try find("alertSheetDeleteButton") { + try alertSheet().children().first(where: { try $0.role() == Accessibility.Role.button }) + } + } + + func notifyAnywayButton() throws -> Accessibility.Element { + let startTime = Date() + defer { messagesApplicationWindowLog.debug("notifyAnywayButton took \(startTime.elapsedMilliseconds)ms") } + let cells = try Self.threadActivityCells(in: transcriptView()) + return try cells.lazy.reversed().compactMap { + guard let child = try? $0.children[0], + (try? child.role()) == Accessibility.Role.button, + (try? child.localizedDescription()) == LocalizedStrings.notifyAnyway else { + return nil + } + return child + }.first.orThrow(ErrorMessage("notifyAnywayButton not found")) + } + + func editableMessageField() throws -> Accessibility.Element { + let editingConfirmButton = try iOSContentGroup().recursiveChildren().lazy.first(where: { + (try? $0.localizedDescription()) == LocalizedStrings.editingConfirm + }).orThrow(ErrorMessage("editingConfirmButton not found")) + return try editingConfirmButton.parent().recursiveChildren().lazy.first(where: { + (try? $0.role()) == Accessibility.Role.textField + }).orThrow(ErrorMessage("editableMessageField not found")) + } + + func menu() throws -> Accessibility.Element { + try retry(withTimeout: 2, interval: 0.1) { + try iOSContentGroup().children() + .first { try $0.role() == Accessibility.Role.menu } + .orThrow(ErrorMessage("menu not found")) + } + } + + func menuEditItem() throws -> Accessibility.Element { + try retry(withTimeout: 1, interval: 0.05) { + try menu().children() + .first { (try? $0.identifier()) == "edit" } + .orThrow(ErrorMessage("Couldn't find \"Edit\" menu item; messages are only editable for 15 minutes after sending")) + } + } + + func cancelEditButton() throws -> Accessibility.Element { + try find("cancelEditButton") { + try iOSContentGroupFirstChild().recursiveChildren() + .first(where: { + (try? $0.localizedDescription()) == LocalizedStrings.editingReject + }) + } + } + + func toFieldPopupButton() throws -> Accessibility.Element { + try find("toFieldPopupButton") { + try iOSContentGroup().children[0].children() + .first { try $0.role() == Accessibility.Role.popUpButton } + } + } +} diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift new file mode 100644 index 00000000..1787bbd9 --- /dev/null +++ b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift @@ -0,0 +1,172 @@ +import AccessibilityControl +import AppKit +import IMessageCore +import Logging + +private let messagesApplicationLog = Logger(imessageLabel: "messages-application") + +final class MessagesApplication { + typealias OpenDeepLink = (URL) async throws -> Void + + let runningApplication: NSRunningApplication + let accessibilityElement: Accessibility.Element + + private let openDeepLink: OpenDeepLink + private var lastDumpedApplicationTree: Date? + + var processIdentifier: pid_t { + runningApplication.processIdentifier + } + + init(runningApplication: NSRunningApplication, openDeepLink: @escaping OpenDeepLink) { + self.runningApplication = runningApplication + self.openDeepLink = openDeepLink + accessibilityElement = Accessibility.Element(pid: runningApplication.processIdentifier) + } + + func allWindows() -> [Window] { + var elements = (try? accessibilityElement.appWindows()) ?? [] + + // after a window is moved to a new Space, AX may not list it consistently in appWindows or children, so keep the focused/main fallbacks. + if let mainWindow: Accessibility.Element = try? accessibilityElement.appMainWindow(), !elements.contains(mainWindow) { + elements.append(mainWindow) + } + + if let focusedWindow: Accessibility.Element = try? accessibilityElement.appFocusedWindow(), !elements.contains(focusedWindow) { + elements.append(focusedWindow) + } + + return elements.map(window(for:)) + } + + func currentWindow() -> Window? { + mainWindow() + } + + func mainWindow() -> Window? { + allWindows() + .first(where: isMainWindow) + } + + func isMainWindow(_ window: Window) -> Bool { + isMainWindow(window.element) + } + + fileprivate func window(for element: Accessibility.Element) -> Window { + Window(parent: self, element: element) + } + + func isMainWindow(_ element: Accessibility.Element) -> Bool { + // These checks are intentionally redundant. Depending on macOS version + // and Spaces state, one path can fail while the other still identifies + // the Messages main window. + conversationList(in: element, useFastPath: false) != nil || + ckConversationListCollectionView(in: element) != nil + } + + func sectionObjects(in element: Accessibility.Element) throws -> [Accessibility.Element] { + try element.sections().compactMap { + $0["SectionObject"].flatMap { Accessibility.Element(erased: $0) } + } + } + + func conversationList(in element: Accessibility.Element, useFastPath: Bool) -> Accessibility.Element? { + if useFastPath, + let conversationList = try? sectionObjects(in: element) + .first(where: { (try? $0.identifier()) == "ConversationList" }) { + return conversationList + } + + return element.recursivelyFindChild(withID: "ConversationList") + } + + func ckConversationListCollectionView(in element: Accessibility.Element) -> Accessibility.Element? { + element.recursivelyFindChild(withID: "CKConversationListCollectionView") + } + + fileprivate func recoverMissingWindow(after attempt: Int, error _: Error?) async throws { + if attempt == 0 { + messagesApplicationLog.notice("availableWindow: using compose deep link to try to get main window") + try await openDeepLink(MessagesDeepLink.compose.url()) + } else if attempt == 1 { + if isPromptVisible() { + messagesApplicationLog.notice("availableWindow: some prompts are visible, attempting to reset") + Defaults.resetPrompts() + } + } else if attempt == 2 { + if isPromptVisible() { + messagesApplicationLog.error("availableWindow: some prompts are still visible, force terminating") + runningApplication.forceTerminate() + } + } else if attempt > 3 { + do { + try dismissAnyPresentedSheet() + } catch { + messagesApplicationLog.error("availableWindow: couldn't try dismissing any presented sheet: \(error)") + } + } + } + + fileprivate func dumpAndLogApplicationTreeIfNeeded() throws { + if let lastDumpedApplicationTree { + guard lastDumpedApplicationTree.elapsedMilliseconds >= 60_000 else { + messagesApplicationLog.debug("not dumping application tree as it was dumped less than a minute ago") + return + } + } + + defer { lastDumpedApplicationTree = Date() } + var buffer = "" + try accessibilityElement.dumpXML(to: &buffer, maxDepth: 10, excludingPII: true, includeActions: false, includeSections: true) + messagesApplicationLog.info("\(buffer)") + } + + private func isPromptVisible() -> Bool { + allWindows().contains(where: { (try? $0.element.windowCloseButton().isEnabled()) == false }) + } + + private func dismissAnyPresentedSheet() throws { + let mainWindow = try accessibilityElement.appMainWindow() + guard let sheet = mainWindow.firstChild(withRole: \.sheet) else { + messagesApplicationLog.debug("(found no sheet to dismiss)") + return + } + + let startTime = Date() + guard let okButton = sheet.recursiveChildren().lazy.first(where: { child in + let description = try? child.localizedDescription() + return description == LocalizedStrings.dismissButtonLabel || description == LocalizedStrings.ok + }) else { + messagesApplicationLog.debug("found a sheet, but no OK button within it to dismiss (took \(startTime.elapsedMilliseconds)ms)") + return + } + + messagesApplicationLog.debug("found OK button within sheet, going to press it (took \(startTime.elapsedMilliseconds)ms)") + do { + try okButton.press() + } catch { + messagesApplicationLog.error("couldn't press OK button: \(error)") + } + } +} + +extension MessagesApplication { + enum WindowAvailability { + static func availableWindow(for application: MessagesApplication) async throws -> Window { + do { + return try await retry(withTimeout: 5, interval: 0.2) { () async throws -> Window in + try application.mainWindow().orThrow(ErrorMessage("Could not get main Messages window")) + } onError: { attempt, error in + try await application.recoverMissingWindow(after: attempt, error: error) + } + } catch { + do { + try application.dumpAndLogApplicationTreeIfNeeded() + } catch { + messagesApplicationLog.error("couldn't dump application tree: \(String(describing: error))") + } + throw error + } + } + } +} diff --git a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift index 8e1900a8..956ccc4b 100644 --- a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift +++ b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift @@ -128,6 +128,7 @@ actor MessagesControllerAutomationLane { guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { return } + await self.scheduleIdleCallback() } } diff --git a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift index e039dc9e..0f62e2e2 100644 --- a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift +++ b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift @@ -14,6 +14,7 @@ extension NSPasteboard { } } + // TODO: replace with Task and figure out if the delay is really needed func withRestoration(perform: () async throws -> Void) async rethrows { let backup = self.backup() defer { From 74e7971322e5aa4595f50fad7fda0775cf28d7fe Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:25:58 +0530 Subject: [PATCH 44/59] Revert "wip" This reverts commit 49e129b71b91f8c96159f8dcc458fce655203da1. --- .../MessagesApplication+Window.swift | 18 - .../MessagesApplication+WindowElements.swift | 317 ------------------ .../MessagesApplication.swift | 172 ---------- .../MessagesControllerAutomationLane.swift | 1 - .../Sources/IMessage/Pasteboard+Backup.swift | 1 - 5 files changed, 509 deletions(-) delete mode 100644 src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift delete mode 100644 src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift delete mode 100644 src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift deleted file mode 100644 index f0df3a26..00000000 --- a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+Window.swift +++ /dev/null @@ -1,18 +0,0 @@ -import AccessibilityControl -import AppKit - -extension MessagesApplication { - final class Window { - let application: MessagesApplication - let element: Accessibility.Element - - init(parent application: MessagesApplication, element: Accessibility.Element) { - self.application = application - self.element = element - } - - var processIdentifier: pid_t { - application.processIdentifier - } - } -} diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift deleted file mode 100644 index 071d7118..00000000 --- a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication+WindowElements.swift +++ /dev/null @@ -1,317 +0,0 @@ -import AccessibilityControl -import AppKit -import IMessageCore -import Logging - -private let messagesApplicationWindowLog = Logger(imessageLabel: "messages-window") - -extension MessagesApplication { - struct ElementLookupError: Error, CustomStringConvertible { - let name: String - let underlyingErrors: [Error] - let dumpID: String? - - var description: String { - var desc = "\(name) not found" - if !underlyingErrors.isEmpty { - desc += " - underlying: " + underlyingErrors.map(String.init(describing:)).joined(separator: "; ") - } - if let dumpID { - desc += " (AX dump ID: \(dumpID))" - } - return desc - } - } -} - -extension MessagesApplication.Window { - static func isMessageContainerCell(_ element: Accessibility.Element) throws -> Bool { - let hasDescription = try !element.localizedDescription().isEmpty - guard hasDescription else { return false } - - return try element.children[0].supportedActions().contains { - $0.name.value.hasPrefix("Name:\(LocalizedStrings.react)") - } - } - - static func messageContainerCells(in transcriptView: Accessibility.Element) throws -> [Accessibility.Element] { - try transcriptView.children().filter { (try? isMessageContainerCell($0)) ?? false } - } - - static func firstMessageCell(in transcriptView: Accessibility.Element) throws -> Accessibility.Element? { - try transcriptView.children().first { (try? isMessageContainerCell($0)) ?? false }?.children[0] - } - - static func firstSelectedMessageCell(in transcriptView: Accessibility.Element) throws -> Accessibility.Element? { - try transcriptView.children().first { (try? $0.children[0].isSelected()) == true }?.children[0] - } - - static func threadActivityCells(in transcriptView: Accessibility.Element) throws -> [Accessibility.Element] { - let count = try transcriptView.children.count() - guard count > 0 else { return [] } - - let lastN = isSequoiaOrUp ? 10 : (isMontereyOrUp ? 3 : 1) - return try transcriptView.children(range: (count - min(count, lastN)).. Accessibility.Element? - ) throws -> Accessibility.Element { - let startTime = logTime ? Date() : nil - defer { - if let startTime { - messagesApplicationWindowLog.debug("\(name) took \(startTime.elapsedMilliseconds)ms") - } - } - - var errors: [Error] = [] - do { - if let result = try search() { - return result - } - } catch { - errors.append(error) - } - - var dumpID: String? - if dumpOnError { - let id = String(UUID().uuidString.prefix(8)).lowercased() - dumpID = id - do { - var buffer = "" - try (root ?? element).dumpXML(to: &buffer, maxDepth: 10, excludingPII: true, includeActions: false, includeSections: true) - messagesApplicationWindowLog.error("[\(id)] AX dump for \(name):\n\(buffer)") - } catch { - messagesApplicationWindowLog.error("[\(id)] failed to dump AX tree for \(name): \(error)") - errors.append(error) - } - } - - throw MessagesApplication.ElementLookupError(name: name, underlyingErrors: errors, dumpID: dumpID) - } - - func sectionObjects() throws -> [Accessibility.Element] { - try application.sectionObjects(in: element) - } - - func conversationList() throws -> Accessibility.Element { - try retry(withTimeout: 1, interval: 0.1) { - try application.conversationList(in: element, useFastPath: true) - .orThrow(ErrorMessage("ConversationList not found")) - } onError: { _, _ in - let searchField = try self.searchField() - messagesApplicationWindowLog.error("fetching ConversationList errored, calling searchField.cancel") - try searchField.cancel() - } - } - - func selectedThreadCell() throws -> Accessibility.Element? { - try conversationList().selectedChildren[0] - } - - func transcriptView(replyTranscript: Bool) throws -> Accessibility.Element { - let startTime = Date() - defer { - messagesApplicationWindowLog.debug("transcriptView(replyTranscript: \(replyTranscript)) took \(startTime.elapsedMilliseconds)ms") - } - - func isReplyTranscriptView(_ element: Accessibility.Element) -> Bool { - (try? element.localizedDescription()) == LocalizedStrings.replyTranscript - } - - let predicate = { (element: Accessibility.Element) -> Bool in - (try? element.identifier()) == "TranscriptCollectionView" && isReplyTranscriptView(element) == replyTranscript - } - - if let transcriptView = try? sectionObjects().first(where: predicate) { - return transcriptView - } - if let transcriptView = element.recursiveChildren().lazy.first(where: predicate) { - return transcriptView - } - throw ErrorMessage("TranscriptCollectionView(replyTranscript: \(replyTranscript)) not found") - } - - func transcriptView() throws -> Accessibility.Element { - try transcriptView(replyTranscript: false) - } - - func replyTranscriptView() throws -> Accessibility.Element { - try transcriptView(replyTranscript: true) - } - - func messageBodyField() throws -> Accessibility.Element { - let startTime = Date() - defer { messagesApplicationWindowLog.debug("messageBodyField took \(startTime.elapsedMilliseconds)ms") } - var alternate = false - return try retry(withTimeout: 1.5, interval: 0.1) { - if alternate { - return try element.recursivelyFindChild(withID: "messageBodyField") - .orThrow(ErrorMessage("messageBodyField not found")) - } - - return try sectionObjects() - .first { (try? $0.identifier()) == "messageBodyField" } - .orThrow(ErrorMessage("messageBodyField not found")) - } onError: { attempt, _ in - alternate = attempt % 2 == 0 - } - } - - func searchField() throws -> Accessibility.Element { - let startTime = Date() - defer { messagesApplicationWindowLog.debug("searchField took \(startTime.elapsedMilliseconds)ms") } - return try retry(withTimeout: 1, interval: 0.1) { - let conversationListView = try application.ckConversationListCollectionView(in: element) - .orThrow(ErrorMessage("CKConversationListCollectionView not found")) - return try conversationListView.children() - .first { (try? $0.subrole()) == Accessibility.Subrole.searchField } - .orThrow(ErrorMessage("searchField not found")) - } - } - - func iOSContentGroup() throws -> Accessibility.Element { - try find("iOSContentGroup") { - try element.children() - .first(where: { (try? $0.subrole()) == "iOSContentGroup" && (try? $0.role()) == NSAccessibility.Role.group.rawValue }) - } - } - - func iOSContentGroupFirstChild() throws -> Accessibility.Element { - try find("iOSContentGroupFirstChild", logTime: true) { - try iOSContentGroup().children[0] - } - } - - func addCustomEmojiReactionButton() throws -> Accessibility.Element { - let element = try (try? iOSContentGroupFirstChild())?.children().first { - (try? $0.identifier()) == nil && (try? $0.role()) == "AXButton" - } - return try element.orThrow(ErrorMessage("couldn't find button to add custom emoji reaction")) - } - - func characterPickerPopover() throws -> Accessibility.Element { - try find("characterPickerPopover") { - if let attachedPopover = element.recursiveChildren().lazy.first(where: CharacterPickerPopover.isPopover) { - return attachedPopover - } - - return try CharacterPickerPopover(app: application.accessibilityElement, ownerPID: processIdentifier).detachedPopover() - } - } - - func characterPickerSearchField() throws -> Accessibility.Element { - try find("characterPickerSearchField") { - try CharacterPickerPopover(app: application.accessibilityElement, ownerPID: processIdentifier) - .searchField(in: characterPickerPopover()) - } - } - - func splitter() throws -> Accessibility.Element { - try find("splitter", logTime: true) { - try iOSContentGroupFirstChild().children() - .first(where: { (try? $0.role()) == Accessibility.Role.splitter }) - } - } - - func reactionsView() throws -> Accessibility.Element { - let startTime = Date() - defer { messagesApplicationWindowLog.debug("reactionsView took \(startTime.elapsedMilliseconds)ms") } - return try retry(withTimeout: 1.5, interval: 0.1) { - let view = try iOSContentGroupFirstChild() - guard (try? view.children.count()) ?? 0 > 0 else { - throw ErrorMessage("reactionsView not found") - } - return view - } - } - - func reactButtons() throws -> [Accessibility.Element] { - let startTime = Date() - defer { messagesApplicationWindowLog.debug("reactButtons took \(startTime.elapsedMilliseconds)ms") } - guard let buttons = try? reactionsView().children().filter({ (try? $0.role()) == Accessibility.Role.button }) else { - throw ErrorMessage("reactButtons not found") - } - return buttons - } - - func tapbackPickerCollectionView() throws -> Accessibility.Element { - let startTime = Date() - defer { messagesApplicationWindowLog.debug("tapbackPickerCollectionView took \(startTime.elapsedMilliseconds)ms") } - guard let element = try? reactionsView().children().first(where: { (try? $0.identifier()) == "TapbackPickerCollectionView" }) else { - throw ErrorMessage("tapbackPickerCollectionView not found") - } - return element - } - - func alertSheet() throws -> Accessibility.Element { - try find("alertSheet") { - try element.children().first(where: { try $0.role() == Accessibility.Role.sheet }) - } - } - - func alertSheetDeleteButton() throws -> Accessibility.Element { - try find("alertSheetDeleteButton") { - try alertSheet().children().first(where: { try $0.role() == Accessibility.Role.button }) - } - } - - func notifyAnywayButton() throws -> Accessibility.Element { - let startTime = Date() - defer { messagesApplicationWindowLog.debug("notifyAnywayButton took \(startTime.elapsedMilliseconds)ms") } - let cells = try Self.threadActivityCells(in: transcriptView()) - return try cells.lazy.reversed().compactMap { - guard let child = try? $0.children[0], - (try? child.role()) == Accessibility.Role.button, - (try? child.localizedDescription()) == LocalizedStrings.notifyAnyway else { - return nil - } - return child - }.first.orThrow(ErrorMessage("notifyAnywayButton not found")) - } - - func editableMessageField() throws -> Accessibility.Element { - let editingConfirmButton = try iOSContentGroup().recursiveChildren().lazy.first(where: { - (try? $0.localizedDescription()) == LocalizedStrings.editingConfirm - }).orThrow(ErrorMessage("editingConfirmButton not found")) - return try editingConfirmButton.parent().recursiveChildren().lazy.first(where: { - (try? $0.role()) == Accessibility.Role.textField - }).orThrow(ErrorMessage("editableMessageField not found")) - } - - func menu() throws -> Accessibility.Element { - try retry(withTimeout: 2, interval: 0.1) { - try iOSContentGroup().children() - .first { try $0.role() == Accessibility.Role.menu } - .orThrow(ErrorMessage("menu not found")) - } - } - - func menuEditItem() throws -> Accessibility.Element { - try retry(withTimeout: 1, interval: 0.05) { - try menu().children() - .first { (try? $0.identifier()) == "edit" } - .orThrow(ErrorMessage("Couldn't find \"Edit\" menu item; messages are only editable for 15 minutes after sending")) - } - } - - func cancelEditButton() throws -> Accessibility.Element { - try find("cancelEditButton") { - try iOSContentGroupFirstChild().recursiveChildren() - .first(where: { - (try? $0.localizedDescription()) == LocalizedStrings.editingReject - }) - } - } - - func toFieldPopupButton() throws -> Accessibility.Element { - try find("toFieldPopupButton") { - try iOSContentGroup().children[0].children() - .first { try $0.role() == Accessibility.Role.popUpButton } - } - } -} diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift b/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift deleted file mode 100644 index 1787bbd9..00000000 --- a/src/IMessage/Sources/IMessage/Messages/MessagesApplication/MessagesApplication.swift +++ /dev/null @@ -1,172 +0,0 @@ -import AccessibilityControl -import AppKit -import IMessageCore -import Logging - -private let messagesApplicationLog = Logger(imessageLabel: "messages-application") - -final class MessagesApplication { - typealias OpenDeepLink = (URL) async throws -> Void - - let runningApplication: NSRunningApplication - let accessibilityElement: Accessibility.Element - - private let openDeepLink: OpenDeepLink - private var lastDumpedApplicationTree: Date? - - var processIdentifier: pid_t { - runningApplication.processIdentifier - } - - init(runningApplication: NSRunningApplication, openDeepLink: @escaping OpenDeepLink) { - self.runningApplication = runningApplication - self.openDeepLink = openDeepLink - accessibilityElement = Accessibility.Element(pid: runningApplication.processIdentifier) - } - - func allWindows() -> [Window] { - var elements = (try? accessibilityElement.appWindows()) ?? [] - - // after a window is moved to a new Space, AX may not list it consistently in appWindows or children, so keep the focused/main fallbacks. - if let mainWindow: Accessibility.Element = try? accessibilityElement.appMainWindow(), !elements.contains(mainWindow) { - elements.append(mainWindow) - } - - if let focusedWindow: Accessibility.Element = try? accessibilityElement.appFocusedWindow(), !elements.contains(focusedWindow) { - elements.append(focusedWindow) - } - - return elements.map(window(for:)) - } - - func currentWindow() -> Window? { - mainWindow() - } - - func mainWindow() -> Window? { - allWindows() - .first(where: isMainWindow) - } - - func isMainWindow(_ window: Window) -> Bool { - isMainWindow(window.element) - } - - fileprivate func window(for element: Accessibility.Element) -> Window { - Window(parent: self, element: element) - } - - func isMainWindow(_ element: Accessibility.Element) -> Bool { - // These checks are intentionally redundant. Depending on macOS version - // and Spaces state, one path can fail while the other still identifies - // the Messages main window. - conversationList(in: element, useFastPath: false) != nil || - ckConversationListCollectionView(in: element) != nil - } - - func sectionObjects(in element: Accessibility.Element) throws -> [Accessibility.Element] { - try element.sections().compactMap { - $0["SectionObject"].flatMap { Accessibility.Element(erased: $0) } - } - } - - func conversationList(in element: Accessibility.Element, useFastPath: Bool) -> Accessibility.Element? { - if useFastPath, - let conversationList = try? sectionObjects(in: element) - .first(where: { (try? $0.identifier()) == "ConversationList" }) { - return conversationList - } - - return element.recursivelyFindChild(withID: "ConversationList") - } - - func ckConversationListCollectionView(in element: Accessibility.Element) -> Accessibility.Element? { - element.recursivelyFindChild(withID: "CKConversationListCollectionView") - } - - fileprivate func recoverMissingWindow(after attempt: Int, error _: Error?) async throws { - if attempt == 0 { - messagesApplicationLog.notice("availableWindow: using compose deep link to try to get main window") - try await openDeepLink(MessagesDeepLink.compose.url()) - } else if attempt == 1 { - if isPromptVisible() { - messagesApplicationLog.notice("availableWindow: some prompts are visible, attempting to reset") - Defaults.resetPrompts() - } - } else if attempt == 2 { - if isPromptVisible() { - messagesApplicationLog.error("availableWindow: some prompts are still visible, force terminating") - runningApplication.forceTerminate() - } - } else if attempt > 3 { - do { - try dismissAnyPresentedSheet() - } catch { - messagesApplicationLog.error("availableWindow: couldn't try dismissing any presented sheet: \(error)") - } - } - } - - fileprivate func dumpAndLogApplicationTreeIfNeeded() throws { - if let lastDumpedApplicationTree { - guard lastDumpedApplicationTree.elapsedMilliseconds >= 60_000 else { - messagesApplicationLog.debug("not dumping application tree as it was dumped less than a minute ago") - return - } - } - - defer { lastDumpedApplicationTree = Date() } - var buffer = "" - try accessibilityElement.dumpXML(to: &buffer, maxDepth: 10, excludingPII: true, includeActions: false, includeSections: true) - messagesApplicationLog.info("\(buffer)") - } - - private func isPromptVisible() -> Bool { - allWindows().contains(where: { (try? $0.element.windowCloseButton().isEnabled()) == false }) - } - - private func dismissAnyPresentedSheet() throws { - let mainWindow = try accessibilityElement.appMainWindow() - guard let sheet = mainWindow.firstChild(withRole: \.sheet) else { - messagesApplicationLog.debug("(found no sheet to dismiss)") - return - } - - let startTime = Date() - guard let okButton = sheet.recursiveChildren().lazy.first(where: { child in - let description = try? child.localizedDescription() - return description == LocalizedStrings.dismissButtonLabel || description == LocalizedStrings.ok - }) else { - messagesApplicationLog.debug("found a sheet, but no OK button within it to dismiss (took \(startTime.elapsedMilliseconds)ms)") - return - } - - messagesApplicationLog.debug("found OK button within sheet, going to press it (took \(startTime.elapsedMilliseconds)ms)") - do { - try okButton.press() - } catch { - messagesApplicationLog.error("couldn't press OK button: \(error)") - } - } -} - -extension MessagesApplication { - enum WindowAvailability { - static func availableWindow(for application: MessagesApplication) async throws -> Window { - do { - return try await retry(withTimeout: 5, interval: 0.2) { () async throws -> Window in - try application.mainWindow().orThrow(ErrorMessage("Could not get main Messages window")) - } onError: { attempt, error in - try await application.recoverMissingWindow(after: attempt, error: error) - } - } catch { - do { - try application.dumpAndLogApplicationTreeIfNeeded() - } catch { - messagesApplicationLog.error("couldn't dump application tree: \(String(describing: error))") - } - throw error - } - } - } -} diff --git a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift index 956ccc4b..8e1900a8 100644 --- a/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift +++ b/src/IMessage/Sources/IMessage/MessagesControllerAutomationLane.swift @@ -128,7 +128,6 @@ actor MessagesControllerAutomationLane { guard await self.shouldContinueIdleCallbacks(expectedEpoch: expectedEpoch) else { return } - await self.scheduleIdleCallback() } } diff --git a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift index 0f62e2e2..e039dc9e 100644 --- a/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift +++ b/src/IMessage/Sources/IMessage/Pasteboard+Backup.swift @@ -14,7 +14,6 @@ extension NSPasteboard { } } - // TODO: replace with Task and figure out if the delay is really needed func withRestoration(perform: () async throws -> Void) async rethrows { let backup = self.backup() defer { From fd743b8222671734941ceb5cc43935680a9e2ba6 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Thu, 4 Jun 2026 03:05:10 +0530 Subject: [PATCH 45/59] improve `waitForLaunch` --- .../Sources/IMessage/Extensions.swift | 30 +-- .../IMessageTests/WaitForLaunchTests.swift | 173 ++++++++++++++++++ 2 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 src/IMessage/Sources/IMessageTests/WaitForLaunchTests.swift diff --git a/src/IMessage/Sources/IMessage/Extensions.swift b/src/IMessage/Sources/IMessage/Extensions.swift index 7ba44e12..e525df5b 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,25 @@ extension NSApplication { } extension NSRunningApplication { - func waitForLaunch(interval: TimeInterval = 0.05, timeout seconds: TimeInterval = 5) async throws { - let start = Date() - while !self.isFinishedLaunching { - Log.default.notice("sleeping \(interval)s for \(String(describing: self.localizedName)) to finish launching") - try await Task.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 { + var cancellable: AnyCancellable? + _ = cancellable + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if isFinishedLaunching { + continuation.resume() + } else { + cancellable = self.publisher(for: \.isFinishedLaunching, options: [.initial, .new]) + .filter { $0 } // we only care about isFinishedLaunching = true + .first() + .timeout(.seconds(seconds), scheduler: DispatchQueue.global()) + .sink { _ in + continuation.resume() + } receiveValue: { _ in + + } } } - try await Task.sleep(forTimeInterval: 0.01) } } 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.. Date: Thu, 4 Jun 2026 01:53:52 +0530 Subject: [PATCH 46/59] - --- src/IMessage/Sources/IMessage/Defaults.swift | 2 +- .../Messages/MessagesController.swift | 80 +++--- .../PlatformAPI+MessagesController.swift | 27 +- .../Sources/IMessage/PlatformAPI.swift | 260 ++++++++++-------- .../IMessageNode/PlatformAPINodeWrapper.swift | 9 +- src/IMessage/lib/index.ts | 2 +- src/api.ts | 1 - 7 files changed, 215 insertions(+), 166 deletions(-) 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/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 2a4c7cc2..4dc22a94 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1524,59 +1524,55 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) log.error("didn't observe a layout change within \(timeout)s, continuing anyways") } - /// Returns an observer that checks one thread while controller work is idle. + /// Checks one thread while controller work is idle. /// The platform-level idle observer calls it repeatedly after active automation drains. - func makeIdleActivityObserver(observingThreadID threadID: String, statusSender: @escaping (ThreadActivityObservation) -> Void) throws -> (() async throws -> Void) { + func observeIdleActivity(threadID: String, statusSender: @escaping @Sendable (ThreadActivityObservation) -> Void) async throws { let url = try MessagesDeepLink(threadID: threadID, body: nil).url() - return { [weak self] in - guard let self else { return } - - guard !Defaults.shouldCoordinateWindow && !messagesIsManuallyActivated else { - log.debug("not observing activity, Messages is manually activated") - return - } + guard !Defaults.shouldCoordinateWindow && !messagesIsManuallyActivated else { + log.debug("not observing activity, Messages is manually activated") + return + } - guard occlusionMonitor.visible else { - #if DEBUG - log.debug("not observing activity, window occluded") - #endif - return - } + guard occlusionMonitor.visible else { + #if DEBUG + log.debug("not observing activity, window occluded") + #endif + return + } - guard isValid else { - #if DEBUG - log.debug("not observing activity, controller is invalid") - #endif - return - } + guard isValid else { + #if DEBUG + log.debug("not observing activity, controller is invalid") + #endif + return + } - if lastThreadIDOpenedForObservation.read() != threadID { - log.debug("activity: entered idle state or thread id changed, opening deep link") - try await withAutomation { - _ = try await self.openDeepLink(url) - log.debug("activity: opened deep link, waiting for layout change") - self.lastThreadIDOpenedForObservation.withLock { $0 = threadID } - 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 await withAutomation { + _ = try await self.openDeepLink(url) + log.debug("activity: opened deep link, waiting for layout change") + self.lastThreadIDOpenedForObservation.withLock { $0 = threadID } + try await self.waitForLayoutChange(timeout: 0.5) } + } - let observationToSend = await 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 diff --git a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift index 1766782c..1998fb7d 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI+MessagesController.swift @@ -125,7 +125,9 @@ private extension MessagesControllerCoordinator { try? reportErrorMessage?(txt) }) } - return MessagesControllerEntry(controller) + let entry = MessagesControllerEntry(controller) + await PlatformAPI.installMessagesControllerIdleCallback(for: entry) + return entry } pendingController = task return task @@ -174,7 +176,7 @@ private extension MessagesControllerCoordinator { func dispose(_ entry: MessagesControllerEntry) async throws { Log.default.notice("[PlatformAPI] disposing MessagesController") try await PlatformAPI.runOnMessagesControllerLane { - await PlatformAPI.setMessagesControllerIdleCallback(nil) + await PlatformAPI.clearMessagesControllerIdleCallback(ifOwnedBy: entry) entry.value.dispose() } } @@ -184,6 +186,7 @@ extension PlatformAPI { // IMessageHost is singleton-only within a process; PlatformAPI wrappers share // 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( @@ -214,4 +217,24 @@ extension PlatformAPI { 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) + } + } + + 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 364bbe86..8e86a44e 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -81,9 +81,19 @@ 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 publicThreadID: String + let hashedThreadID: String + let singleParticipantID: String? + let dndUserIDs: Protected> + let sendEvents: EventCallback + } + public init(accountID: String, reportErrorMessage: ReportErrorMessage? = nil, enforceSingleton: Bool = true) throws { self.accountID = accountID self.errorMessageReporter = reportErrorMessage @@ -491,74 +501,48 @@ public final class PlatformAPI { } 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 { + guard Defaults.watchThreadActivity, + !Preferences.enabledExperiments.contains("no_watch_thread") + else { + clearSelectedThreadActivity() 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) + let selectedThread = SelectedThreadActivityState( + owner: ObjectIdentifier(self), + threadID: threadID, + publicThreadID: publicThreadID, + hashedThreadID: Hasher.thread.tokenizeRemembering(pii: threadID), + singleParticipantID: singleParticipantID, + dndUserIDs: dndUserIDs, + sendEvents: sendEvents + ) + Self.selectedThreadActivityState.withLock { $0 = selectedThread } platformLog.debug("activity/\(publicThreadID): watching") - try await watchThreadActivity(threadID: threadID) { [dndUserIDs] status in - platformLog.debug("activity/\(publicThreadID): received \(status)") - - guard let singleParticipantID else { - platformLog.debug("activity/\(publicThreadID): NOT syncing; not a single participant \(status)") + try await withMessagesController { controller in + guard try Self.threadSupportsActivityObservation(threadID: threadID, controller: controller) else { + self.clearSelectedThreadActivity() return } - - let hashedParticipantID = Hasher.participant.tokenizeRemembering(pii: singleParticipantID) - let hadDNDStatus = dndUserIDs.withLock { $0.contains(singleParticipantID) } - var events: [ServerEvent] = [ - .userActivity( - activityType: status.activityType, - threadID: hashedThreadID, - participantID: hashedParticipantID, - durationMilliseconds: 120_000, - customLabel: nil - ) - ] - - if let presenceStatus = status.presenceStatus { - dndUserIDs.withLock { - _ = $0.insert(singleParticipantID) - } - events.append( - .userPresenceUpdated( - PlatformSDK.UserPresence( - userID: hashedParticipantID, - status: presenceStatus - ) - ) - ) - } else if status.didObservePresence, hadDNDStatus { - dndUserIDs.withLock { - _ = $0.remove(singleParticipantID) - } - events.append( - .userPresenceUpdated( - PlatformSDK.UserPresence( - userID: hashedParticipantID, - status: .idle - ) - ) - ) - } else if status.didObservePresence { - dndUserIDs.withLock { - _ = $0.remove(singleParticipantID) - } - } - - try await sendEvents(events) + await Self.observeSelectedThreadActivity(using: controller) } } @@ -590,6 +574,7 @@ 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 } @@ -605,77 +590,130 @@ 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 } + } + + private func clearSelectedThreadActivityIfOwned() { + let owner = ObjectIdentifier(self) + Self.selectedThreadActivityState.withLock { state in + guard state?.owner == owner else { return } + state = nil } + } - // reset the idle observer in case we fail and bail out - await Self.setMessagesControllerIdleCallback(nil) + private static func threadSupportsActivityObservation(threadID: String, controller: MessagesController) throws -> Bool { + // only watch thread activity for iMessage chats + // TODO: implement this for groups + if threadID.hasPrefix("iMessage;-;") { + return true + } + + 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 + } - let requestID = UUID() - let threadObserveRequestToken = threadObserveRequestToken - threadObserveRequestToken.withLock { $0 = requestID } + 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 + } + + guard chat.serviceName == .imessage else { + #if DEBUG + platformLog.debug("chat definitely isn't an iMessage 1:1 DM, not watching for activity") + #endif + return false + } + + return true + } + + static func observeSelectedThreadActivity(using controller: MessagesController) async { + guard let selectedThread = selectedThreadActivityState.read() else { + return + } @Sendable func sendStatus(_ status: ThreadActivityObservation) { Task { - do { - try await statusSender(status) - } catch { - platformLog.error("failed to send activity status: \(String(reflecting: error))") + guard let currentThread = selectedThreadActivityState.read(), + currentThread.threadID == selectedThread.threadID + else { + return } + await sendThreadActivityStatus(status, selectedThread: currentThread) } } - 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 - } + do { + try await controller.observeIdleActivity(threadID: selectedThread.threadID, statusSender: sendStatus) + } catch { + platformLog.error("failed to observe activity: \(error)") + } + } - 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 - } + private static func sendThreadActivityStatus( + _ status: ThreadActivityObservation, + selectedThread: SelectedThreadActivityState + ) async { + platformLog.debug("activity/\(selectedThread.publicThreadID): received \(status)") - guard chat.serviceName == .imessage else { - #if DEBUG - platformLog.debug("chat definitely isn't an iMessage 1:1 DM, not watching for activity") - #endif - return - } - } + guard let singleParticipantID = selectedThread.singleParticipantID else { + platformLog.debug("activity/\(selectedThread.publicThreadID): NOT syncing; not a single participant \(status)") + return + } - guard threadObserveRequestToken.read() == requestID else { return } + let hashedParticipantID = Hasher.participant.tokenizeRemembering(pii: singleParticipantID) + let hadDNDStatus = selectedThread.dndUserIDs.withLock { $0.contains(singleParticipantID) } + var events: [ServerEvent] = [ + .userActivity( + activityType: status.activityType, + threadID: selectedThread.hashedThreadID, + participantID: hashedParticipantID, + durationMilliseconds: 120_000, + customLabel: nil + ) + ] - let observe = try controller.makeIdleActivityObserver(observingThreadID: threadID, statusSender: sendStatus) - await Self.setMessagesControllerIdleCallback { - guard threadObserveRequestToken.read() == requestID else { return } - do { - try await observe() - } catch { - platformLog.error("failed to observe activity: \(error)") - } + if let presenceStatus = status.presenceStatus { + selectedThread.dndUserIDs.withLock { + _ = $0.insert(singleParticipantID) } + events.append( + .userPresenceUpdated( + PlatformSDK.UserPresence( + userID: hashedParticipantID, + status: presenceStatus + ) + ) + ) + } else if status.didObservePresence, hadDNDStatus { + selectedThread.dndUserIDs.withLock { + _ = $0.remove(singleParticipantID) + } + events.append( + .userPresenceUpdated( + PlatformSDK.UserPresence( + userID: hashedParticipantID, + status: .idle + ) + ) + ) + } else if status.didObservePresence { + selectedThread.dndUserIDs.withLock { + _ = $0.remove(singleParticipantID) + } + } - // 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 await observe() + do { + try await selectedThread.sendEvents(events) + } catch { + platformLog.error("failed to send activity status: \(String(reflecting: error))") } } 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/lib/index.ts b/src/IMessage/lib/index.ts index b3b63e1c..bcb980be 100644 --- a/src/IMessage/lib/index.ts +++ b/src/IMessage/lib/index.ts @@ -69,7 +69,7 @@ export declare class NativePlatformAPI { markAsUnread: (threadID: ThreadID) => 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! From 8e5577f3d90e2d45ae839bd5485b5bf8f42f7fb6 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:03:12 +0530 Subject: [PATCH 47/59] rcs --- src/IMessage/Sources/IMessage/PlatformAPI.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index 8e86a44e..beeee306 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -603,9 +603,9 @@ public final class PlatformAPI { } private static func threadSupportsActivityObservation(threadID: String, controller: MessagesController) throws -> Bool { - // only watch thread activity for iMessage chats + // only watch thread activity for iMessage/RCS chats // TODO: implement this for groups - if threadID.hasPrefix("iMessage;-;") { + if threadID.hasPrefix("iMessage;-;") || threadID.hasPrefix("RCS;-;") { return true } @@ -624,9 +624,9 @@ public final class PlatformAPI { return false } - guard chat.serviceName == .imessage else { + guard chat.serviceName == .imessage || chat.serviceName == .rcs else { #if DEBUG - platformLog.debug("chat definitely isn't an iMessage 1:1 DM, not watching for activity") + platformLog.debug("chat definitely isn't an iMessage/RCS 1:1 DM, not watching for activity") #endif return false } From 3e745a0f497aa4c49f6be5b1ca27ec03c6003bf0 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:11:06 +0530 Subject: [PATCH 48/59] - --- .../Sources/IMessage/PlatformAPI.swift | 133 ++++++++---------- 1 file changed, 60 insertions(+), 73 deletions(-) diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index beeee306..9a278b42 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -87,11 +87,7 @@ public final class PlatformAPI { private struct SelectedThreadActivityState: Sendable { let owner: ObjectIdentifier let threadID: String - let publicThreadID: String - let hashedThreadID: String - let singleParticipantID: String? - let dndUserIDs: Protected> - let sendEvents: EventCallback + let sendStatus: @Sendable (ThreadActivityObservation) async -> Void } public init(accountID: String, reportErrorMessage: ReportErrorMessage? = nil, enforceSingleton: Bool = true) throws { @@ -525,15 +521,66 @@ public final class PlatformAPI { } let singleParticipantID = singleParticipantAddress(threadID) + let hashedThreadID = Hasher.thread.tokenizeRemembering(pii: threadID) let selectedThread = SelectedThreadActivityState( owner: ObjectIdentifier(self), - threadID: threadID, - publicThreadID: publicThreadID, - hashedThreadID: Hasher.thread.tokenizeRemembering(pii: threadID), - singleParticipantID: singleParticipantID, - dndUserIDs: dndUserIDs, - sendEvents: sendEvents - ) + threadID: threadID + ) { [dndUserIDs] status in + platformLog.debug("activity/\(publicThreadID): received \(status)") + + guard let singleParticipantID else { + platformLog.debug("activity/\(publicThreadID): NOT syncing; not a single participant \(status)") + return + } + + let hashedParticipantID = Hasher.participant.tokenizeRemembering(pii: singleParticipantID) + let hadDNDStatus = dndUserIDs.withLock { $0.contains(singleParticipantID) } + var events: [ServerEvent] = [ + .userActivity( + activityType: status.activityType, + threadID: hashedThreadID, + participantID: hashedParticipantID, + durationMilliseconds: 120_000, + customLabel: nil + ) + ] + + if let presenceStatus = status.presenceStatus { + dndUserIDs.withLock { + _ = $0.insert(singleParticipantID) + } + events.append( + .userPresenceUpdated( + PlatformSDK.UserPresence( + userID: hashedParticipantID, + status: presenceStatus + ) + ) + ) + } else if status.didObservePresence, hadDNDStatus { + dndUserIDs.withLock { + _ = $0.remove(singleParticipantID) + } + events.append( + .userPresenceUpdated( + PlatformSDK.UserPresence( + userID: hashedParticipantID, + status: .idle + ) + ) + ) + } else if status.didObservePresence { + dndUserIDs.withLock { + _ = $0.remove(singleParticipantID) + } + } + + 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") @@ -646,7 +693,7 @@ public final class PlatformAPI { else { return } - await sendThreadActivityStatus(status, selectedThread: currentThread) + await currentThread.sendStatus(status) } } @@ -657,66 +704,6 @@ public final class PlatformAPI { } } - private static func sendThreadActivityStatus( - _ status: ThreadActivityObservation, - selectedThread: SelectedThreadActivityState - ) async { - platformLog.debug("activity/\(selectedThread.publicThreadID): received \(status)") - - guard let singleParticipantID = selectedThread.singleParticipantID else { - platformLog.debug("activity/\(selectedThread.publicThreadID): NOT syncing; not a single participant \(status)") - return - } - - let hashedParticipantID = Hasher.participant.tokenizeRemembering(pii: singleParticipantID) - let hadDNDStatus = selectedThread.dndUserIDs.withLock { $0.contains(singleParticipantID) } - var events: [ServerEvent] = [ - .userActivity( - activityType: status.activityType, - threadID: selectedThread.hashedThreadID, - participantID: hashedParticipantID, - durationMilliseconds: 120_000, - customLabel: nil - ) - ] - - if let presenceStatus = status.presenceStatus { - selectedThread.dndUserIDs.withLock { - _ = $0.insert(singleParticipantID) - } - events.append( - .userPresenceUpdated( - PlatformSDK.UserPresence( - userID: hashedParticipantID, - status: presenceStatus - ) - ) - ) - } else if status.didObservePresence, hadDNDStatus { - selectedThread.dndUserIDs.withLock { - _ = $0.remove(singleParticipantID) - } - events.append( - .userPresenceUpdated( - PlatformSDK.UserPresence( - userID: hashedParticipantID, - status: .idle - ) - ) - ) - } else if status.didObservePresence { - selectedThread.dndUserIDs.withLock { - _ = $0.remove(singleParticipantID) - } - } - - do { - try await selectedThread.sendEvents(events) - } catch { - platformLog.error("failed to send activity status: \(String(reflecting: error))") - } - } - @discardableResult private func performControllerOperation( name: String, From 8758f48a81e4c6f773bcf34d0786c64369686fcc Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:58:49 +0530 Subject: [PATCH 49/59] always keep track of lastOpenedThreadID, reset when user opens messages app, refactor openDeepLink --- .../Messages/MessagesAppElements.swift | 6 +- .../Messages/MessagesController.swift | 99 +++++++++++-------- .../IMessage/Messages/MessagesDeepLink.swift | 43 ++++++-- 3 files changed, 96 insertions(+), 52 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 3c48b0a5..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) async 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) async throws -> Void) { + init(runningApp: NSRunningApplication, openDeepLink: @escaping (MessagesDeepLink) async throws -> Void) { self.runningApp = runningApp self.openDeepLink = openDeepLink app = Accessibility.Element(pid: runningApp.processIdentifier) @@ -282,7 +282,7 @@ final class MessagesAppElements { log.notice("mainWindow: using compose deep link to try to get main window") // 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.url()) + try await self.openDeepLink(MessagesDeepLink.compose) } else if attempt == 1 { if self.isPromptVisibleInMessagesApp() { log.notice("mainWindow: some prompts are visible, attempting to reset") diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 4dc22a94..e6af2a27 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? @@ -147,13 +147,14 @@ final class MessagesController { @discardableResult static func openDeepLink( - _ url: URL, + _ 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 @@ -176,8 +177,17 @@ final class MessagesController { } @discardableResult - private func openDeepLink(_ url: URL, activating: Bool = false, hiding: Bool = true) async throws -> NSRunningApplication { - try await 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 { @@ -273,7 +283,7 @@ final class MessagesController { private func openThread(_ threadID: String) async throws { try? await self.clearTypingStatus() - try await openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil).url()) + try await openDeepLink(try MessagesDeepLink(threadID: threadID, body: nil)) try await assertSelectedThread(threadID: threadID) } @@ -295,7 +305,7 @@ final class MessagesController { // 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.url(), activating: !withoutActivation, timeout: 30) + return try await Self.openDeepLink(MessagesDeepLink.compose, activating: !withoutActivation, timeout: 30) } if Preferences.useSecondaryMessagesInstance { @@ -337,8 +347,8 @@ final class MessagesController { // without sleeping, appElement.observe applicationActivated/applicationDeactivated doesn't fire try await selectedApp.waitForLaunch() - elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { url in - try await Self.openDeepLink(url, targeting: selectedApp) + elements = MessagesAppElements(runningApp: selectedApp, openDeepLink: { deepLink in + try await Self.openDeepLink(deepLink, targeting: selectedApp) }) keyPresser = KeyPresser(pid: selectedApp.processIdentifier) @@ -640,12 +650,14 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // performs `perform` while the Messages window is unhidden private func withActivation( - openBefore: URL?, openAfter: URL? = nil, + 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 await openDeepLink(openBefore) } @@ -653,9 +665,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 await openDeepLink(openAfter) } @@ -665,18 +678,19 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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? await closeReplyTranscriptView() } - try await withActivation(openBefore: url) { + 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 @@ -955,10 +969,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 await withAutomation { - try await withActivation(openBefore: url) { + try await withActivation(openBefore: deepLink) { try await assertSelectedThread(threadID: threadID) if isVenturaOrUp { return try keyPresser.commandShiftU() @@ -1007,10 +1021,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) defer { log.debug("muteThread took \(startTime.timeIntervalSinceNow * -1000)ms") } #endif - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) try await withAutomation { - try await withActivation(openBefore: url) { + try await withActivation(openBefore: deepLink) { try await assertSelectedThread(threadID: threadID) let selectedThreadCell = try await scrollAndGetSelectedThreadCell(threadID: threadID) if muted { @@ -1033,10 +1047,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) 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 await withAutomation { - try await withActivation(openBefore: url) { + try await withActivation(openBefore: deepLink) { try await assertSelectedThread(threadID: threadID) try await triggerThreadCellAction(threadID: threadID, action: .delete) try await elements.alertSheetDeleteButton.press() @@ -1050,10 +1064,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) // (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 await withAutomation { - _ = try await openDeepLink(url) + _ = try await openDeepLink(deepLink) } } @@ -1234,17 +1248,24 @@ 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 { + 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") } @@ -1264,7 +1285,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) if quotedMessage == nil { try? await closeReplyTranscriptViewAndWait() } // needed even when opening deep link - try await withActivation(openBefore: url) { + try await withActivation(openBefore: deepLink) { if let threadID { try await assertSelectedThread(threadID: threadID) } if quotedMessage != nil { @@ -1386,6 +1407,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) do { 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 @@ -1487,10 +1509,10 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } func notifyAnyway(threadID: String) async throws { - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) try await withAutomation { - try await withActivation(openBefore: url) { + try await withActivation(openBefore: deepLink) { try await assertSelectedThread(threadID: threadID) try await elements.notifyAnywayButton.press() } @@ -1498,11 +1520,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } func activityStatus(threadID: String) async throws -> ThreadActivityObservation { - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) var observation = ThreadActivityObservation.unknown try await withAutomation { - try await withActivation(openBefore: url) { + try await withActivation(openBefore: deepLink) { try await assertSelectedThread(threadID: threadID) observation = await activityObservation() } @@ -1527,7 +1549,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) /// Checks one thread while controller work is idle. /// The platform-level idle observer calls it repeatedly after active automation drains. func observeIdleActivity(threadID: String, statusSender: @escaping @Sendable (ThreadActivityObservation) -> Void) async throws { - let url = try MessagesDeepLink(threadID: threadID, body: nil).url() + let deepLink = try MessagesDeepLink(threadID: threadID, body: nil) guard !Defaults.shouldCoordinateWindow && !messagesIsManuallyActivated else { log.debug("not observing activity, Messages is manually activated") @@ -1548,12 +1570,11 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) return } - if lastThreadIDOpenedForObservation.read() != threadID { + if lastOpenedThreadID != threadID { log.debug("activity: entered idle state or thread id changed, opening deep link") try await withAutomation { - _ = try await self.openDeepLink(url) + _ = try await self.openDeepLink(deepLink) log.debug("activity: opened deep link, waiting for layout change") - self.lastThreadIDOpenedForObservation.withLock { $0 = threadID } try await self.waitForLayoutChange(timeout: 0.5) } } 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( From 7257425acb23ae91f09ec99d7cac103a8f7ab554 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:55:19 +0530 Subject: [PATCH 50/59] Update PlatformAPI.swift --- src/IMessage/Sources/IMessage/PlatformAPI.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index 9a278b42..d87040fa 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -505,9 +505,7 @@ public final class PlatformAPI { return } - guard Defaults.watchThreadActivity, - !Preferences.enabledExperiments.contains("no_watch_thread") - else { + guard Defaults.watchThreadActivity else { clearSelectedThreadActivity() return } From 5aeefee9c88d9352fe38bfdc7be7377e3d9c5ba9 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:56:53 +0530 Subject: [PATCH 51/59] Update MessagesController.swift --- src/IMessage/Sources/IMessage/Messages/MessagesController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index e6af2a27..cf10cb8b 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1256,7 +1256,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) overlay: quotedMessage.overlay, threadID: threadID ) - } else if let quotedMessage { + } 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, From 3b452a27f1d6a1b3120b507df61b2dec95693529 Mon Sep 17 00:00:00 2001 From: Purav Manot <60108184+pmanot@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:12:10 +0530 Subject: [PATCH 52/59] Update src/IMessage/Sources/IMessage/PromptAutomation.swift Co-authored-by: indent[bot] <216979840+indent[bot]@users.noreply.github.com> --- src/IMessage/Sources/IMessage/PromptAutomation.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/IMessage/Sources/IMessage/PromptAutomation.swift b/src/IMessage/Sources/IMessage/PromptAutomation.swift index 047c9eb6..cfd6c702 100644 --- a/src/IMessage/Sources/IMessage/PromptAutomation.swift +++ b/src/IMessage/Sources/IMessage/PromptAutomation.swift @@ -70,7 +70,9 @@ enum PromptAutomation { configuration.activates = false // hides shows a gray background and doesn't render the UI - configuration.hides = true + configuration.activates = false + + let app = try await NSWorkspace.shared.open( let app = try await NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!, From d8572c16e43cab820b920b6cbaa662fa106dd82b Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Thu, 4 Jun 2026 20:29:05 +0530 Subject: [PATCH 53/59] fix broken commit --- src/IMessage/Sources/IMessage/PromptAutomation.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/IMessage/Sources/IMessage/PromptAutomation.swift b/src/IMessage/Sources/IMessage/PromptAutomation.swift index cfd6c702..4517a275 100644 --- a/src/IMessage/Sources/IMessage/PromptAutomation.swift +++ b/src/IMessage/Sources/IMessage/PromptAutomation.swift @@ -67,12 +67,11 @@ enum PromptAutomation { static func disableNotificationsForApp(named appName: String) async throws -> Bool { let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = false // hides shows a gray background and doesn't render the UI - configuration.activates = false + // configuration.hides = false - let app = try await NSWorkspace.shared.open( + configuration.activates = false let app = try await NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!, From 2a4275ddf16fa4e919c8d1b96219d3ab6d66d9a1 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 5 Jun 2026 16:59:15 +0530 Subject: [PATCH 54/59] add withTimeout primitive --- .../IMessage/Utilities/Task+Timeout.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift 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..a367eece --- /dev/null +++ b/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct TimeoutError: Error, Equatable, Sendable {} + +func withTimeout( + _ timeout: TimeInterval, + operation: sending @escaping @isolated(any) () async throws -> Success +) async throws -> Success { + let deadline: Date = Date().addingTimeInterval(timeout) + + guard timeout > 0 else { + throw TimeoutError() + } + + let timeoutTask: @Sendable () async throws -> Success = { + let remainingTime = deadline.timeIntervalSinceNow + + guard remainingTime > 0 else { + throw TimeoutError() + } + + if #available(macOS 13.0, *) { + let clock: ContinuousClock = ContinuousClock() + let deadlineInstant: ContinuousClock.Instant = clock.now.advanced(by: .seconds(remainingTime)) + try await Task.sleep(until: deadlineInstant, tolerance: nil, clock: clock) + } else { + try await Task.sleep(forTimeInterval: remainingTime) + } + + throw TimeoutError() + } + + return try await withThrowingTaskGroup(of: Success.self) { group in + defer { + group.cancelAll() + } + + if #available(macOS 26.0, *) { + group.addImmediateTask(operation: timeoutTask) + } else { + group.addTask(operation: timeoutTask) + } + + group.addTask { + try await operation() + } + + guard let value = try await group.next() else { + throw Swift.CancellationError() + } + + return value + } +} + +public extension Task where Failure == any Error { + 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 withTimeout(timeout, operation: operation) + } + } +} From b8b7b0a501a30c0622515b064b281fbe5afb6339 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 5 Jun 2026 17:00:08 +0530 Subject: [PATCH 55/59] expose KVO through AsyncStream --- .../IMessage/Utilities/KVO+AsyncStream.swift | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift 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..eeebf964 --- /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 withTimeout(timeout) { [self] in + guard let value = try await self.waitForValue(keyPath, options: options, where: predicate) else { + throw Swift.CancellationError() + } + + return value + } + } +} From 74e97c5150bc46dbadce4269610d567e3ae520ae Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 5 Jun 2026 17:03:47 +0530 Subject: [PATCH 56/59] rewrite waitForLaunch --- .../Sources/IMessage/Extensions.swift | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Extensions.swift b/src/IMessage/Sources/IMessage/Extensions.swift index e525df5b..52783f47 100644 --- a/src/IMessage/Sources/IMessage/Extensions.swift +++ b/src/IMessage/Sources/IMessage/Extensions.swift @@ -16,22 +16,26 @@ extension NSApplication { extension NSRunningApplication { func waitForLaunch(timeout seconds: TimeInterval = 5) async throws { - var cancellable: AnyCancellable? - _ = cancellable + if isFinishedLaunching, !isTerminated { + return + } + + try await 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") + } - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - if isFinishedLaunching { - continuation.resume() - } else { - cancellable = self.publisher(for: \.isFinishedLaunching, options: [.initial, .new]) - .filter { $0 } // we only care about isFinishedLaunching = true - .first() - .timeout(.seconds(seconds), scheduler: DispatchQueue.global()) - .sink { _ in - continuation.resume() - } receiveValue: { _ in - - } + try await group.next() } } } From aef29254cb4c01bc25c12f2ae8ea21b4decf3694 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 5 Jun 2026 17:04:06 +0530 Subject: [PATCH 57/59] improve LaunchServices code + add timeouts --- .../Messages/MessagesController.swift | 4 +- .../Messages/MessagesInstanceTarget.swift | 17 ++-- .../Utilities/NSWorkspace+AsyncOpen.swift | 88 ----------------- .../IMessageTests/AsyncUtilityTests.swift | 87 +++++++++++++++++ .../NSWorkspaceAsyncOpenTests.swift | 97 ------------------- 5 files changed, 97 insertions(+), 196 deletions(-) delete mode 100644 src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift create mode 100644 src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift delete mode 100644 src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index cf10cb8b..982f0d62 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -159,7 +159,9 @@ final class MessagesController { case .handledBySecondaryInstance(let app): return app case .open(let openOptions): - return try await NSWorkspace.shared.open(url, configuration: openOptions, timeout: timeout) + return try await withTimeout(timeout) { + try await NSWorkspace.shared.open(url, configuration: openOptions) + } } } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift index c4a84847..8a074407 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift @@ -54,23 +54,20 @@ enum MessagesInstanceTarget { configuration.launchWithoutRestoringState = true configuration.waitForApplicationToCheckIn = true - let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: timeout, - timeoutMessage: "Timed out waiting for secondary Messages.app launch after \(timeout)s", - missingApplicationMessage: "LaunchServices completed without returning Messages.app" - ) { completion in - NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration, completionHandler: completion) + let application = try await withTimeout(timeout) { + try await NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration) } - try await 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/Utilities/NSWorkspace+AsyncOpen.swift b/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift deleted file mode 100644 index fbe66165..00000000 --- a/src/IMessage/Sources/IMessage/Utilities/NSWorkspace+AsyncOpen.swift +++ /dev/null @@ -1,88 +0,0 @@ -import AppKit -import Foundation -import IMessageCore - -extension NSWorkspace { - typealias RunningApplicationOpenHandler = @Sendable (NSRunningApplication?, Error?) -> Void - - func waitForRunningApplicationOpen( - timeout: TimeInterval, - timeoutMessage: String, - missingApplicationMessage: String = "LaunchServices completed without returning an app", - start: (@escaping RunningApplicationOpenHandler) -> Void - ) async throws -> NSRunningApplication { - typealias OpenContinuation = CheckedContinuation - let state = Protected<(continuation: OpenContinuation?, completed: Bool, timeoutTask: Task?)>((nil, false, nil)) - - // Single completion point. Whoever finishes first (LaunchServices callback, - // the timeout, or caller cancellation) resumes the continuation and tears down - // the timeout task; everyone else is a no-op via the `completed` flag. Cancelling - // the timeout task here (rather than only in the success callback) means a - // cancelled or errored open doesn't leave the timeout task sleeping with its - // captured state retained until the deadline. - let finish: @Sendable (Result) -> Void = { result in - let (continuation, timeoutTask) = state.withLock { state -> (OpenContinuation?, Task?) in - guard !state.completed else { - return (nil, nil) - } - state.completed = true - let continuation = state.continuation - let timeoutTask = state.timeoutTask - state.continuation = nil - state.timeoutTask = nil - return (continuation, timeoutTask) - } - timeoutTask?.cancel() - continuation?.resume(with: result) - } - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: OpenContinuation) in - guard state.withLock({ - guard !$0.completed else { - return false - } - $0.continuation = continuation - return true - }) else { - continuation.resume(throwing: CancellationError()) - return - } - - let timeoutTask = Task { - try? await Task.sleep(forTimeInterval: timeout) - guard !Task.isCancelled else { return } - finish(.failure(ErrorMessage(timeoutMessage))) - } - // Publish the handle before calling `start` so a synchronous completion - // can still cancel the timeout task through `finish`. - state.withLock { $0.timeoutTask = timeoutTask } - - start { running, error in - if let error { - finish(.failure(error)) - } else if let running { - finish(.success(running)) - } else { - finish(.failure(ErrorMessage(missingApplicationMessage))) - } - } - } - } onCancel: { - finish(.failure(CancellationError())) - } - } - - func open( - _ url: URL, - configuration: OpenConfiguration, - timeout: TimeInterval - ) async throws -> NSRunningApplication { - try await waitForRunningApplicationOpen( - timeout: timeout, - timeoutMessage: "Timed out opening URL via LaunchServices after \(timeout)s" - ) { completion in - open(url, configuration: configuration, completionHandler: completion) - } - } -} diff --git a/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift b/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift new file mode 100644 index 00000000..b0b28491 --- /dev/null +++ b/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift @@ -0,0 +1,87 @@ +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 withTimeout(5) { + 42 + } + + #expect(value == 42) +} + +@Test func withTimeoutPropagatesOperationError() async throws { + await #expect(throws: TaskTimeoutTestError.self) { + _ = try await withTimeout(5) { + throw TaskTimeoutTestError() + } + } +} + +@Test func withTimeoutThrowsWhenOperationDoesNotFinishBeforeDeadline() async throws { + let started = Protected(false) + let startedAt = Date() + + await #expect(throws: TimeoutError.self) { + _ = try await 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 withTimeoutPropagatesCallerCancellation() async throws { + let started = Protected(false) + let task = Task { + try await 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: TimeoutError.self) { + _ = try await object.waitForValue(\.isReady, timeout: 0.05) { $0 } + } +} + +@Test func openDeepLinkHonorsZeroTimeoutBeforeLaunchServicesOpen() async throws { + await #expect(throws: TimeoutError.self) { + _ = try await MessagesController.openDeepLink(.compose, timeout: 0) + } +} diff --git a/src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift b/src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift deleted file mode 100644 index 47a3c1e1..00000000 --- a/src/IMessage/Sources/IMessageTests/NSWorkspaceAsyncOpenTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import AppKit -import Foundation -@testable import IMessage -@testable import IMessageCore -import Testing - -// Covers NSWorkspace.waitForRunningApplicationOpen — the continuation bridge that -// replaced the DispatchSemaphore LaunchServices opens. The `start` closure is -// injected, so every completion path is exercised without launching an app. -// NSRunningApplication.current stands in for a real returned app (identity check -// via processIdentifier). - -private struct OpenTestError: Error, Equatable {} - -@Test func asyncOpenReturnsRunningApplicationOnSuccess() async throws { - let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: 5, - timeoutMessage: "should not time out" - ) { completion in - completion(NSRunningApplication.current, nil) - } - #expect(app.processIdentifier == NSRunningApplication.current.processIdentifier) -} - -@Test func asyncOpenPropagatesStartError() async throws { - await #expect(throws: OpenTestError.self) { - try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: 5, - timeoutMessage: "should not time out" - ) { completion in - completion(nil, OpenTestError()) - } - } -} - -@Test func asyncOpenThrowsMissingApplicationWhenNeitherReturned() async throws { - let missing = "no app returned (test)" - do { - _ = try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: 5, - timeoutMessage: "should not time out", - missingApplicationMessage: missing - ) { completion in - completion(nil, nil) - } - Issue.record("expected waitForRunningApplicationOpen to throw") - } catch let error as ErrorMessage { - #expect(error.description == missing) - } -} - -@Test func asyncOpenTimesOutWhenStartNeverCompletes() async throws { - let timeoutMessage = "timed out (test)" - do { - _ = try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: 0.05, - timeoutMessage: timeoutMessage - ) { _ in - // intentionally never completes - } - Issue.record("expected waitForRunningApplicationOpen to time out") - } catch let error as ErrorMessage { - #expect(error.description == timeoutMessage) - } -} - -@Test func asyncOpenThrowsCancellationWhenCallerCancelled() async throws { - let started = Protected(false) - let task = Task { - try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: 10, - timeoutMessage: "should not time out" - ) { _ in - started.withLock { $0 = true } - // never completes; resolution must come from cancellation - } - } - - #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 asyncOpenIgnoresSecondCompletion() async throws { - // First completion wins; a later completion must be a no-op (the `completed` - // flag guards the checked continuation against a fatal double-resume). - let app = try await NSWorkspace.shared.waitForRunningApplicationOpen( - timeout: 5, - timeoutMessage: "should not time out" - ) { completion in - completion(NSRunningApplication.current, nil) - completion(nil, OpenTestError()) // must be ignored, not crash - } - #expect(app.processIdentifier == NSRunningApplication.current.processIdentifier) -} From b31da6c8b82f7c9944ea8d1a0d7c2bedb05bcb1d Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Sat, 6 Jun 2026 23:07:19 +0530 Subject: [PATCH 58/59] fix timeout (wip) --- .../Sources/IMessage/Extensions.swift | 2 +- .../Messages/MessagesController.swift | 2 +- .../Messages/MessagesInstanceTarget.swift | 2 +- .../IMessage/Utilities/KVO+AsyncStream.swift | 2 +- .../IMessage/Utilities/Task+Timeout.swift | 205 ++++++++++++++---- .../IMessageTests/AsyncUtilityTests.swift | 30 ++- 6 files changed, 189 insertions(+), 54 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Extensions.swift b/src/IMessage/Sources/IMessage/Extensions.swift index 52783f47..24acc9fa 100644 --- a/src/IMessage/Sources/IMessage/Extensions.swift +++ b/src/IMessage/Sources/IMessage/Extensions.swift @@ -20,7 +20,7 @@ extension NSRunningApplication { return } - try await withTimeout(seconds) { + try await Task.withTimeout(seconds) { try await withThrowingTaskGroup(of: Void.self) { group in defer { group.cancelAll() diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 982f0d62..4b3f8037 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -159,7 +159,7 @@ final class MessagesController { case .handledBySecondaryInstance(let app): return app case .open(let openOptions): - return try await withTimeout(timeout) { + return try await Task.withTimeout(timeout) { try await NSWorkspace.shared.open(url, configuration: openOptions) } } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift index 8a074407..a5e9e571 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesInstanceTarget.swift @@ -54,7 +54,7 @@ enum MessagesInstanceTarget { configuration.launchWithoutRestoringState = true configuration.waitForApplicationToCheckIn = true - let application = try await withTimeout(timeout) { + let application = try await Task.withTimeout(timeout) { try await NSWorkspace.shared.openApplication(at: applicationURL, configuration: configuration) } diff --git a/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift b/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift index eeebf964..f6be5193 100644 --- a/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift +++ b/src/IMessage/Sources/IMessage/Utilities/KVO+AsyncStream.swift @@ -112,7 +112,7 @@ public extension NSObjectProtocol where Self: NSObject { options: NSKeyValueObservingOptions = [.initial, .new], where predicate: @escaping @Sendable (Value) async throws -> Bool ) async throws -> Value { - try await withTimeout(timeout) { [self] in + try await Task.withTimeout(timeout) { [self] in guard let value = try await self.waitForValue(keyPath, options: options, where: predicate) else { throw Swift.CancellationError() } diff --git a/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift b/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift index a367eece..8cb3a91b 100644 --- a/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift +++ b/src/IMessage/Sources/IMessage/Utilities/Task+Timeout.swift @@ -1,67 +1,186 @@ import Foundation +import IMessageCore -public struct TimeoutError: Error, Equatable, Sendable {} +extension Task where Success == Never, Failure == Never { + struct TimeoutError: Error, Equatable, Sendable {} +} -func withTimeout( - _ timeout: TimeInterval, - operation: sending @escaping @isolated(any) () async throws -> Success -) async throws -> Success { - let deadline: Date = Date().addingTimeInterval(timeout) +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) + } - guard timeout > 0 else { - throw TimeoutError() + 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) + } } +} - let timeoutTask: @Sendable () async throws -> Success = { - let remainingTime = deadline.timeIntervalSinceNow +private extension Task where Success == Never, Failure == Never { + struct TimeoutState { + var continuation: CheckedContinuation? + var operationTask: Task? + var timeoutTask: Task? + var result: Result? + } - guard remainingTime > 0 else { - throw TimeoutError() - } + static func _withTimeout( + _ timeout: TimeInterval, + operation: sending @escaping @isolated(any) () async throws -> Output + ) async throws -> Output { + let deadline: Date = Date().addingTimeInterval(timeout) - if #available(macOS 13.0, *) { - let clock: ContinuousClock = ContinuousClock() - let deadlineInstant: ContinuousClock.Instant = clock.now.advanced(by: .seconds(remainingTime)) - try await Task.sleep(until: deadlineInstant, tolerance: nil, clock: clock) - } else { - try await Task.sleep(forTimeInterval: remainingTime) + guard timeout > 0 else { + throw Task.TimeoutError() } - throw 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) + } } - return try await withThrowingTaskGroup(of: Success.self) { group in - defer { - group.cancelAll() + 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 #available(macOS 26.0, *) { - group.addImmediateTask(operation: timeoutTask) - } else { - group.addTask(operation: timeoutTask) + 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() - group.addTask { - try await operation() + 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 let value = try await group.next() else { - throw Swift.CancellationError() + guard remainingTime > 0 else { + return } - return value - } -} + if #available(macOS 13.0, *) { + let clock = ContinuousClock() + let deadlineInstant = clock.now.advanced(by: .seconds(remainingTime)) -public extension Task where Failure == any Error { - 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 withTimeout(timeout, operation: operation) + try await Task.sleep(until: deadlineInstant, tolerance: nil, clock: clock) + } else { + try await Task.sleep(forTimeInterval: remainingTime) } } } diff --git a/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift b/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift index b0b28491..1f3e0d69 100644 --- a/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift +++ b/src/IMessage/Sources/IMessageTests/AsyncUtilityTests.swift @@ -10,7 +10,7 @@ private final class ObservableFlag: NSObject { } @Test func withTimeoutReturnsOperationValue() async throws { - let value = try await withTimeout(5) { + let value = try await Task.withTimeout(5) { 42 } @@ -19,7 +19,7 @@ private final class ObservableFlag: NSObject { @Test func withTimeoutPropagatesOperationError() async throws { await #expect(throws: TaskTimeoutTestError.self) { - _ = try await withTimeout(5) { + _ = try await Task.withTimeout(5) { throw TaskTimeoutTestError() } } @@ -29,8 +29,8 @@ private final class ObservableFlag: NSObject { let started = Protected(false) let startedAt = Date() - await #expect(throws: TimeoutError.self) { - _ = try await withTimeout(0.05) { + await #expect(throws: Task.TimeoutError.self) { + _ = try await Task.withTimeout(0.05) { started.withLock { $0 = true } try await Task.sleep(forTimeInterval: 60) return 0 @@ -41,10 +41,26 @@ private final class ObservableFlag: NSObject { #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 withTimeout(10) { + try await Task.withTimeout(10) { started.withLock { $0 = true } try await Task.sleep(forTimeInterval: 60) return 0 @@ -75,13 +91,13 @@ private final class ObservableFlag: NSObject { @Test func waitForValueUsesTimeoutWhenObservedValueNeverMatches() async throws { let object = ObservableFlag() - await #expect(throws: TimeoutError.self) { + await #expect(throws: Task.TimeoutError.self) { _ = try await object.waitForValue(\.isReady, timeout: 0.05) { $0 } } } @Test func openDeepLinkHonorsZeroTimeoutBeforeLaunchServicesOpen() async throws { - await #expect(throws: TimeoutError.self) { + await #expect(throws: Task.TimeoutError.self) { _ = try await MessagesController.openDeepLink(.compose, timeout: 0) } } From c4afe9785291e7581b2bc2ed3f2d0672564c6166 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:57:29 +0530 Subject: [PATCH 59/59] always open chat, even if we don't want to observe --- .../Messages/MessagesController.swift | 40 +++++++++++++------ .../Sources/IMessage/PlatformAPI.swift | 23 ++++++----- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 4b3f8037..e05e58ac 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -1550,25 +1550,22 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) /// Checks one thread while controller work is idle. /// The platform-level idle observer calls it repeatedly after active automation drains. - func observeIdleActivity(threadID: String, statusSender: @escaping @Sendable (ThreadActivityObservation) -> Void) async throws { + 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 occlusionMonitor.visible else { + guard isValid else { #if DEBUG - log.debug("not observing activity, window occluded") + log.debug("not observing activity, controller is invalid") #endif return } - guard isValid else { - #if DEBUG - log.debug("not observing activity, controller is invalid") - #endif + guard !messagesIsManuallyActivated else { + log.debug("not observing activity, Messages is manually activated") return } @@ -1581,6 +1578,25 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } } + guard readActivity else { + #if DEBUG + log.debug("not observing activity, thread doesn't support activity observation") + #endif + return + } + + guard !Defaults.shouldCoordinateWindow else { + log.debug("not observing activity, Messages window is being coordinated") + return + } + + guard occlusionMonitor.visible else { + #if DEBUG + log.debug("not observing activity, window occluded") + #endif + return + } + let observationToSend = await activityObservation() guard lastSentActivityObservation != observationToSend || (observationToSend.activityType == .typing && lastSentActivityObservationTime.map { $0.timeIntervalSinceNow * -1 > 30 } == true) else { #if DEBUG diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index d87040fa..1fd68f47 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -505,11 +505,6 @@ public final class PlatformAPI { return } - guard Defaults.watchThreadActivity else { - clearSelectedThreadActivity() - return - } - let threadID: String do { threadID = try originalThreadID(for: publicThreadID) @@ -583,11 +578,15 @@ public final class PlatformAPI { platformLog.debug("activity/\(publicThreadID): watching") try await withMessagesController { controller in - guard try Self.threadSupportsActivityObservation(threadID: threadID, controller: controller) else { + 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() - return } - await Self.observeSelectedThreadActivity(using: controller) } } @@ -679,7 +678,7 @@ public final class PlatformAPI { return true } - static func observeSelectedThreadActivity(using controller: MessagesController) async { + static func observeSelectedThreadActivity(using controller: MessagesController, readActivity: Bool = true) async { guard let selectedThread = selectedThreadActivityState.read() else { return } @@ -696,7 +695,11 @@ public final class PlatformAPI { } do { - try await controller.observeIdleActivity(threadID: selectedThread.threadID, statusSender: sendStatus) + try await controller.observeIdleActivity( + threadID: selectedThread.threadID, + readActivity: readActivity, + statusSender: sendStatus + ) } catch { platformLog.error("failed to observe activity: \(error)") }