diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index 6d9243e..71a53c2 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -259,6 +259,10 @@ EB5C383F2E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; EB5C38402E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; EB5C38412E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; + EB63EDB72FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB63EDB62FF42390000CC61F /* ModelessAlertPresenter.swift */; }; + EB63EDB92FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB63EDB62FF42390000CC61F /* ModelessAlertPresenter.swift */; }; + EB63EDBA2FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB63EDB62FF42390000CC61F /* ModelessAlertPresenter.swift */; }; + EB63EDBB2FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB63EDB62FF42390000CC61F /* ModelessAlertPresenter.swift */; }; EB6E51A52F2F9370006D9812 /* IntroductionSheetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */; }; EB6E51A62F2F9370006D9812 /* IntroductionSheetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */; }; EB6E51AB2F311ED4006D9812 /* QuickDropIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = EB721F232E75AFA900F2099F /* QuickDropIcon.icon */; }; @@ -575,6 +579,7 @@ EB5C37E22E377C2E0052EFD7 /* QuickDropiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickDropiOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; EB5C38382E38C1F60052EFD7 /* justMaybe.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = justMaybe.mp3; sourceTree = ""; }; EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = ""; }; + EB63EDB62FF42390000CC61F /* ModelessAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelessAlertPresenter.swift; sourceTree = ""; }; EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroductionSheetManager.swift; sourceTree = ""; }; EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -991,6 +996,7 @@ A1C0DE152F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift */, EB1325F72E70287C00FD749C /* SendToExtension.swift */, EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */, + EB63EDB62FF42390000CC61F /* ModelessAlertPresenter.swift */, EB886E662E4F2F7C0052B7D9 /* License.swift */, EB6FE7D12EFEFB2700FF2BCF /* AllowedWorkMonitor.swift */, EB36435F2D73A29F009CDFF8 /* AudioManager.swift */, @@ -1389,6 +1395,7 @@ A7D000000000000000000103 /* AndroidAirDropModeIssueView.swift in Sources */, EBC05C882E33B58300EECC18 /* FirewallIssueView.swift in Sources */, EBC05C892E33B58300EECC18 /* ApIsolationIssueView.swift in Sources */, + EB63EDBB2FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */, 699DEBA62AB0573200115D22 /* ShareViewController.swift in Sources */, EB96ED8B2D7F7CF7004B3FF1 /* SmallSheetView.swift in Sources */, EBCC27F92D80C9F90079C478 /* Zip.swift in Sources */, @@ -1447,6 +1454,7 @@ EB5C37C22E377A780052EFD7 /* device_to_device_messages.pb.swift in Sources */, EB5C37C32E377A780052EFD7 /* wire_format.pb.swift in Sources */, EB16B6152E58B4B9007D9792 /* IdentityManager.swift in Sources */, + EB63EDB92FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */, EB5C37C42E377A780052EFD7 /* securegcm.pb.swift in Sources */, EB97A0102E51CD13008B8C5C /* ReceiveModel.swift in Sources */, A1F5B3DD2F3A1A0100A1B2C8 /* PairingQrSheetManager.swift in Sources */, @@ -1519,6 +1527,7 @@ EB3B9F0F2EF7150400D6704E /* device_to_device_messages.pb.swift in Sources */, EB3B9F102EF7150400D6704E /* wire_format.pb.swift in Sources */, EB3B9F112EF7150400D6704E /* IdentityManager.swift in Sources */, + EB63EDB72FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */, EB3B9F122EF7150400D6704E /* securegcm.pb.swift in Sources */, EB3B9F132EF7150400D6704E /* ReceiveModel.swift in Sources */, A1F5B3DE2F3A1A0100A1B2C9 /* PairingQrSheetManager.swift in Sources */, @@ -1575,6 +1584,7 @@ A7D000000000000000000104 /* AndroidAirDropModeIssueView.swift in Sources */, EB3B9F542EF7151900D6704E /* FirewallIssueView.swift in Sources */, EB3B9F552EF7151900D6704E /* ApIsolationIssueView.swift in Sources */, + EB63EDBA2FF42390000CC61F /* ModelessAlertPresenter.swift in Sources */, EB3B9F562EF7151900D6704E /* ShareViewController.swift in Sources */, EB3B9F572EF7151900D6704E /* SmallSheetView.swift in Sources */, EB3B9F582EF7151900D6704E /* Zip.swift in Sources */, diff --git a/QuickDrop/Util/ErrorAlertHandler.swift b/QuickDrop/Util/ErrorAlertHandler.swift index 74bfbe9..0801eab 100644 --- a/QuickDrop/Util/ErrorAlertHandler.swift +++ b/QuickDrop/Util/ErrorAlertHandler.swift @@ -106,29 +106,11 @@ class ErrorAlertHandler { #if os(macOS) - let primaryButtonTitle = "InformDeveloper".localized() - let secondaryButtonTitle = "CloseAlert".localized() - + // Shown modelessly (the native NSAlert panel via makeKeyAndOrderFront) rather than + // runModal(), which blocks the main run loop and would freeze the next incoming transfer's + // consent prompt until dismissed. Same window runModal() uses, so it looks identical. self.isAlertShown = true - let alert = NSAlert() - alert.alertStyle = .critical - alert.messageText = title - alert.informativeText = description - alert.addButton(withTitle: primaryButtonTitle) - alert.addButton(withTitle: secondaryButtonTitle) - - let result = alert.runModal() - self.isAlertShown = false - - if result == .alertFirstButtonReturn { - if Bundle.main.bundlePath.hasSuffix(".appex") { - if let url = URL(string: "quickdrop://sendLog") { - NSWorkspace.shared.open(url) - } - } else { - LogExportPresenter.showUploadLogsAlert(openSupportMailAfterUpload: false) - } - } + presentTransferErrorAlert(title: title, description: description) #else showAlert(title: title, message: description) #endif @@ -211,6 +193,44 @@ class ErrorAlertHandler { window.makeKeyAndOrderFront(nil) window.orderFrontRegardless() } + + + /// Shows the transfer-error alert as the native `NSAlert` panel, but presented modelessly + /// (`makeKeyAndOrderFront`) instead of `runModal()`, so it does NOT block the main run loop — an + /// incoming transfer's consent prompt can still appear while it's on screen. It's the same + /// `_NSAlertPanel` window `runModal()` would show, so it looks identical; we just route the button + /// clicks ourselves (there's no modal session to end) and reset state on dismissal. + private func presentTransferErrorAlert(title: String, description: String) { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = title + alert.informativeText = description + alert.addButton(withTitle: "InformDeveloper".localized()) // tag == .alertFirstButtonReturn + alert.addButton(withTitle: "CloseAlert".localized()) // tag == .alertSecondButtonReturn + alert.buttons.first?.keyEquivalent = "\r" // default button: Return + if alert.buttons.count > 1 { alert.buttons[1].keyEquivalent = "\u{1b}" } // Close: Escape + + ModelessAlertPresenter.present(alert) { [weak self] response in + self?.isAlertShown = false + if response == .alertFirstButtonReturn { + Self.presentLogUpload() + } + } + } + + + /// The "Inform developer" action: from the share extension, hand off to the host app via the + /// `quickdrop://sendLog` URL; otherwise upload logs directly. Mirrors the previous alert's primary + /// button. + private static func presentLogUpload() { + if Bundle.main.bundlePath.hasSuffix(".appex") { + if let url = URL(string: "quickdrop://sendLog") { + NSWorkspace.shared.open(url) + } + } else { + LogExportPresenter.showUploadLogsAlert(openSupportMailAfterUpload: false) + } + } #endif } diff --git a/QuickDrop/Util/ModelessAlertPresenter.swift b/QuickDrop/Util/ModelessAlertPresenter.swift new file mode 100644 index 0000000..c8ac526 --- /dev/null +++ b/QuickDrop/Util/ModelessAlertPresenter.swift @@ -0,0 +1,78 @@ +// +// ModelessAlertPresenter.swift +// QuickDrop +// +// Created by Leon Böttger on 30.06.26. +// + +import AppKit + +/// Presents an `NSAlert` as its own modeless panel (non-blocking) instead of `runModal()`. It's the +/// exact `_NSAlertPanel` window `runModal()` would show, so it looks identical — but it does not spin a +/// nested modal run loop, so the app keeps working (e.g. can still receive the next transfer) while the +/// alert is on screen. +/// +/// Use this in place of `alert.runModal()` for any alert that may appear while the app is busy. The +/// clicked button is reported to `completion` as the `NSApplication.ModalResponse` that `runModal()` +/// would have returned (first button = `.alertFirstButtonReturn`, second = `.alertSecondButtonReturn`, +/// …). Must be called on the main thread. +final class ModelessAlertPresenter: NSObject { + + /// Keeps each live presenter — and thus its `NSAlert` and panel — retained while on screen; the + /// entry is removed when the alert is dismissed. `NSButton.target` is weak and the panel is owned by + /// the `NSAlert`, so without this the alert would deallocate immediately. + private static var active: Set = [] + + private let alert: NSAlert + private let completion: (NSApplication.ModalResponse) -> Void + + private init(alert: NSAlert, completion: @escaping (NSApplication.ModalResponse) -> Void) { + self.alert = alert + self.completion = completion + } + + static func present(_ alert: NSAlert, + completion: @escaping (NSApplication.ModalResponse) -> Void = { _ in }) { + + if let window = NSApp.mainWindow ?? NSApp.keyWindow { + alert.beginSheetModal(for: window, completionHandler: completion) + } + else { + let presenter = ModelessAlertPresenter(alert: alert, completion: completion) + active.insert(presenter) + presenter.show() + } + } + + private func show() { + alert.layout() + + // runModal() makes the default button respond to Return; modelessly we set it ourselves — but + // only if the caller specified no key equivalents, so explicit ones (e.g. Escape) are kept. + if alert.buttons.allSatisfy({ $0.keyEquivalent.isEmpty }) { + alert.buttons.first?.keyEquivalent = "\r" + } + + for button in alert.buttons { + button.target = self + button.action = #selector(buttonClicked(_:)) + } + + let window = alert.window + window.center() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + + @objc private func buttonClicked(_ sender: NSButton) { + let response = NSApplication.ModalResponse(rawValue: sender.tag) + alert.window.orderOut(nil) + // Defer teardown so the alert and its panel aren't deallocated while this button's action is + // still on the stack. + DispatchQueue.main.async { + self.completion(response) + ModelessAlertPresenter.active.remove(self) + } + } +} diff --git a/QuickDrop/Util/SendToExtension.swift b/QuickDrop/Util/SendToExtension.swift index fed53e8..9b9d317 100644 --- a/QuickDrop/Util/SendToExtension.swift +++ b/QuickDrop/Util/SendToExtension.swift @@ -26,7 +26,7 @@ func sendToSharingService(items: [Any]) { alert.informativeText = "ShareExtensionDisabledDescriptionLegacy".localized() } alert.addButton(withTitle: "CloseAlert".localized()) - alert.runModal() + ModelessAlertPresenter.present(alert) } } } diff --git a/QuickDrop/View/IssueViews/ApIsolationIssueView.swift b/QuickDrop/View/IssueViews/ApIsolationIssueView.swift index 7d1db9e..dcbaae8 100644 --- a/QuickDrop/View/IssueViews/ApIsolationIssueView.swift +++ b/QuickDrop/View/IssueViews/ApIsolationIssueView.swift @@ -71,7 +71,7 @@ func openRouterSettingsPage() async { alert.informativeText = "ApIsolationFindRouterFailedDescription".localized() alert.addButton(withTitle: "CloseAlert".localized()) - alert.runModal() + ModelessAlertPresenter.present(alert) } return } diff --git a/QuickDrop/View/Settings/SettingsView.swift b/QuickDrop/View/Settings/SettingsView.swift index 3a6219f..2f278d9 100644 --- a/QuickDrop/View/Settings/SettingsView.swift +++ b/QuickDrop/View/Settings/SettingsView.swift @@ -150,8 +150,8 @@ struct SettingsView: View { alert.messageText = "FolderSelectionFailedTitle".localized() alert.informativeText = "FolderSelectionFailedMessage".localized() alert.addButton(withTitle: "CloseAlert".localized()) - - alert.runModal() + + ModelessAlertPresenter.present(alert) } } } diff --git a/QuickDrop/View/UserManual/TutorialView.swift b/QuickDrop/View/UserManual/TutorialView.swift index b99a9e4..d4f5f1c 100644 --- a/QuickDrop/View/UserManual/TutorialView.swift +++ b/QuickDrop/View/UserManual/TutorialView.swift @@ -75,12 +75,7 @@ struct TutorialView: View { alert.informativeText = "AndroidAirDropModeAlertMessage".localized() alert.addButton(withTitle: "CloseAlert".localized()) - if let window = NSApp.mainWindow ?? NSApp.keyWindow { - alert.beginSheetModal(for: window) { _ in } - } - else { - alert.runModal() - } + ModelessAlertPresenter.present(alert) } } diff --git a/QuickDrop/ViewModel/ReceiveModel.swift b/QuickDrop/ViewModel/ReceiveModel.swift index 1e12c71..f737325 100644 --- a/QuickDrop/ViewModel/ReceiveModel.swift +++ b/QuickDrop/ViewModel/ReceiveModel.swift @@ -359,7 +359,7 @@ class ReceiveModel: ObservableObject, InboundAppDelegate { alert.informativeText = error.localizedDescription alert.addButton(withTitle: "CloseAlert".localized()) - alert.runModal() + ModelessAlertPresenter.present(alert) } } } @@ -453,7 +453,7 @@ class ReceiveModel: ObservableObject, InboundAppDelegate { alert.informativeText = error.localizedDescription alert.addButton(withTitle: "CloseAlert".localized()) - alert.runModal() + ModelessAlertPresenter.present(alert) } } }