From ec03273325e54122e2289c5b5629a5fa1f0565ad Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 11:48:39 -0500 Subject: [PATCH 01/10] fix: attribute page departures and preserve native ownership Record page input through release, coalesce reloads without treating hash changes as new documents, and cancel delayed hover exit on return. Keep tab geometry and WebKit fullscreen ownership intact. Preserve nullable JavaScript results and the SDK-specific native dialog witness on older macOS. --- .github/workflows/test.yml | 10 + CHANGELOG.md | 8 + Chorus.xcodeproj/project.pbxproj | 24 + Chorus/App/AppState.swift | 54 +- Chorus/App/ChorusApp.swift | 20 +- Chorus/App/ChorusAppDelegate.swift | 42 +- Chorus/Services/HibernatedBadgePoller.swift | 4 +- Chorus/Services/NotificationManager.swift | 4 +- Chorus/Utilities/InputSettle.swift | 95 ++++ Chorus/Utilities/WebViewJavaScript.swift | 30 + Chorus/Views/MainWindow/WebContentView.swift | 169 +++--- Chorus/Views/MainWindow/WebToolbarView.swift | 2 +- Chorus/Views/WebView/WebViewContainer.swift | 74 ++- Chorus/Views/WebView/WebViewCoordinator.swift | 46 +- Chorus/Views/WebView/WebViewDeparture.swift | 160 ++++++ Chorus/Views/WebView/WebViewPool.swift | 61 +-- ChorusTests/ChorusTests.swift | 21 + ChorusTests/InputSettleTests.swift | 125 +++++ ChorusTests/WebViewDepartureTests.swift | 511 ++++++++++++++++++ ChorusTests/WebViewPoolTests.swift | 78 +++ 20 files changed, 1363 insertions(+), 175 deletions(-) create mode 100644 Chorus/Utilities/InputSettle.swift create mode 100644 Chorus/Utilities/WebViewJavaScript.swift create mode 100644 Chorus/Views/WebView/WebViewDeparture.swift create mode 100644 ChorusTests/InputSettleTests.swift create mode 100644 ChorusTests/WebViewDepartureTests.swift create mode 100644 ChorusTests/WebViewPoolTests.swift diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5c83d3..31b9aa2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,3 +85,13 @@ jobs: -scheme Chorus \ -configuration Debug \ -destination 'platform=macOS' + + - name: Save failed test diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-diagnostics-${{ matrix.os }} + path: | + ~/Library/Developer/Xcode/DerivedData/Chorus-*/Logs/Test/*.xcresult + ~/Library/Logs/DiagnosticReports/Chorus*.ips + retention-days: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf0f2d..96305f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to Chorus are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Fixed + +- Hiding Chorus and switching services happen at once. Chorus gives recent page actions a short time to finish before clearing hover or putting the page aside. This does not confirm a server save: reloading or quitting immediately after a click can still lose an unsent change. +- Service tabs no longer cover the top of the page. Returning from Hide cancels its delayed hover exit, and repeated reload requests share one pending reload. +- Chorus no longer crashes on macOS 14 when a page action returns no result. + ## [1.5.28] - 2026-10-11 ### Changed diff --git a/Chorus.xcodeproj/project.pbxproj b/Chorus.xcodeproj/project.pbxproj index de92af4..1862880 100644 --- a/Chorus.xcodeproj/project.pbxproj +++ b/Chorus.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0294214694FE69AB69B03C55 /* WebViewJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8291060BF6847242E1F03C6 /* WebViewJavaScript.swift */; }; 03162EF33AE4EEB6E524E5B1 /* CatalogGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F17BFF4DCC563429E130A13 /* CatalogGridView.swift */; }; 0413EB0C08636424F9362A1C /* FeatureTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F8BC3392C6945D38F1AEA7 /* FeatureTips.swift */; }; 0551F23E1A9E226486C1A0BB /* AddServiceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECCC4A3133903E40BFB42AA6 /* AddServiceSheet.swift */; }; @@ -57,6 +58,7 @@ 8F6E2C62BD277E5577FBE480 /* WebContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4617D503719C1701307F28 /* WebContentView.swift */; }; 92BF212550953DD2E21A12E7 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01EC3720B49A4D8B953CDA90 /* EmojiPickerView.swift */; }; 9722D48056956FA71E911D98 /* ServicePopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200FB7DAF9279B0E2764474D /* ServicePopup.swift */; }; + 97D0EDAE4F2712616A66B8C2 /* WebViewPoolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E589AEBC286712A2556141FC /* WebViewPoolTests.swift */; }; 9912BDFEE290E93DC57ED3C7 /* DemoControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C2BEF1C847F26AD98EABF0 /* DemoControl.swift */; }; 9EDF307D989D199DE994D432 /* EditServiceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD5F8F7C2450C88FA1A3886 /* EditServiceSheet.swift */; }; 9F0C82A9D1AFDF927EC1A59C /* DarkReaderSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6935142E75345640B2C4D7F0 /* DarkReaderSupport.swift */; }; @@ -69,8 +71,10 @@ BAA5421430BDF6F9B918B7B5 /* WebViewPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B27F538D778C6FC4B1697 /* WebViewPool.swift */; }; BAEE46A4791ABB6963914A5D /* NativeAppSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F91EDD3C2858890FA0B343 /* NativeAppSupport.swift */; }; BD6B6CD0E903780B610BD6B5 /* StoreRecoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05727F83BFEFB338D97E4DF /* StoreRecoveryView.swift */; }; + BEB67E84D36E90ADBC75AEF6 /* WebViewDepartureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F3C6319147E80D9FCD5260E /* WebViewDepartureTests.swift */; }; C14E7A689411E8DCCC20ED74 /* fanboy-annoyance.json in Resources */ = {isa = PBXBuildFile; fileRef = 0D9CBE47687FB4FF2EF6BED5 /* fanboy-annoyance.json */; }; C1E4CE7879BB117DBA721C35 /* SpaceStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537DF7ABA3D6DED64273776A /* SpaceStripView.swift */; }; + C4603B5D275EEABB2283D850 /* InputSettle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89214AFC85F9FE43143A36EC /* InputSettle.swift */; }; C4ADA7C24E806E00FF4709D1 /* ChorusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA2B8A19B41207F66E51CC3 /* ChorusTests.swift */; }; C4F49AC21F05DCC84FE0D96F /* SetupTransferSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132D2C07876ECAB3E3073872 /* SetupTransferSection.swift */; }; C65C95AB514FF50CEE9A75D6 /* ServiceCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30F23AFD0B5D81AF103BF37 /* ServiceCatalog.swift */; }; @@ -80,10 +84,12 @@ CAD06ACCA8483CC80764D8C8 /* hagezi-light.json in Resources */ = {isa = PBXBuildFile; fileRef = D9E64C2450C98941A48AC7BD /* hagezi-light.json */; }; CF6CC3C4CC580F7733D24212 /* SpaceServiceLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312995FF05808961FD519D76 /* SpaceServiceLink.swift */; }; D19CD58F64D002620BE6850C /* StoreRepair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DC16234E82B88C1DB338A49 /* StoreRepair.swift */; }; + D1C6FFBDDA20E8172D0BC347 /* InputSettleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90198C05F204DF1B75DC485C /* InputSettleTests.swift */; }; D2FF4AA7BA395992C0609D93 /* ChorusAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E3F3C66C05EA91501A73EF /* ChorusAppDelegate.swift */; }; D341B327E1C20FA11DEC2C2B /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D533B667501DF0913FF62B9A /* AppState.swift */; }; D4721545C5C3DD809AE549A7 /* ChorusStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50260A359445D2B4C556E1E6 /* ChorusStyle.swift */; }; D861B394A60C4CD2F47CF8EA /* ServiceHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD9BB4DBF6536D1AD0CA298 /* ServiceHealth.swift */; }; + D887CCC00E8FE67581C132C4 /* WebViewDeparture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF59EADC9EE4BBF5C9E0B8E /* WebViewDeparture.swift */; }; DB9B6FD9265D63DCA11B6CFC /* AppCapabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E0D298EDEBDC1F1EB614912 /* AppCapabilities.swift */; }; DF712EA56B5AE10C7BF77176 /* WebViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D78C9503E732B7792FA10 /* WebViewCoordinator.swift */; }; DFA25C2036755E1DAD624BA5 /* licenses in Resources */ = {isa = PBXBuildFile; fileRef = 7C3DF954AFE066BC42761ABC /* licenses */; }; @@ -135,6 +141,7 @@ 464C332182D96B75A17F363C /* WhatsNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNew.swift; sourceTree = ""; }; 4666C49AA129F26D936B0715 /* ServiceInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceInstance.swift; sourceTree = ""; }; 4D2B28970B7AEF9B0EB53AFD /* UnifiedRailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedRailView.swift; sourceTree = ""; }; + 4FF59EADC9EE4BBF5C9E0B8E /* WebViewDeparture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewDeparture.swift; sourceTree = ""; }; 50260A359445D2B4C556E1E6 /* ChorusStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChorusStyle.swift; sourceTree = ""; }; 52D93229EEC510DC0850005E /* SpaceHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceHeaderView.swift; sourceTree = ""; }; 537DF7ABA3D6DED64273776A /* SpaceStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceStripView.swift; sourceTree = ""; }; @@ -156,7 +163,10 @@ 7E0D298EDEBDC1F1EB614912 /* AppCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCapabilities.swift; sourceTree = ""; }; 8223D66D602364046AF98EDC /* Chorus.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Chorus.entitlements; sourceTree = ""; }; 88C307C75776869CE8A82618 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = ""; }; + 89214AFC85F9FE43143A36EC /* InputSettle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSettle.swift; sourceTree = ""; }; 8F17BFF4DCC563429E130A13 /* CatalogGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogGridView.swift; sourceTree = ""; }; + 8F3C6319147E80D9FCD5260E /* WebViewDepartureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewDepartureTests.swift; sourceTree = ""; }; + 90198C05F204DF1B75DC485C /* InputSettleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSettleTests.swift; sourceTree = ""; }; 94326A5DEE01135F945E9DF3 /* Chorus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chorus.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9D811CC9D75730152E17859C /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 9DD5F8F7C2450C88FA1A3886 /* EditServiceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServiceSheet.swift; sourceTree = ""; }; @@ -169,6 +179,7 @@ B44B9AB3EC52D565BFB46183 /* ServiceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceRowView.swift; sourceTree = ""; }; B5AEA5DCBB5AB96E2C962A90 /* WindowBackdrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowBackdrop.swift; sourceTree = ""; }; B71CC3F7D7BD76E4100BC126 /* StoreRelocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreRelocation.swift; sourceTree = ""; }; + B8291060BF6847242E1F03C6 /* WebViewJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewJavaScript.swift; sourceTree = ""; }; B8E0761BBE5A85E34BC73573 /* ServiceTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceTabs.swift; sourceTree = ""; }; BFD9BB4DBF6536D1AD0CA298 /* ServiceHealth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceHealth.swift; sourceTree = ""; }; CA55A0E026084DC20071FBCE /* QuickSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherView.swift; sourceTree = ""; }; @@ -185,6 +196,7 @@ E143D686890F381C5B42761F /* ChorusApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChorusApp.swift; sourceTree = ""; }; E1E431CF7CE145D536081A23 /* UserAgentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentProvider.swift; sourceTree = ""; }; E30F23AFD0B5D81AF103BF37 /* ServiceCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceCatalog.swift; sourceTree = ""; }; + E589AEBC286712A2556141FC /* WebViewPoolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewPoolTests.swift; sourceTree = ""; }; E84C935C8A82FD6B6B969E6B /* ServiceIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceIconView.swift; sourceTree = ""; }; E88CC47CBB66B4749ACDC068 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; EA17C93CEDF437318D536482 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -215,6 +227,7 @@ B8E0761BBE5A85E34BC73573 /* ServiceTabs.swift */, 09D9F34755F65BE5A3C76C88 /* WebViewContainer.swift */, 438D78C9503E732B7792FA10 /* WebViewCoordinator.swift */, + 4FF59EADC9EE4BBF5C9E0B8E /* WebViewDeparture.swift */, 608B27F538D778C6FC4B1697 /* WebViewPool.swift */, 192C907C2215D62E02FF8408 /* WebViewState.swift */, ); @@ -310,8 +323,10 @@ CA77C4C73BEB532F6AC1C339 /* AppRelauncher.swift */, 38CB29411D3B889CE0623121 /* Deadline.swift */, 1ACFB877AD2138035F9E3EAE /* FaviconFetcher.swift */, + 89214AFC85F9FE43143A36EC /* InputSettle.swift */, 313680424148829FCBEAA9BC /* ModelContext+Save.swift */, E1E431CF7CE145D536081A23 /* UserAgentProvider.swift */, + B8291060BF6847242E1F03C6 /* WebViewJavaScript.swift */, ); path = Utilities; sourceTree = ""; @@ -449,6 +464,9 @@ isa = PBXGroup; children = ( FBA2B8A19B41207F66E51CC3 /* ChorusTests.swift */, + 90198C05F204DF1B75DC485C /* InputSettleTests.swift */, + 8F3C6319147E80D9FCD5260E /* WebViewDepartureTests.swift */, + E589AEBC286712A2556141FC /* WebViewPoolTests.swift */, ); path = ChorusTests; sourceTree = ""; @@ -583,6 +601,7 @@ 3DEA98A98656EE981A29A71D /* FindInPageBar.swift in Sources */, 06696BB66C7BAA761E8291C9 /* HibernatedBadgePoller.swift in Sources */, A98FA6B9454EC0BCEDEED093 /* InAppBrowserWindow.swift in Sources */, + C4603B5D275EEABB2283D850 /* InputSettle.swift in Sources */, 091825995DF2FD9C4A7BC86F /* KeyboardShortcutManager.swift in Sources */, 701984CFE765170D8EAE50EE /* MenuBarView.swift in Sources */, 6C15E37D4717938D4C31384A /* ModelContext+Save.swift in Sources */, @@ -622,6 +641,8 @@ A22B72681582D92F92938917 /* WebToolbarView.swift in Sources */, 5561BE9B6C6D8AA46CD42884 /* WebViewContainer.swift in Sources */, DF712EA56B5AE10C7BF77176 /* WebViewCoordinator.swift in Sources */, + D887CCC00E8FE67581C132C4 /* WebViewDeparture.swift in Sources */, + 0294214694FE69AB69B03C55 /* WebViewJavaScript.swift in Sources */, BAA5421430BDF6F9B918B7B5 /* WebViewPool.swift in Sources */, 783EF6C467034D74DE8D5F52 /* WebViewState.swift in Sources */, 27E1865FA1BB65766DBDB808 /* WhatsNew.swift in Sources */, @@ -634,6 +655,9 @@ buildActionMask = 2147483647; files = ( C4ADA7C24E806E00FF4709D1 /* ChorusTests.swift in Sources */, + D1C6FFBDDA20E8172D0BC347 /* InputSettleTests.swift in Sources */, + BEB67E84D36E90ADBC75AEF6 /* WebViewDepartureTests.swift in Sources */, + 97D0EDAE4F2712616A66B8C2 /* WebViewPoolTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Chorus/App/AppState.swift b/Chorus/App/AppState.swift index abf6220..473a443 100644 --- a/Chorus/App/AppState.swift +++ b/Chorus/App/AppState.swift @@ -1058,56 +1058,14 @@ final class AppState { }) } - /// Gives every live page a save point before the app quits, then lets it - /// go. Called from `applicationShouldTerminate`, so every way out — the - /// menu, ⌘Q, a relaunch, a Sparkle install — passes through it. - /// - /// Pages save when they go hidden, and a quit kills them with no - /// `visibilitychange` or `pagehide` at all. Chorus pins every page to - /// visible besides, so WhatsApp Web never reached the point where it writes - /// its session, and could come back signed out. Three steps, each capped so - /// a wedged page can't hold the quit hostage, about a second in all: - /// release every page to hidden, give the pages a moment to write, then read - /// each data store's records — a round trip through the storage process that - /// lands behind the writes just queued. That last step is best effort. + /// Gives live pages bounded time before quit, relaunch and installation. + /// This grace period does not confirm a provider's server save. func releasePagesForQuit() async { let webViews = webViewPool.liveWebViews - guard !webViews.isEmpty else { return } - - // Started together, awaited in turn: all the pages get the signal at - // once, and the deadline bounds the wait for the slowest. - await withDeadline(seconds: 0.3, fallback: ()) { - let releases = webViews.map { webView in - Task { @MainActor in - _ = try? await webView.evaluateJavaScript(UserScriptManager.quitReleaseJS) - } - } - for release in releases { await release.value } - } - - try? await Task.sleep(for: Self.quitWriteWindow) - - var seen = Set() - let stores = webViews - .map { $0.configuration.websiteDataStore } - .filter { seen.insert(ObjectIdentifier($0)).inserted } - await withDeadline(seconds: 0.5, fallback: ()) { - let flushes = stores.map { store in - Task { @MainActor in - _ = await store.dataRecords(ofTypes: [ - WKWebsiteDataTypeLocalStorage, - WKWebsiteDataTypeIndexedDBDatabases, - ]) - } - } - for flush in flushes { await flush.value } - } + await WebViewDeparture.prepareForQuit(in: webViews) AppLogger.webView.info("Released \(webViews.count) pages for quit") } - /// How long pages get to write after going hidden before quit goes on. - static let quitWriteWindow: Duration = .milliseconds(250) - /// Pauses or resumes polling when network connectivity toggles. While /// offline every poll (active, background, and hibernated) would only fire /// doomed requests, draining battery for nothing; on reconnect we restart @@ -1196,10 +1154,12 @@ final class AppState { // MARK: - Active service actions (driven by keyboard shortcuts) /// Reload the currently displayed service's web view. Triggered by Cmd-R. - func reloadActiveService() { + func reloadActiveService() async { guard let id = webViewPool.activeServiceID, let webView = webViewPool.displayedWebView(for: id) else { return } - WebViewCoordinator.reload(webView, fallbackURL: fetchService(id: id).flatMap { URL(string: $0.url) }) + // Capture the target before waiting, rather than reloading whichever + // service happens to be active when the wait ends. + await WebViewCoordinator.reload(webView, fallbackURL: fetchService(id: id).flatMap { URL(string: $0.url) }) } // MARK: - Service tabs diff --git a/Chorus/App/ChorusApp.swift b/Chorus/App/ChorusApp.swift index 4e2056b..c180fcf 100644 --- a/Chorus/App/ChorusApp.swift +++ b/Chorus/App/ChorusApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import SwiftData #if canImport(Sparkle) import Sparkle @@ -105,6 +106,23 @@ struct ChorusApp: App { .keyboardShortcut("w", modifiers: .command) } + // End native hover before hiding, without delaying the command. + CommandGroup(replacing: .appVisibility) { + Button("Hide Chorus") { + WebViewDeparture.hideApplication() + } + .keyboardShortcut("h", modifiers: .command) + + Button("Hide Others") { + NSApp.hideOtherApplications(nil) + } + .keyboardShortcut("h", modifiers: [.command, .option]) + + Button("Show All") { + NSApp.unhideAllApplications(nil) + } + } + KeyboardShortcutCommands( selectedServiceID: Binding( get: { appState.selectedServiceID }, @@ -128,7 +146,7 @@ struct ChorusApp: App { CommandGroup(after: .toolbar) { Button("Reload") { - appState.reloadActiveService() + Task { await appState.reloadActiveService() } } .keyboardShortcut("r", modifiers: .command) diff --git a/Chorus/App/ChorusAppDelegate.swift b/Chorus/App/ChorusAppDelegate.swift index fb33362..9be70ed 100644 --- a/Chorus/App/ChorusAppDelegate.swift +++ b/Chorus/App/ChorusAppDelegate.swift @@ -1,6 +1,6 @@ import AppKit -/// Holds a quit back for about a second so every live page can save first. See +/// Gives live pages a bounded save period before quit. See /// `AppState.releasePagesForQuit`. @MainActor final class ChorusAppDelegate: NSObject, NSApplicationDelegate { @@ -12,8 +12,46 @@ final class ChorusAppDelegate: NSObject, NSApplicationDelegate { /// asked for while it runs, must not start it again. private var isReleasingPages = false + /// Routes Cmd-H through the same departure policy as the app menu. + private var hideKeyMonitor: Any? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Watching from launch means a click is counted even when the leave + // that follows is the first thing to ask for a settle window. + InputSettle.shared.start() + patchHideMenuItem() + Task { @MainActor [weak self] in + // SwiftUI may build the app menu after launch; look once more. + try? await Task.sleep(for: .milliseconds(500)) + self?.patchHideMenuItem() + } + hideKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + let shortcutFlags = event.modifierFlags.intersection([.command, .option, .control, .shift]) + guard shortcutFlags == .command, + event.charactersIgnoringModifiers?.lowercased() == "h" + else { return event } + MainActor.assumeIsolated { self?.hideWithDeparture(nil) } + return nil + } + } + + /// Points a standard Hide item at `hideWithDeparture` when SwiftUI left it as + /// the system `hide:` action. + private func patchHideMenuItem() { + guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return } + for item in appMenu.items where item.action == #selector(NSApplication.hide(_:)) { + item.target = self + item.action = #selector(hideWithDeparture(_:)) + } + } + + @objc private func hideWithDeparture(_ sender: Any?) { + WebViewDeparture.hideApplication() + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - guard let appState, !isReleasingPages, appState.webViewPool.loadedCount > 0 else { + if isReleasingPages { return .terminateLater } + guard let appState, !appState.webViewPool.liveWebViews.isEmpty else { return .terminateNow } isReleasingPages = true diff --git a/Chorus/Services/HibernatedBadgePoller.swift b/Chorus/Services/HibernatedBadgePoller.swift index f818232..dc8c47b 100644 --- a/Chorus/Services/HibernatedBadgePoller.swift +++ b/Chorus/Services/HibernatedBadgePoller.swift @@ -349,14 +349,14 @@ final class TransientBadgeFetcher { // with a selector never reads the title, so a title count for another // view can't inflate the badge. if let js = target.badgeJS { - if let result = try? await webView.evaluateJavaScript(js) { + if let result = try? await webView.evaluateJavaScriptValue(js) { if let intResult = result as? Int { best = max(best, intResult) } else if let stringResult = result as? String, let parsed = Int(stringResult) { best = max(best, parsed) } } - } else if let title = (try? await webView.evaluateJavaScript("document.title")) as? String { + } else if let title = (try? await webView.evaluateJavaScriptValue("document.title")) as? String { best = max(best, NotificationManager.extractBadgeCount(from: title)) } diff --git a/Chorus/Services/NotificationManager.swift b/Chorus/Services/NotificationManager.swift index 90ad2da..2fd8869 100644 --- a/Chorus/Services/NotificationManager.swift +++ b/Chorus/Services/NotificationManager.swift @@ -205,7 +205,7 @@ final class NotificationManager { /// the default (true): reading your inbox empty authoritatively clears it. private func pollTitle(webView: WKWebView, instanceID: UUID, isMuted: Bool, showBadge: Bool, resetToZero: Bool = true) async { do { - let result = try await webView.evaluateJavaScript("document.title") + let result = try await webView.evaluateJavaScriptValue("document.title") // The JS await is a suspension point: the poll task may have been // cancelled (service switched away / hibernated) while it ran. Drop // the result so a stale tick can't write a badge after cancellation. @@ -223,7 +223,7 @@ final class NotificationManager { private func pollBadge(webView: WKWebView, instanceID: UUID, isMuted: Bool, showBadge: Bool, catalogEntry: ServiceCatalogEntry, resetToZero: Bool = true) async { guard let badgeJS = catalogEntry.badgeJS else { return } do { - let result = try await webView.evaluateJavaScript(badgeJS) + let result = try await webView.evaluateJavaScriptValue(badgeJS) // Drop the result if the poll task was cancelled during the JS await, // so a stale tick can't write a badge after cancellation. guard !Task.isCancelled else { return } diff --git a/Chorus/Utilities/InputSettle.swift b/Chorus/Utilities/InputSettle.swift new file mode 100644 index 0000000..795d9ab --- /dev/null +++ b/Chorus/Utilities/InputSettle.swift @@ -0,0 +1,95 @@ +import AppKit +import WebKit + +/// Gives recent input on a particular page a bounded chance to finish. +/// App chrome and other pages never refresh that page's grace period. +@MainActor +final class InputSettle { + static let shared = InputSettle() + static let settleWindow: TimeInterval = 2.2 + + private struct Entry { + weak var page: WKWebView? + var timestamp: TimeInterval + } + + private var entries: [ObjectIdentifier: Entry] = [:] + private var monitor: Any? + private let now: () -> TimeInterval + private let sleep: (TimeInterval) async throws -> Void + private let window: TimeInterval + + init( + window: TimeInterval = 2.2, + now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + sleep: @escaping (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) } + ) { + self.window = window + self.now = now + self.sleep = sleep + } + + func start() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .keyDown, .keyUp] + ) { [weak self] event in + MainActor.assumeIsolated { self?.observe(event) } + return event + } + } + + /// Used by the native event monitor; hit-testing attributes mouse input + /// to the page, while keyboard input follows the actual first responder. + func observe(_ event: NSEvent) { + guard let window = event.window else { return } + let target: NSView? + if event.type == .keyDown || event.type == .keyUp { + let flags = event.modifierFlags.intersection([.command, .control, .option, .shift]) + if flags == .command, + ["h", "q", "r", "[", "]"].contains(event.charactersIgnoringModifiers?.lowercased() ?? "") { + return + } + target = window.firstResponder as? NSView + } else { + guard let content = window.contentView else { return } + target = content.hitTest(content.convert(event.locationInWindow, from: nil)) + } + var view = target + while let current = view { + if let page = current as? WKWebView { + recordInput(in: page) + return + } + view = current.superview + } + } + + func recordInput(in page: WKWebView) { + entries = entries.filter { $0.value.page != nil } + entries[ObjectIdentifier(page)] = Entry(page: page, timestamp: now()) + } + + /// An exit can itself start page work; remember it across hide/switch/quit. + func recordDeparture(in page: WKWebView) { + recordInput(in: page) + } + + func remainingWait(for page: WKWebView) -> TimeInterval { + guard let entry = entries[ObjectIdentifier(page)], entry.page === page else { return 0 } + return max(0, window - (now() - entry.timestamp)) + } + + func waitForSettle(in pages: [WKWebView]) async { + let deadline = now() + window + while !Task.isCancelled { + let remaining = min(pages.map { remainingWait(for: $0) }.max() ?? 0, deadline - now()) + guard remaining > 0 else { return } + do { try await sleep(remaining) } catch { return } + } + } + + nonisolated static func remainingWait(since lastInput: Date, now: Date, window: TimeInterval) -> TimeInterval { + max(0, window - now.timeIntervalSince(lastInput)) + } +} diff --git a/Chorus/Utilities/WebViewJavaScript.swift b/Chorus/Utilities/WebViewJavaScript.swift new file mode 100644 index 0000000..465ebea --- /dev/null +++ b/Chorus/Utilities/WebViewJavaScript.swift @@ -0,0 +1,30 @@ +import WebKit + +extension WKWebView { + /// Xcode 16's generated async bridge force-unwraps a successful nil result. + /// Use the callback interface so JavaScript `undefined` remains a valid result. + @MainActor + @discardableResult + func evaluateJavaScriptValue(_ script: String) async throws -> Any? { + let result = JavaScriptEvaluationResult() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + evaluateJavaScript(script) { value, error in + // WebKit delivers JavaScript completion on the main thread. + MainActor.assumeIsolated { + if let error { + continuation.resume(throwing: error) + } else { + result.value = value + continuation.resume() + } + } + } + } + return result.value + } +} + +@MainActor +private final class JavaScriptEvaluationResult { + var value: Any? +} diff --git a/Chorus/Views/MainWindow/WebContentView.swift b/Chorus/Views/MainWindow/WebContentView.swift index 0d31b25..7a90740 100644 --- a/Chorus/Views/MainWindow/WebContentView.swift +++ b/Chorus/Views/MainWindow/WebContentView.swift @@ -111,72 +111,22 @@ struct WebContentView: View { } } - /// What the card holds: the live page with its snapshot and find bar, the - /// placeholder while a web view is made, or the empty state. - @ViewBuilder private var cardContent: some View { - if let service = selectedService, let bundleID = service.nativeAppBundleID { - NativeAppPanel(label: service.label, bundleID: bundleID) - } else if let service = selectedService, let webView = displayedWebView { - VStack(spacing: 0) { - if let tabs = selectedTabs, !tabs.isEmpty { - ServiceTabStrip( - serviceLabel: service.label, - tabs: tabs, - onClose: { id in - appState.webViewPool.closeTab(id, for: service.id) - } - ) - } - pageContent(webView) - } - } else if selectedService != nil { - ProgressView("Loading service…") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - emptyState - } - } - - /// The live page with its load snapshot and find bar. - private func pageContent(_ webView: WKWebView) -> some View { - ZStack(alignment: .topTrailing) { - WebViewContainer(webView: webView) - - // Show cached snapshot as instant visual feedback while page loads. - // Fades out once the web view finishes loading. It fills the - // web view's frame (rather than aspect-fill, which cropped or - // stretched it); since the snapshot was taken at this frame it - // lines up without distortion. - if let snapshot = transitionSnapshot, webViewState.isLoading { - Image(nsImage: snapshot) - .resizable() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipped() - .transition(.opacity) - // The snapshot is a picture, never a shield. Without - // this it sits over the live web view and eats every - // click for as long as a navigation runs — a page - // that looks exactly like the one underneath but - // answers nothing (reported on TD EasyWeb: click - // Login and the app appears to freeze). - .allowsHitTesting(false) - .accessibilityHidden(true) + WebContentCard( + service: selectedService, + webView: displayedWebView, + tabs: selectedTabs, + transitionSnapshot: transitionSnapshot, + isLoading: webViewState.isLoading, + findInPageVisible: Binding( + get: { appState.findInPageVisible }, + set: { appState.findInPageVisible = $0 } + ), + onCloseTab: { id in + guard let serviceID = selectedServiceID else { return } + appState.webViewPool.closeTab(id, for: serviceID) } - - if appState.findInPageVisible { - FindInPageBar( - isVisible: Binding( - get: { appState.findInPageVisible }, - set: { appState.findInPageVisible = $0 } - ), - webView: webView - ) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: webViewState.isLoading) - .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: appState.findInPageVisible) + ) { emptyState } } /// Points the nav buttons at whichever page is now on screen, after a tab @@ -207,7 +157,7 @@ struct WebContentView: View { private static func nudgeLayout(of webView: WKWebView) { Task { @MainActor in try? await Task.sleep(for: .milliseconds(250)) - _ = try? await webView.evaluateJavaScript("window.dispatchEvent(new Event('resize'))") + _ = try? await webView.evaluateJavaScriptValue("window.dispatchEvent(new Event('resize'))") } } @@ -397,6 +347,95 @@ struct WebContentView: View { } } +/// Renders the selected service without owning selection, polling, or storage. +/// Keeping this separate lets the real SwiftUI service transition run in tests. +struct WebContentCard: View { + let service: ServiceInstance? + /// The page on screen for this service: the selected tab, or its own page. + let webView: WKWebView? + /// The service's open tabs, shown above the page when it has any. + let tabs: ServiceTabs? + let transitionSnapshot: NSImage? + let isLoading: Bool + @Binding var findInPageVisible: Bool + let onCloseTab: (UUID) -> Void + @ViewBuilder var emptyContent: () -> EmptyContent + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The page the host should hold. A Mac-app service and an empty selection + /// both have none, and the host stays mounted either way so the outgoing + /// page can still receive its native exit. + private var pageWebView: WKWebView? { + guard let service, service.nativeAppBundleID == nil else { return nil } + return webView + } + + var body: some View { + VStack(spacing: 0) { + if let service, pageWebView != nil, let tabs, !tabs.isEmpty { + ServiceTabStrip( + serviceLabel: service.label, + tabs: tabs, + onClose: onCloseTab + ) + } + ZStack(alignment: .topTrailing) { + // Keep one host mounted through web, native and empty selections. + WebViewContainer(webView: pageWebView) + + if let service, let bundleID = service.nativeAppBundleID { + NativeAppPanel(label: service.label, bundleID: bundleID) + .background(ChorusColor.card) + } else if let webView = pageWebView { + pageChrome(webView) + } else if service != nil { + ProgressView("Loading service…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ChorusColor.card) + } else { + emptyContent() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ChorusColor.card) + } + } + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: isLoading) + .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: findInPageVisible) + } + + /// The load snapshot and the find bar over the live page. The page itself is + /// the card's base layer, so it stays mounted while this comes and goes. + private func pageChrome(_ webView: WKWebView) -> some View { + ZStack(alignment: .topTrailing) { + // Show cached snapshot as instant visual feedback while page loads. + // Fades out once the web view finishes loading. It fills the + // web view's frame (rather than aspect-fill, which cropped or + // stretched it); since the snapshot was taken at this frame it + // lines up without distortion. + if let snapshot = transitionSnapshot, isLoading { + Image(nsImage: snapshot) + .resizable() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .transition(.opacity) + // The snapshot is a picture, never a shield. Without + // this it sits over the live web view and eats every + // click for as long as a navigation runs — a page + // that looks exactly like the one underneath but + // answers nothing (reported on TD EasyWeb: click + // Login and the app appears to freeze). + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + if findInPageVisible { + FindInPageBar(isVisible: $findInPageVisible, webView: webView) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } +} + /// What the card shows for a Mac-app service: the app runs in its own window, /// so this offers to bring it forward. private struct NativeAppPanel: View { diff --git a/Chorus/Views/MainWindow/WebToolbarView.swift b/Chorus/Views/MainWindow/WebToolbarView.swift index 37cf676..1b9f901 100644 --- a/Chorus/Views/MainWindow/WebToolbarView.swift +++ b/Chorus/Views/MainWindow/WebToolbarView.swift @@ -30,7 +30,7 @@ struct WebNavButtons: View { if webViewState.isLoading { webViewState.webView?.stopLoading() } else if let webView = webViewState.webView { - WebViewCoordinator.reload(webView, fallbackURL: homeURL) + Task { await WebViewCoordinator.reload(webView, fallbackURL: homeURL) } } } label: { Image(systemName: webViewState.isLoading ? "xmark" : "arrow.clockwise") diff --git a/Chorus/Views/WebView/WebViewContainer.swift b/Chorus/Views/WebView/WebViewContainer.swift index 2fe4c0f..55b10fb 100644 --- a/Chorus/Views/WebView/WebViewContainer.swift +++ b/Chorus/Views/WebView/WebViewContainer.swift @@ -2,7 +2,7 @@ import SwiftUI import WebKit struct WebViewContainer: NSViewRepresentable { - let webView: WKWebView + let webView: WKWebView? func makeNSView(context: Context) -> WebViewHostView { let host = WebViewHostView() @@ -19,6 +19,7 @@ struct WebViewContainer: NSViewRepresentable { /// rounded corners. final class WebViewHostView: NSView { private weak var currentWebView: WKWebView? + private var pendingDetachments: [ObjectIdentifier: Task] = [:] /// The size the page area last had, for the pool to make new web views at. /// A web view made at zero size loads its page against a 0 by 0 window, and @@ -47,19 +48,68 @@ final class WebViewHostView: NSView { fatalError("init(coder:) has not been implemented") } - func setWebView(_ webView: WKWebView) { - guard webView !== currentWebView else { return } + override func hitTest(_ point: NSPoint) -> NSView? { + guard let currentWebView, let hit = super.hitTest(point), + hit === currentWebView || hit.isDescendant(of: currentWebView) + else { return nil } + return hit + } + + func setWebView(_ webView: WKWebView?) { + // WebKit temporarily owns a page during element fullscreen. Only + // transfer between Chorus hosts or restore an unattached page. + if let owner = webView?.superview, owner !== self, !(owner is WebViewHostView) { + return + } + guard webView !== currentWebView || (webView != nil && webView?.superview !== self) else { return } - currentWebView?.removeFromSuperview() + // A quick return (even through a different host) cancels the old + // removal. Its pending task must not detach the now-active view. + if let webView, let previousHost = webView.superview as? WebViewHostView { + previousHost.cancelDetachment(of: webView) + } + let outgoing = currentWebView + let focused = window?.firstResponder as? NSView + let transfersFocus = outgoing.map { focused === $0 || focused?.isDescendant(of: $0) == true } ?? false currentWebView = webView - webView.translatesAutoresizingMaskIntoConstraints = false - addSubview(webView) - NSLayoutConstraint.activate([ - webView.topAnchor.constraint(equalTo: topAnchor), - webView.bottomAnchor.constraint(equalTo: bottomAnchor), - webView.leadingAnchor.constraint(equalTo: leadingAnchor), - webView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) + if let webView { + let alreadyAttached = webView.superview === self + webView.translatesAutoresizingMaskIntoConstraints = false + webView.setAccessibilityHidden(false) + addSubview(webView) + if !alreadyAttached { + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: topAnchor), + webView.bottomAnchor.constraint(equalTo: bottomAnchor), + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + } + + // Retaining the old view must not leave keyboard input routed to it. + // Do not steal focus when the user is navigating the rail instead. + // A native panel or empty selection has no incoming page to focus. + if transfersFocus { window?.makeFirstResponder(webView) } + + if let outgoing, outgoing !== webView { + outgoing.setAccessibilityHidden(true) + let id = ObjectIdentifier(outgoing) + // Show the new page or native panel at once. Keep the old page + // attached underneath until its native exit and page work can run. + pendingDetachments[id] = Task { @MainActor [weak self] in + await WebViewDeparture.prepareForDestruction(in: [outgoing]) + guard !Task.isCancelled, let self else { return } + defer { self.pendingDetachments.removeValue(forKey: id) } + guard outgoing !== self.currentWebView, + outgoing.superview === self else { return } + outgoing.removeFromSuperview() + } + } + } + + private func cancelDetachment(of webView: WKWebView) { + pendingDetachments.removeValue(forKey: ObjectIdentifier(webView))?.cancel() } } diff --git a/Chorus/Views/WebView/WebViewCoordinator.swift b/Chorus/Views/WebView/WebViewCoordinator.swift index 41764e3..4361209 100644 --- a/Chorus/Views/WebView/WebViewCoordinator.swift +++ b/Chorus/Views/WebView/WebViewCoordinator.swift @@ -52,6 +52,10 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK /// (`instanceID`), so AppState can honour that service's "open links in /// Chorus" choice. When nil the coordinator falls back to `NSWorkspace.open` /// directly. + var auxiliaryWebViews: [WKWebView] { + popups.map(\.webView) + closingTabs.map(\.webView) + } + var externalLinkHandler: ((URL, UUID?) -> Void)? /// Whether some Chorus service owns a URL, so a page's `window.open` to it @@ -449,7 +453,30 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK /// Reloads a web view, or loads `fallbackURL` when there is nothing to /// reload. A first load stopped before it committed leaves no page, and /// `reload()` on that does nothing, so Reload looked broken. - static func reload(_ webView: WKWebView, fallbackURL: URL?) { + @MainActor + private final class PendingReload { + var navigationStarted = false + var observation: NSKeyValueObservation? + } + private static var pendingReloads: [ObjectIdentifier: PendingReload] = [:] + + static func reload(_ webView: WKWebView, fallbackURL: URL?) async { + let id = ObjectIdentifier(webView) + guard pendingReloads[id] == nil else { return } + let request = PendingReload() + pendingReloads[id] = request + // Loading a different document supersedes this request. A hash/history + // change does not: comparing URLs used to silently drop those reloads. + request.observation = webView.observe(\.isLoading, options: [.new]) { _, change in + guard change.newValue == true else { return } + MainActor.assumeIsolated { request.navigationStarted = true } + } + defer { + request.observation?.invalidate() + pendingReloads.removeValue(forKey: id) + } + await WebViewDeparture.prepareForDestruction(in: [webView]) + guard !Task.isCancelled, !request.navigationStarted else { return } if webView.reload() == nil, let fallbackURL { webView.load(URLRequest(url: fallbackURL)) } @@ -863,7 +890,7 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK tab.webView.removeFromSuperview() closingTabs = closingTabs + [tab] Task { @MainActor [weak self] in - _ = try? await tab.webView.evaluateJavaScript(UserScriptManager.quitReleaseJS) + _ = try? await tab.webView.evaluateJavaScriptValue(UserScriptManager.quitReleaseJS) try? await Task.sleep(for: Self.tabSaveWindow) Self.tearDown(tab.webView) self?.closingTabs.removeAll { $0 === tab } @@ -1019,16 +1046,19 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK /// the no-subject prompt), so a signed-in user clicking Send just saw nothing /// happen. Implementing all three restores the expected behaviour. /// - /// CRITICAL: `completionHandler` MUST be `@escaping @MainActor`. The WebKit - /// header annotates the block `WK_SWIFT_UI_ACTOR` (= `@MainActor`); drop it - /// and Swift silently declines to treat this as the protocol witness — the - /// method never reaches the Obj-C runtime, WebKit never calls it, and the - /// page hangs. Same trap as `runOpenPanelWith` above. + /// Match the SDK's optional witness exactly: newer SDKs annotate the + /// alert completion with MainActor; Xcode 16 imports it as Sendable. + #if compiler(>=6.4) + typealias JavaScriptAlertCompletion = @MainActor () -> Void + #else + typealias JavaScriptAlertCompletion = @Sendable () -> Void + #endif + func webView( _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping @MainActor () -> Void + completionHandler: @escaping JavaScriptAlertCompletion ) { AppLogger.webView.info("JS alert panel") let alert = NSAlert() diff --git a/Chorus/Views/WebView/WebViewDeparture.swift b/Chorus/Views/WebView/WebViewDeparture.swift new file mode 100644 index 0000000..daac891 --- /dev/null +++ b/Chorus/Views/WebView/WebViewDeparture.swift @@ -0,0 +1,160 @@ +import AppKit +import WebKit + +/// Ends the page's hover state before its view disappears. This must go through +/// AppKit: dispatching a DOM mouseout does not clear WebKit's hit-test state. +@MainActor +enum WebViewDeparture { + /// Bounded time for work started by a native hover exit. Neither this + /// grace period nor the input wait confirms a provider's server save. + private static let exitWorkGrace: TimeInterval = 0.5 + private static var hideTask: Task? + private static var hideGeneration = UUID() + private static var unhideObserver: NSObjectProtocol? + + private static func cancelPendingHide() { + hideGeneration = UUID() + hideTask?.cancel() + hideTask = nil + } + + /// All hide entry points share this policy, including popups. Hiding + /// itself is never delayed: the app hides first, then a background task + /// gives recent input a bounded grace period before sending a native exit. + /// Returning cancels the pending exit. + static func hideApplication() { + var webViews: [WKWebView] = [] + @MainActor func visit(_ view: NSView) { + if let webView = view as? WKWebView { + webViews.append(webView) + } else { + for subview in view.subviews { visit(subview) } + } + } + for window in NSApp.windows where window.isVisible { + if let content = window.contentView { visit(content) } + } + if unhideObserver == nil { + unhideObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willUnhideNotification, object: nil, queue: nil + ) { _ in + MainActor.assumeIsolated { cancelPendingHide() } + } + } + cancelPendingHide() + let generation = hideGeneration + NSApp.hide(nil) + hideTask = Task { @MainActor in + let activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiatedAllowingIdleSystemSleep], + reason: "Let recently started page work finish after Hide" + ) + defer { ProcessInfo.processInfo.endActivity(activity) } + await InputSettle.shared.waitForSettle(in: webViews) + guard !Task.isCancelled, generation == hideGeneration, NSApp.isHidden else { return } + hideTask = nil + for webView in webViews + where webView.window != nil && !webView.isHiddenOrHasHiddenAncestor + { + endHover(in: webView) + } + } + } + + /// Let hover work run while visible, then release the pinned visibility + /// override so pages such as WhatsApp can save their session. The release + /// and storage round trips are bounded; neither confirms a server write. + static func prepareForQuit(in webViews: [WKWebView]) async { + guard !webViews.isEmpty else { return } + await prepareForDestruction(in: webViews) + guard !Task.isCancelled else { return } + await withDeadline(seconds: 0.3, fallback: ()) { + let releases = webViews.map { webView in + Task { @MainActor in + _ = try? await webView.evaluateJavaScriptValue(UserScriptManager.quitReleaseJS) + } + } + for release in releases { await release.value } + } + try? await Task.sleep(for: .milliseconds(250)) + var seen = Set() + let stores = webViews.map { $0.configuration.websiteDataStore } + .filter { seen.insert(ObjectIdentifier($0)).inserted } + await withDeadline(seconds: 0.5, fallback: ()) { + let flushes = stores.map { store in + Task { @MainActor in + _ = await store.dataRecords(ofTypes: [ + WKWebsiteDataTypeLocalStorage, + WKWebsiteDataTypeIndexedDBDatabases, + ]) + } + } + for flush in flushes { await flush.value } + } + } + + /// Reload, quit, and switch-detach destroy or shelve page state. Waits out + /// recently started work FIRST, then clears hover, then gives + /// exit-triggered saves a short bounded window. Sending the exit before + /// the wait risks cancelling a click whose request the page has not + /// dispatched yet; the trailing window preserves mouseout-driven saves. + /// Limits App Nap during the grace period; this does not force site dispatch. + /// Switching shows the new page while this settles the outgoing view + /// before it is detached. + static func prepareForDestruction(in webViews: [WKWebView]) async { + guard !Task.isCancelled else { return } + // Limit App Nap while allowing idle system sleep. + let activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiatedAllowingIdleSystemSleep], + reason: "Let recently started page work finish before destruction" + ) + defer { ProcessInfo.processInfo.endActivity(activity) } + await InputSettle.shared.waitForSettle(in: webViews) + guard !Task.isCancelled else { return } + let attached = webViews.filter { $0.window != nil && !$0.isHiddenOrHasHiddenAncestor } + let hadHover = await withDeadline(seconds: 0.2, fallback: true) { + for webView in attached { + // :root makes this work in quirks mode too, where a bare + // :hover selector can report false despite a hovered element. + let result = try? await webView.evaluateJavaScriptValue("document.documentElement.matches(':root:hover')") + if result as? Bool != false { return true } + } + return false + } + guard !Task.isCancelled else { return } + guard hadHover else { return } + for webView in attached { endHover(in: webView) } + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .seconds(exitWorkGrace)) + } + + static func endHover(in webView: WKWebView) { + guard let window = webView.window, + !webView.isHiddenOrHasHiddenAncestor, + let event = NSEvent.enterExitEvent( + with: .mouseExited, + location: webView.convert(NSPoint(x: webView.bounds.minX - 1, y: webView.bounds.minY - 1), to: nil), + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, + eventNumber: 0, trackingNumber: 0, userData: nil + ) else { + return + } + + // Modern WKWebView delegates tracking to an owner rather than handling + // mouseExited itself. Use the public tracking interface, not a private + // WebKit selector, and avoid sending twice to an owner with two areas. + var notified = Set() + @MainActor func sendExit(in view: NSView) { + for area in view.trackingAreas where area.options.contains(.mouseMoved) { + guard let owner = area.owner as? NSObject, + owner.responds(to: #selector(NSResponder.mouseExited(with:))), + notified.insert(ObjectIdentifier(owner)).inserted else { continue } + owner.perform(#selector(NSResponder.mouseExited(with:)), with: event) + } + for subview in view.subviews { sendExit(in: subview) } + } + sendExit(in: webView) + if !notified.isEmpty { InputSettle.shared.recordDeparture(in: webView) } + } +} diff --git a/Chorus/Views/WebView/WebViewPool.swift b/Chorus/Views/WebView/WebViewPool.swift index fc122e4..21b79b0 100644 --- a/Chorus/Views/WebView/WebViewPool.swift +++ b/Chorus/Views/WebView/WebViewPool.swift @@ -267,23 +267,7 @@ final class WebViewPool { return existing } - let config = makeConfiguration(for: instance) - let webView = WKWebView(frame: CGRect(origin: .zero, size: WebViewHostView.lastSize), configuration: config) - webView.allowsBackForwardNavigationGestures = true - webView.customUserAgent = instance.userAgent ?? UserAgentProvider.safariDefault - #if DEBUG - // Lets Safari's Develop menu attach to the page, to watch a sign-in. - webView.isInspectable = true - #endif - - let coordinator = makeCoordinator(for: instance) - webView.navigationDelegate = coordinator - webView.uiDelegate = coordinator - coordinators[instance.id] = coordinator - - webViews[instance.id] = webView - lastAccessTimes[instance.id] = Date() - observeCaptureState(webView, id: instance.id) + let webView = createWebView(for: instance) // Restore the last-visited URL when waking from full hibernation // so the user lands back where they left off, not at the home URL. @@ -315,23 +299,7 @@ final class WebViewPool { // A Mac-app service has no page to load. guard instance.nativeAppBundleID == nil else { return } - let config = makeConfiguration(for: instance) - let webView = WKWebView(frame: CGRect(origin: .zero, size: WebViewHostView.lastSize), configuration: config) - webView.allowsBackForwardNavigationGestures = true - webView.customUserAgent = instance.userAgent ?? UserAgentProvider.safariDefault - #if DEBUG - // Lets Safari's Develop menu attach to the page, to watch a sign-in. - webView.isInspectable = true - #endif - - let coordinator = makeCoordinator(for: instance) - webView.navigationDelegate = coordinator - webView.uiDelegate = coordinator - coordinators[instance.id] = coordinator - - webViews[instance.id] = webView - lastAccessTimes[instance.id] = Date() - observeCaptureState(webView, id: instance.id) + let webView = createWebView(for: instance) // Register the hibernation-exemption flags now, not just on first // activation. A service preloaded but never clicked would otherwise be @@ -444,6 +412,7 @@ final class WebViewPool { /// Every live service web view and open tab, for the quit handoff. var liveWebViews: [WKWebView] { webViews.keys.flatMap { allWebViews(for: $0) } + + coordinators.values.flatMap { $0.auxiliaryWebViews } } /// Runs the call-detection JS for a service on the main actor, returning @@ -452,7 +421,7 @@ final class WebViewPool { /// tasks carry only Sendable values. private func probeCallDetection(_ instanceID: UUID) async -> Bool { guard let webView = webViews[instanceID] else { return false } - let result = try? await webView.evaluateJavaScript(UserScriptManager.callDetectionQueryJS) + let result = try? await webView.evaluateJavaScriptValue(UserScriptManager.callDetectionQueryJS) return (result as? Bool) == true } @@ -803,6 +772,28 @@ final class WebViewPool { onServiceTornDown?(instanceID) } + /// Creates and registers a page identically for foreground and preload. + /// Callers decide activation, the URL to load, and preload callbacks. + private func createWebView(for instance: ServiceInstance) -> WKWebView { + let config = makeConfiguration(for: instance) + let webView = WKWebView(frame: CGRect(origin: .zero, size: WebViewHostView.lastSize), configuration: config) + webView.allowsBackForwardNavigationGestures = true + webView.customUserAgent = instance.userAgent ?? UserAgentProvider.safariDefault + #if DEBUG + // Lets Safari's Develop menu attach to the page, to watch a sign-in. + webView.isInspectable = true + #endif + + let coordinator = makeCoordinator(for: instance) + webView.navigationDelegate = coordinator + webView.uiDelegate = coordinator + coordinators[instance.id] = coordinator + webViews[instance.id] = webView + lastAccessTimes[instance.id] = Date() + observeCaptureState(webView, id: instance.id) + return webView + } + /// Builds a navigation/UI coordinator wired to this service. Shared by /// `webView(for:)` and `preload(_:)` so the instance id, fallback URL, /// external-link routing, and navigation-finished callback stay in sync. diff --git a/ChorusTests/ChorusTests.swift b/ChorusTests/ChorusTests.swift index 35c2ab0..3354562 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -83,6 +83,27 @@ final class ChorusTests: XCTestCase { XCTAssertNotNil(service.dataStoreIdentifier) } + /// A transition that ends a page waits out the rest of the settle window + /// since the last mouse or key event, and no longer. See `InputSettle`. + func testSettleWaitCountsDownFromTheLastInput() { + let now = Date(timeIntervalSince1970: 1_000) + XCTAssertEqual( + InputSettle.remainingWait(since: now.addingTimeInterval(-0.1), now: now, window: 0.6), + 0.5, + accuracy: 0.0001 + ) + XCTAssertEqual( + InputSettle.remainingWait(since: now.addingTimeInterval(-0.6), now: now, window: 0.6), + 0, + accuracy: 0.0001 + ) + XCTAssertEqual( + InputSettle.remainingWait(since: now.addingTimeInterval(-30), now: now, window: 0.6), + 0, + accuracy: 0.0001 + ) + } + func testSpaceCreation() { let space = Space(name: "Work", emoji: "🏢", sortOrder: 0) XCTAssertEqual(space.name, "Work") diff --git a/ChorusTests/InputSettleTests.swift b/ChorusTests/InputSettleTests.swift new file mode 100644 index 0000000..97855ce --- /dev/null +++ b/ChorusTests/InputSettleTests.swift @@ -0,0 +1,125 @@ +import AppKit +import WebKit +import XCTest +@testable import Chorus + +@MainActor +final class InputSettleTests: XCTestCase { + func testPageInputAndExitDoNotDelayOtherPages() { + var time: TimeInterval = 10 + let settle = InputSettle(window: 2, now: { time }) + let first = page(), second = page() + settle.recordInput(in: first) + XCTAssertEqual(settle.remainingWait(for: first), 2) + XCTAssertEqual(settle.remainingWait(for: second), 0) + time = 11.5 + XCTAssertEqual(settle.remainingWait(for: first), 0.5) + settle.recordDeparture(in: first) + XCTAssertEqual(settle.remainingWait(for: first), 2) + time = 14 + XCTAssertEqual(settle.remainingWait(for: first), 0) + } + + func testWaitIsBoundedEvenWhenFreshInputArrivesDuringSleep() async { + var time: TimeInterval = 10 + let view = page() + var settle: InputSettle! + settle = InputSettle(window: 2, now: { time }, sleep: { duration in + time += duration + settle.recordInput(in: view) + }) + settle.recordInput(in: view) + await settle.waitForSettle(in: [view]) + XCTAssertEqual(time, 12, "New input cannot extend one departure forever") + } + + func testNativeMouseObservationIgnoresChromeAndAttributesPageClicks() throws { + let view = page() + let content = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + view.frame = NSRect(x: 0, y: 0, width: 200, height: 300) + content.addSubview(view) + let window = NSWindow(contentRect: content.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = content + defer { window.close() } + let settle = InputSettle(window: 2, now: { 10 }) + func click(_ x: CGFloat) throws -> NSEvent { + try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseDown, location: NSPoint(x: x, y: 100), + modifierFlags: [], timestamp: 10, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)) + } + settle.observe(try click(300)) + XCTAssertEqual(settle.remainingWait(for: view), 0) + settle.observe(try click(100)) + XCTAssertEqual(settle.remainingWait(for: view), 2) + } + + func testEventQueueDeliveryReachesTheInstalledMonitor() throws { + let view = page() + view.frame = NSRect(x: 0, y: 0, width: 300, height: 300) + let window = NSWindow(contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { window.close() } + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseDown, location: NSPoint(x: 100, y: 100), + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)) + InputSettle.shared.start() + NSApp.postEvent(event, atStart: true) + let delivered = try XCTUnwrap(NSApp.nextEvent(matching: .leftMouseDown, + until: Date().addingTimeInterval(1), inMode: .default, dequeue: true)) + NSApp.sendEvent(delivered) + XCTAssertGreaterThan(InputSettle.shared.remainingWait(for: view), 0, + "Deliver native input through the monitor, not a tracking-owner shortcut") + } + + func testMouseReleaseAfterLongHoldRenewsGraceThroughTheMonitor() async throws { + let view = page() + view.frame = NSRect(x: 0, y: 0, width: 300, height: 300) + let window = NSWindow(contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { window.close() } + InputSettle.shared.start() + InputSettle.shared.recordInput(in: view) + try await Task.sleep(for: .seconds(InputSettle.settleWindow + 0.1)) + XCTAssertEqual(InputSettle.shared.remainingWait(for: view), 0) + + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseUp, location: NSPoint(x: 100, y: 100), + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 0)) + NSApp.postEvent(event, atStart: true) + let delivered = try XCTUnwrap(NSApp.nextEvent(matching: .leftMouseUp, + until: Date().addingTimeInterval(1), inMode: .default, dequeue: true)) + NSApp.sendEvent(delivered) + XCTAssertGreaterThan(InputSettle.shared.remainingWait(for: view), 0, + "A held click starts page work on release, after mouse-down's grace expires") + } + + func testKeyboardDepartureShortcutDoesNotCountButCommandSaveDoes() throws { + let view = page() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 300), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { window.close() } + XCTAssertTrue(window.makeFirstResponder(view)) + let settle = InputSettle(window: 2, now: { 10 }) + func key(_ character: String) throws -> NSEvent { + try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, + modifierFlags: [.command], timestamp: 10, windowNumber: window.windowNumber, + context: nil, characters: character, charactersIgnoringModifiers: character, + isARepeat: false, keyCode: 0)) + } + settle.observe(try key("r")) + XCTAssertEqual(settle.remainingWait(for: view), 0) + settle.observe(try key("s")) + XCTAssertEqual(settle.remainingWait(for: view), 2) + } + + private func page() -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + return WKWebView(frame: .zero, configuration: configuration) + } +} diff --git a/ChorusTests/WebViewDepartureTests.swift b/ChorusTests/WebViewDepartureTests.swift new file mode 100644 index 0000000..c20d3b7 --- /dev/null +++ b/ChorusTests/WebViewDepartureTests.swift @@ -0,0 +1,511 @@ +import AppKit +import SwiftUI +import WebKit +import XCTest +@testable import Chorus + +@MainActor +final class WebViewDepartureTests: XCTestCase { + func testBrowserUIDelegateImplementsNativeJavaScriptDialogSelectors() { + let delegate = WebViewCoordinator() + let selectors = [ + #selector(WKUIDelegate.webView(_:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:)), + #selector(WKUIDelegate.webView(_:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:)), + #selector(WKUIDelegate.webView(_:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:)) + ] + for selector in selectors { + XCTAssertTrue(delegate.responds(to: selector), "WebKit must see \(NSStringFromSelector(selector)) on every SDK") + } + } + + + func testJavaScriptEvaluationAcceptsUndefinedAndPreservesValuesAndErrors() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let undefined = try await webView.evaluateJavaScriptValue("window.optionalResultMarker = 42; void 0") + XCTAssertNil(undefined) + let marker = try await webView.evaluateJavaScriptValue("window.optionalResultMarker") as? Int + XCTAssertEqual(marker, 42) + do { + _ = try await webView.evaluateJavaScriptValue("throw new Error('fixture error')") + XCTFail("JavaScript exceptions must reach the caller") + } catch { + XCTAssertEqual((error as NSError).domain, WKError.errorDomain) + XCTAssertEqual((error as NSError).code, WKError.Code.javaScriptExceptionOccurred.rawValue) + } + } + + func testTabStripReservesPageHeightAndClosingLastTabRestoresIt() async throws { + let (window, webView, hosting, service) = try await selectedCardFixture() + defer { window.close() } + let tabs = ServiceTabs() + let tab = ServiceTab(webView: webView) + tabs.add(tab) + hosting.rootView = WebContentCard( + service: service, webView: webView, tabs: tabs, + transitionSnapshot: nil, isLoading: false, + findInPageVisible: .constant(false), onCloseTab: { _ in } + ) { Color.clear } + hosting.layoutSubtreeIfNeeded() + + XCTAssertLessThan(webView.frame.height, hosting.bounds.height - 20, + "The tab strip must reserve height, not cover the page") + let originalHost = webView.superview + tabs.remove(tab.id) + // SwiftUI invalidates Observation-backed layout on a later AppKit turn + // on macOS 15. Wait for that update without relaxing the geometry check. + for _ in 0..<100 { + hosting.layoutSubtreeIfNeeded() + if abs(webView.frame.height - hosting.bounds.height) <= 1 { break } + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(webView.frame.height, hosting.bounds.height, accuracy: 1) + XCTAssertTrue(webView.superview === originalHost, "Keep the page host mounted as tabs change") + } + + func testUpdatingCurrentPageDoesNotReclaimItFromFullscreenHost() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let host = try XCTUnwrap(window.contentView as? WebViewHostView) + let fullscreenHost = NSView(frame: host.bounds) + webView.removeFromSuperview() + fullscreenHost.addSubview(webView) + + host.setWebView(webView) + + XCTAssertTrue(webView.superview === fullscreenHost, + "WebKit owns the view until it restores it from fullscreen") + webView.removeFromSuperview() + host.setWebView(webView) + XCTAssertTrue(webView.superview === host, "An unattached page can be restored") + } + + func testDepartureClearsNativeHoverWithoutMovingThePointer() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let cursorBefore = NSEvent.mouseLocation + + WebViewDeparture.endHover(in: webView) + // Check the synchronous operation before waiting for DOM delivery; + // the user can move the pointer during that later asynchronous wait. + XCTAssertEqual(NSEvent.mouseLocation, cursorBefore) + try await waitUntil(webView, "!document.getElementById('target').matches(':hover') && window.trustedExits > 0") + } + + func testReturningFromHideDoesNotEndHoverOnTheRestoredPage() async throws { + let (window, webView) = try await hoveredFixture() + let wasHidden = NSApp.isHidden + defer { + if wasHidden { NSApp.hide(nil) } else { NSApp.unhideWithoutActivation() } + window.close() + } + InputSettle.shared.recordDeparture(in: webView) + let started = ContinuousClock.now + WebViewDeparture.hideApplication() + XCTAssertLessThan(started.duration(to: .now), .seconds(1)) + + // AppKit orders hide asynchronously; return after that transition, + // rather than asking to unhide before Hide has taken effect. + try await Task.sleep(for: .milliseconds(100)) + NSApp.unhideWithoutActivation() + try await Task.sleep(for: .milliseconds(100)) + XCTAssertFalse(NSApp.isHidden) + try await seedHover(in: webView, window: window) + let exitsBeforeWait = try await webView.evaluateJavaScriptValue("window.trustedExits") as? Int + try await Task.sleep(for: .seconds(2.6)) + let exits = try await webView.evaluateJavaScriptValue("window.trustedExits") as? Int + XCTAssertEqual(exits, exitsBeforeWait, "A stale hide must not end hover after return") + let hovered = try await webView.evaluateJavaScriptValue("document.getElementById('target').matches(':hover')") + XCTAssertEqual(hovered as? Bool, true) + } + + func testEmptyPageDoesNotWaitForAnotherPagesDeparture() async throws { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + let otherPage = WKWebView(frame: .zero, configuration: configuration) + InputSettle.shared.recordDeparture(in: otherPage) + let started = ContinuousClock.now + + await WebViewDeparture.prepareForDestruction(in: [webView]) + + XCTAssertLessThan(started.duration(to: .now), .milliseconds(500)) + } + + func testDestructiveDepartureAllowsWorkStartedByHoverExit() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + + await WebViewDeparture.prepareForDestruction(in: [webView]) + + let status = try await webView.evaluateJavaScriptValue("document.getElementById('status').textContent") + XCTAssertEqual(status as? String, "Saved") + } + + func testSwitchingViewsEndsTheOutgoingPagesHover() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let host = try XCTUnwrap(window.contentView as? WebViewHostView) + let replacement = WKWebView(frame: webView.frame, configuration: webView.configuration) + + host.setWebView(replacement) + XCTAssertTrue(host.subviews.last === replacement, "Show the new page without waiting") + + try await waitUntil(webView, "window.trustedExits > 0 && document.getElementById('status').textContent === 'Saved'") + try await waitForDetachment(webView) + XCTAssertTrue(replacement.superview === host) + } + + func testSelectingAMacAppSettlesTheOutgoingPageBeforeDetaching() async throws { + let (window, webView, hosting, _) = try await selectedCardFixture() + defer { window.close() } + + // Selection changes before WebContentView clears its current page. + // The native branch must shelve it even during that intermediate render. + hosting.rootView = card(service: nativeService(), webView: webView) + hosting.layoutSubtreeIfNeeded() + + XCTAssertTrue(webView.window === window, "Keep the outgoing page attached during departure") + let hit = hosting.hitTest(NSPoint(x: hosting.bounds.midX, y: hosting.bounds.midY)) + XCTAssertNotNil(hit, "The native panel must accept input immediately") + XCTAssertFalse(hit === webView || hit?.isDescendant(of: webView) == true, "The native panel must cover the page immediately") + let focused = window.firstResponder as? NSView + XCTAssertFalse(focused === webView || focused?.isDescendant(of: webView) == true) + try await waitUntil(webView, "window.trustedExits > 0 && document.getElementById('status').textContent === 'Saved'") + try await waitForDetachment(webView) + } + + func testQuickReturnFromAMacAppKeepsTheRestoredPageAttached() async throws { + let (window, webView, hosting, service) = try await selectedCardFixture() + defer { window.close() } + hosting.rootView = card(service: nativeService(), webView: nil) + hosting.layoutSubtreeIfNeeded() + + hosting.rootView = card(service: service, webView: webView) + hosting.layoutSubtreeIfNeeded() + // Wait past the full settle and exit-work windows. A shorter wait + // would miss a stale task that ends hover after the test returns. + try await Task.sleep(for: .seconds(3)) + + XCTAssertTrue(webView.window === window) + let exits = try await webView.evaluateJavaScriptValue("window.trustedExits") + XCTAssertEqual(exits as? Int, 0, "A cancelled departure must not end hover on the restored page") + let host = try XCTUnwrap(webView.superview as? WebViewHostView) + XCTAssertTrue(host.hitTest(NSPoint(x: 100, y: 100))?.isDescendant(of: webView) == true) + } + + func testClearingSelectionSettlesTheOutgoingPageBeforeDetaching() async throws { + let (window, webView, hosting, _) = try await selectedCardFixture() + defer { window.close() } + + hosting.rootView = card(service: nil, webView: nil) + hosting.layoutSubtreeIfNeeded() + + XCTAssertTrue(webView.window === window) + let host = try XCTUnwrap(webView.superview as? WebViewHostView) + XCTAssertNil(host.hitTest(NSPoint(x: 100, y: 100)), "A retained outgoing page must not receive pointer input") + try await waitUntil(webView, "window.trustedExits > 0 && document.getElementById('status').textContent === 'Saved'") + try await waitForDetachment(webView) + } + + private func nativeService() -> ServiceInstance { + ServiceInstance(label: "Fixture Mac app", url: NativeApp.serviceURL(forBundleID: "com.chorus.fixture.missing")) + } + + private func selectedCardFixture() async throws -> (NSWindow, WKWebView, NSHostingView>, ServiceInstance) { + let service = ServiceInstance(label: "Fixture", url: "https://example.invalid") + let (window, webView) = try await hoveredFixture(service: service) + let hosting = try XCTUnwrap(window.contentView as? NSHostingView>) + XCTAssertTrue(window.makeFirstResponder(webView)) + let focused = window.firstResponder as? NSView + XCTAssertTrue(focused === webView || focused?.isDescendant(of: webView) == true) + return (window, webView, hosting, service) + } + + private func card(service: ServiceInstance?, webView: WKWebView?) -> WebContentCard { + WebContentCard( + service: service, webView: webView, tabs: nil, + transitionSnapshot: nil, isLoading: false, + findInPageVisible: .constant(false), onCloseTab: { _ in } + ) { Color.clear } + } + + func testRepeatedReloadDoesNotQueueAnotherDeparture() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let first = Task { @MainActor in + await WebViewCoordinator.reload(webView, fallbackURL: nil) + } + try await waitUntil(webView, "window.trustedExits > 0") + + await WebViewCoordinator.reload(webView, fallbackURL: nil) + let loads = try await webView.evaluateJavaScriptValue("window.loadCount") + XCTAssertEqual(loads as? Int, 1, "Repeated intent returns while the original reload is pending") + await first.value + try await waitUntil(webView, "window.loadCount === 2") + } + + func testNewDocumentSupersedesPendingReload() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let reload = Task { @MainActor in + await WebViewCoordinator.reload(webView, fallbackURL: nil) + } + try await waitUntil(webView, "window.trustedExits > 0") + let replacement = FileManager.default.temporaryDirectory.appendingPathComponent("reload-target-\(UUID()).html") + defer { try? FileManager.default.removeItem(at: replacement) } + try """ + + """.write(to: replacement, atomically: true, encoding: .utf8) + webView.loadFileURL(replacement, allowingReadAccessTo: replacement.deletingLastPathComponent()) + try await waitUntil(webView, "window.newDocument === true") + // A timing value is not document identity. Use a token created once + // per real, reloadable document, independent of clock precision. + let tokenValue = try await webView.evaluateJavaScriptValue("window.documentToken") + let token = try XCTUnwrap(tokenValue as? String) + await reload.value + for _ in 0..<100 { + if !webView.isLoading { break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertFalse(webView.isLoading) + let tokenAfterValue = try await webView.evaluateJavaScriptValue("window.documentToken") + let tokenAfter = try XCTUnwrap(tokenAfterValue as? String) + XCTAssertEqual(tokenAfter, token, "Do not reload a document that replaced the requested target") + } + + func testRequestedReloadSurvivesSameDocumentHashChange() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let reload = Task { @MainActor in + await WebViewCoordinator.reload(webView, fallbackURL: nil) + } + try await waitUntil(webView, "window.trustedExits > 0") + try await webView.evaluateJavaScriptValue("location.hash = 'changed'; null") + await reload.value + + try await waitUntil(webView, "window.loadCount === 2") + } + + func testReloadPreservesWorkStartedByHoverExit() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + + await WebViewCoordinator.reload(webView, fallbackURL: nil) + + try await waitUntil(webView, "window.loadCount === 2") + let status = try await webView.evaluateJavaScriptValue("document.getElementById('status').textContent") + XCTAssertEqual(status as? String, "Saved") + } + + func testQuickReturnDoesNotDetachOrEndHoverOnTheRestoredPage() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let host = try XCTUnwrap(window.contentView as? WebViewHostView) + let replacement = WKWebView(frame: webView.frame, configuration: webView.configuration) + + host.setWebView(replacement) + host.setWebView(webView) + try await waitForDetachment(replacement) + + XCTAssertTrue(webView.superview === host) + XCTAssertTrue(host.subviews.last === webView) + let hovering = try await webView.evaluateJavaScriptValue("document.getElementById('target').matches(':hover')") + XCTAssertEqual(hovering as? Bool, true) + XCTAssertNil(replacement.superview) + } + + func testQuitFinishesHoverExitWorkBeforeReleasingThePage() async throws { + let (window, webView) = try await hoveredFixture(releasesForQuit: true) + defer { window.close() } + + await WebViewDeparture.prepareForQuit(in: [webView]) + + let status = try await webView.evaluateJavaScriptValue("document.getElementById('status').textContent") + XCTAssertEqual(status as? String, "pagehide:Saved") + } + + func testQuitHonoursWorkFromAnAlreadyDeliveredHoverExit() async throws { + let (window, webView) = try await hoveredFixture(releasesForQuit: true) + defer { window.close() } + WebViewDeparture.endHover(in: webView) + try await waitUntil(webView, "window.trustedExits > 0") + + await WebViewDeparture.prepareForQuit(in: [webView]) + + let status = try await webView.evaluateJavaScriptValue("document.getElementById('status').textContent") + XCTAssertEqual(status as? String, "pagehide:Saved") + } + + func testDestructiveDepartureSupportsQuirksModePages() async throws { + let (window, webView) = try await hoveredFixture(standardsMode: false) + defer { window.close() } + + await WebViewDeparture.prepareForDestruction(in: [webView]) + + let status = try await webView.evaluateJavaScriptValue("document.getElementById('status').textContent") + XCTAssertEqual(status as? String, "Saved") + } + + func testCancelledReloadKeepsTheCurrentDocument() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let reload = Task { @MainActor in + await WebViewCoordinator.reload(webView, fallbackURL: nil) + } + try await waitUntil(webView, "window.trustedExits > 0") + + reload.cancel() + await reload.value + + let loads = try await webView.evaluateJavaScriptValue("window.loadCount") + XCTAssertEqual(loads as? Int, 1) + } + + func testSwitchTransfersKeyboardFocusToTheNewPage() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let host = try XCTUnwrap(window.contentView as? WebViewHostView) + let replacement = WKWebView(frame: webView.frame, configuration: webView.configuration) + + host.setWebView(replacement) + + let focused = window.firstResponder as? NSView + XCTAssertTrue(focused === replacement || focused?.isDescendant(of: replacement) == true) + } + + func testRestoredPageStillFillsTheResizedWindow() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + let host = try XCTUnwrap(window.contentView as? WebViewHostView) + let replacement = WKWebView(frame: webView.frame, configuration: webView.configuration) + host.setWebView(replacement) + host.setWebView(webView) + + window.setContentSize(NSSize(width: 600, height: 500)) + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(webView.frame, host.bounds) + } + + private func hoveredFixture(standardsMode: Bool = true, releasesForQuit: Bool = false, service: ServiceInstance? = nil) async throws -> (NSWindow, WKWebView) { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + if releasesForQuit { + configuration.userContentController.addUserScript(WKUserScript( + source: UserScriptManager.makeVisibilityOverrideScript(), + injectionTime: .atDocumentStart, forMainFrameOnly: true + )) + } + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300), configuration: configuration) + let window = FixtureWindow( + contentRect: NSRect(x: 100, y: 100, width: 400, height: 300), + styleMask: [.titled], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + if let service { + window.contentView = NSHostingView(rootView: card(service: service, webView: webView)) + } else { + let host = WebViewHostView(frame: webView.frame) + host.setWebView(webView) + window.contentView = host + } + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(webView) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("chorus-departure-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let fixtureURL = directory.appendingPathComponent("fixture.html") + try """ + \(standardsMode ? "" : "") + +
Hover fixture Pending
+ + """.write(to: fixtureURL, atomically: true, encoding: .utf8) + webView.loadFileURL(fixtureURL, allowingReadAccessTo: directory) + try await waitUntil(webView, "window.fixtureReady === true && !document.hidden") + + try await seedHover(in: webView, window: window) + return (window, webView) + } + + private func seedHover(in webView: WKWebView, window: NSWindow) async throws { + // Seed hover through AppKit, not a DOM event or a cursor warp. + let move = try XCTUnwrap(NSEvent.mouseEvent( + with: .mouseMoved, + location: webView.convert(NSPoint(x: 100, y: 100), to: nil), + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 0, pressure: 0 + )) + sendNativeMove(move, in: webView) + try await waitUntil(webView, "document.getElementById('target').matches('#target:hover')") + } + + /// Control only the platform window metadata. The real WebKit engine still + /// handles input, hit-testing, CSS hover, and trusted DOM events. This keeps + /// the fixture usable on a locked desktop or a headless CI runner. + private final class FixtureWindow: NSWindow { + override var occlusionState: NSWindow.OcclusionState { [.visible] } + override var isKeyWindow: Bool { true } + } + + private func sendNativeMove(_ event: NSEvent, in view: NSView) { + for area in view.trackingAreas where area.options.contains(.mouseMoved) { + if let owner = area.owner as? NSObject, + owner.responds(to: #selector(NSResponder.mouseMoved(with:))) { + owner.perform(#selector(NSResponder.mouseMoved(with:)), with: event) + } + } + for subview in view.subviews { sendNativeMove(event, in: subview) } + } + + private func waitForDetachment(_ webView: WKWebView) async throws { + for _ in 0..<200 { + if webView.superview == nil { return } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("The outgoing page remained attached after its grace period") + throw FixtureError.timeout + } + + private func waitUntil(_ webView: WKWebView, _ condition: String) async throws { + // The shared settle window can still be running from the previous + // departure. Allow its 2.2 seconds plus exit work and runner overhead. + for _ in 0..<200 { + if try await webView.evaluateJavaScriptValue(condition) as? Bool == true { return } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("WebKit did not reach expected state: \(condition)") + throw FixtureError.timeout + } + + private enum FixtureError: Error { case timeout } +} diff --git a/ChorusTests/WebViewPoolTests.swift b/ChorusTests/WebViewPoolTests.swift new file mode 100644 index 0000000..770fafd --- /dev/null +++ b/ChorusTests/WebViewPoolTests.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftData +import WebKit +import XCTest +@testable import Chorus + +@MainActor +final class WebViewPoolTests: XCTestCase { + func testForegroundAndPreloadUseTheSameConstructionPolicy() throws { + let schema = Schema(versionedSchema: ChorusSchemaVCurrent.self) + let container = try ModelContainer( + for: schema, + configurations: [ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)] + ) + let stores = DataStoreManager() + let pool = WebViewPool( + dataStoreManager: stores, + userScriptManager: UserScriptManager(), + contentBlocker: ContentBlockerManager(isEnabled: false) + ) + let host = WebViewHostView(frame: NSRect(x: 0, y: 0, width: 640, height: 480)) + let previousSize = WebViewHostView.lastSize + host.layout() + defer { + host.setFrameSize(previousSize) + host.layout() + } + + for userAgent in [nil, "FixtureBrowser/1.0"] as [String?] { + let foreground = ServiceInstance(label: "Foreground", url: "about:blank", userAgent: userAgent) + let preloaded = ServiceInstance(label: "Preloaded", url: "about:blank", userAgent: userAgent) + container.mainContext.insert(foreground) + container.mainContext.insert(preloaded) + defer { + for service in [foreground, preloaded] { + pool.removeWebView(for: service.id) + stores.evict(identifier: service.dataStoreIdentifier) + } + } + let identifiers = [foreground.dataStoreIdentifier, preloaded.dataStoreIdentifier] + addTeardownBlock { @MainActor in + // These identifiers belong only to this test. Views and cached + // stores are released, but the network process can lag behind. + for identifier in identifiers { + for attempt in 0..<30 { + try await Task.sleep(for: .milliseconds(100)) + do { + try await WKWebsiteDataStore.remove(forIdentifier: identifier) + break + } catch { + if attempt == 29 { throw error } + } + } + } + } + + let displayed = pool.webView(for: foreground) + pool.preload(preloaded) + let background = try XCTUnwrap(pool.liveWebView(for: preloaded.id)) + + XCTAssertEqual(pool.activeServiceID, foreground.id, "Preloading must not change the active service") + for webView in [displayed, background] { + XCTAssertEqual(webView.frame.size, CGSize(width: 640, height: 480)) + XCTAssertTrue(webView.allowsBackForwardNavigationGestures) + XCTAssertEqual(webView.customUserAgent, userAgent ?? UserAgentProvider.safariDefault) + XCTAssertNotNil(webView.navigationDelegate) + XCTAssertNotNil(webView.uiDelegate) + XCTAssertTrue(webView.navigationDelegate === webView.uiDelegate) + #if DEBUG + XCTAssertTrue(webView.isInspectable) + #endif + } + XCTAssertEqual(displayed.configuration.websiteDataStore.identifier, foreground.dataStoreIdentifier) + XCTAssertEqual(background.configuration.websiteDataStore.identifier, preloaded.dataStoreIdentifier) + XCTAssertTrue(pool.webView(for: preloaded) === background, "Activation must reuse the preloaded page") + } + } +} From 278192bb59c350c3e9b61d73c4a237cfa7594537 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 11:55:04 -0500 Subject: [PATCH 02/10] chore: separate the default Safari 27 browser identity The identity applies to every service without an override, including popups and polling. Keep it separate from departure timing and remove the unsupported claim that Gmail toolbar actions improve with this token. The installed WebKit engine is unchanged. --- CHANGELOG.md | 4 ++++ Chorus/Utilities/UserAgentProvider.swift | 15 +++++---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96305f2..ace17b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to Chorus are documented here. Format loosely follows ## [Unreleased] +### Changed + +- Chorus tells sites it is Safari 27 rather than 26 unless the service has a custom browser setting. + ### Fixed - Hiding Chorus and switching services happen at once. Chorus gives recent page actions a short time to finish before clearing hover or putting the page aside. This does not confirm a server save: reloading or quitting immediately after a click can still lose an unsent change. diff --git a/Chorus/Utilities/UserAgentProvider.swift b/Chorus/Utilities/UserAgentProvider.swift index 473eb95..39e090e 100644 --- a/Chorus/Utilities/UserAgentProvider.swift +++ b/Chorus/Utilities/UserAgentProvider.swift @@ -1,16 +1,11 @@ import Foundation enum UserAgentProvider { - /// Safari user agent string used when a service has no custom userAgent set. - /// Matches a real Safari release so services like Gmail, Outlook, Slack, and - /// WhatsApp recognise the browser and serve their full web app instead of a - /// degraded view (or refuse outright — Slack drops support for older Safari - /// versions on a rolling basis). - /// - /// Keep the `Version/N.N` token bumped to a currently-shipping Safari major - /// when releasing. The "Intel Mac OS X 10_15_7" platform token is what - /// Apple's own Safari emits on Apple Silicon too — don't change it. - static let safariDefault = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15" + /// Default browser identity for every service without an explicit override. + /// Inboxes, popups, badge polling and floating drafts use the same default. + /// The Version token identifies the browser to sites; it does not change + /// the installed WebKit engine. Validate provider behavior before each bump. + static let safariDefault = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15" /// Mobile Safari (iOS) user agent used by a service's "Mobile view" toggle, /// so sites serve their phone/tablet web layout instead of the desktop one. From 64dc46395ed50e6c2d52f4c43f1c279ba71e3734 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 14:41:51 -0500 Subject: [PATCH 03/10] test: isolate real-store checks and report migration provenance --- ChorusTests/ChorusTests.swift | 114 +++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 35 deletions(-) diff --git a/ChorusTests/ChorusTests.swift b/ChorusTests/ChorusTests.swift index 3354562..47691af 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -1,4 +1,5 @@ import XCTest +import CoreData import SwiftData import SQLite3 import JavaScriptCore @@ -4972,42 +4973,66 @@ final class ChorusTests: XCTestCase { } } - /// Opens a COPY of a real store through the migration plan and reports what - /// came through. Skipped unless `CHORUS_REAL_STORE_COPY` names a directory. - /// - /// Every synthetic migration test here builds its own fixture, so they prove - /// the stages are correct about stores this file wrote. They cannot prove - /// anything about the store a real install has accumulated across versions, - /// and that is the store a release actually has to migrate. This repo has - /// lost a user's spaces and services twice, so before shipping a schema - /// change the release check is: copy the live store triple somewhere else, - /// point this at it, and read the counts. - /// - /// Copy all three files. A store copied without its `-wal` and `-shm` cannot - /// be opened read-only (`CANTOPEN`), which is the trap that once made the - /// auto-restore blind to perfectly good backups. - /// - /// CHORUS_REAL_STORE_COPY=/tmp/copy xcodebuild test … \ - /// -only-testing:ChorusTests/ChorusTests/testMigratesARealStoreCopy - /// - /// Never point it at the live store. It opens read-write through the plan, - /// which migrates in place. + /// The marker names a prepared store triple, never the live store. Each run + /// opens a fresh disposable copy and reports whether its stored shape changed. func testMigratesARealStoreCopy() throws { - // A file rather than an environment variable. These tests are app-hosted, - // and neither a bare nor a TEST_RUNNER_-prefixed variable on the - // xcodebuild command line reaches the hosted process — both were tried. - // A path in a file always arrives. let marker = "/tmp/chorus-real-store-copy" - guard let raw = try? String(contentsOfFile: marker, encoding: .utf8) else { - throw XCTSkip("write a directory holding a COPY of default.store to \(marker)") + guard let raw = try? String(contentsOfFile: marker, encoding: .utf8), + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw XCTSkip("write a prepared store directory to \(marker)") } - let dir = raw.trimmingCharacters(in: .whitespacesAndNewlines) - let url = URL(fileURLWithPath: dir).appending(path: "default.store") - XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), "no default.store in \(dir)") + let source = URL(fileURLWithPath: raw.trimmingCharacters(in: .whitespacesAndNewlines)) + _ = try checkPreparedStoreCopy(source) + } + + private let preparedStoreFiles = ["default.store", "default.store-wal", "default.store-shm"] + private func preparedStoreBytes(_ directory: URL) throws -> [Data] { + try preparedStoreFiles.map { try Data(contentsOf: directory.appending(path: $0)) } + } + + private func storeShape(_ url: URL) throws -> [String: Data] { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, at: url, options: [NSReadOnlyPersistentStoreOption: true] + ) + return try XCTUnwrap(metadata[NSStoreModelVersionHashesKey] as? [String: Data]) + } + + /// Metadata is read only from the disposable copy: even a read-only SQLite + /// connection can update SHM. Entity hashes identify shape, not a version label. + @discardableResult + private func checkPreparedStoreCopy(_ source: URL) throws -> Bool { + let original = try preparedStoreBytes(source) + let directory = FileManager.default.temporaryDirectory + .appending(path: "chorus-real-store-check-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for file in preparedStoreFiles { + try FileManager.default.copyItem(at: source.appending(path: file), to: directory.appending(path: file)) + } + defer { + do { XCTAssertEqual(try preparedStoreBytes(source), original, "prepared store changed") } + catch { XCTFail("could not verify prepared store: \(error)") } + } + let url = directory.appending(path: "default.store") + let before = try storeShape(url) let schema = Schema(versionedSchema: ChorusSchemaVCurrent.self) let config = ModelConfiguration(schema: schema, url: url) - let container = try ModelContainer(for: schema, migrationPlan: ChorusMigrationPlan.self, configurations: [config]) + print("REAL STORE SOURCE SHAPE: \(before.mapValues { $0.base64EncodedString() })") + let container: ModelContainer + do { + container = try ModelContainer(for: schema, migrationPlan: ChorusMigrationPlan.self, configurations: [config]) + } catch { + throw NSError(domain: "RealStoreMigrationCheck", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Prepared source cannot open through the migration plan targeting \(ChorusSchemaVCurrent.versionIdentifier). A newer or unsupported source shape is not migration evidence. Underlying error: \(error)" + ]) + } + let after = try storeShape(url) + let migrated = before != after + print("REAL STORE PROVENANCE: source hashes=\(before.mapValues { $0.base64EncodedString() }); target hashes=\(after.mapValues { $0.base64EncodedString() })") + print(migrated + ? "REAL STORE RESULT: migration occurred; stored shape changed" + : "REAL STORE RESULT: source already matches current shape; NO MIGRATION occurred; open-integrity check only") let ctx = container.mainContext let spaces = try ctx.fetch(FetchDescriptor()) @@ -5015,11 +5040,6 @@ final class ChorusTests: XCTestCase { let links = try ctx.fetch(FetchDescriptor()) print("REAL STORE: \(spaces.count) spaces, \(services.count) services, \(links.count) links") - for space in spaces.sorted(by: { $0.sortOrder < $1.sortOrder }) { - let members = space.serviceLinks.compactMap(\.liveService).map(\.label) - print(" \(space.emoji) \(space.name): \(members.joined(separator: ", "))") - } - XCTAssertFalse(spaces.isEmpty, "the migration must not empty the store") XCTAssertFalse(services.isEmpty, "the migration must not empty the store") @@ -5031,6 +5051,30 @@ final class ChorusTests: XCTestCase { dangling.isEmpty, "\(dangling.count) of \(links.count) links lost an end in the migration" ) + return migrated + } + + func testRealStoreCopyIsolationAndRepeatedCurrentShapeCheck() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "chorus-prepared-store-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let schema = Schema(versionedSchema: ChorusSchemaVCurrent.self) + let config = ModelConfiguration(schema: schema, url: directory.appending(path: "default.store")) + let container = try ModelContainer(for: schema, configurations: [config]) + let space = Space(name: "Fixture", emoji: "🧪", sortOrder: 0) + let service = ServiceInstance(label: "Fixture", url: "https://example.com") + container.mainContext.insert(space) + container.mainContext.insert(service) + let link = SpaceServiceLink(sortOrder: 0, space: space, service: service) + if link.modelContext == nil { container.mainContext.insert(link) } + try container.mainContext.save() + let original = try preparedStoreBytes(directory) + XCTAssertFalse(try checkPreparedStoreCopy(directory)) + XCTAssertEqual(try preparedStoreBytes(directory), original) + XCTAssertFalse(try checkPreparedStoreCopy(directory)) + XCTAssertEqual(try preparedStoreBytes(directory), original) + withExtendedLifetime(container) {} } /// The REAL production provenance. Every field store today was written by the From c2c4ad83cfcff16ec2b2149a54564a977c0ba889 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 14:54:11 -0500 Subject: [PATCH 04/10] test: prove hide-return cancellation without window-server hide --- Chorus/Views/WebView/WebViewDeparture.swift | 18 +++++++++-- ChorusTests/WebViewDepartureTests.swift | 34 ++++++++++----------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/Chorus/Views/WebView/WebViewDeparture.swift b/Chorus/Views/WebView/WebViewDeparture.swift index daac891..2273fac 100644 --- a/Chorus/Views/WebView/WebViewDeparture.swift +++ b/Chorus/Views/WebView/WebViewDeparture.swift @@ -34,6 +34,17 @@ enum WebViewDeparture { for window in NSApp.windows where window.isVisible { if let content = window.contentView { visit(content) } } + NSApp.hide(nil) + scheduleHideExit(in: webViews) + } + + /// The hidden-state query is separate from AppKit so cancellation can be + /// exercised even when the window server ignores Hide (for example in CI). + @discardableResult + static func scheduleHideExit( + in webViews: [WKWebView], + isHidden: @escaping @MainActor () -> Bool = { NSApp.isHidden } + ) -> Task { if unhideObserver == nil { unhideObserver = NotificationCenter.default.addObserver( forName: NSApplication.willUnhideNotification, object: nil, queue: nil @@ -43,15 +54,14 @@ enum WebViewDeparture { } cancelPendingHide() let generation = hideGeneration - NSApp.hide(nil) - hideTask = Task { @MainActor in + let task = Task { @MainActor in let activity = ProcessInfo.processInfo.beginActivity( options: [.userInitiatedAllowingIdleSystemSleep], reason: "Let recently started page work finish after Hide" ) defer { ProcessInfo.processInfo.endActivity(activity) } await InputSettle.shared.waitForSettle(in: webViews) - guard !Task.isCancelled, generation == hideGeneration, NSApp.isHidden else { return } + guard !Task.isCancelled, generation == hideGeneration, isHidden() else { return } hideTask = nil for webView in webViews where webView.window != nil && !webView.isHiddenOrHasHiddenAncestor @@ -59,6 +69,8 @@ enum WebViewDeparture { endHover(in: webView) } } + hideTask = task + return task } /// Let hover work run while visible, then release the pinned visibility diff --git a/ChorusTests/WebViewDepartureTests.swift b/ChorusTests/WebViewDepartureTests.swift index c20d3b7..986aa89 100644 --- a/ChorusTests/WebViewDepartureTests.swift +++ b/ChorusTests/WebViewDepartureTests.swift @@ -95,31 +95,31 @@ final class WebViewDepartureTests: XCTestCase { func testReturningFromHideDoesNotEndHoverOnTheRestoredPage() async throws { let (window, webView) = try await hoveredFixture() - let wasHidden = NSApp.isHidden - defer { - if wasHidden { NSApp.hide(nil) } else { NSApp.unhideWithoutActivation() } - window.close() - } + defer { window.close() } InputSettle.shared.recordDeparture(in: webView) - let started = ContinuousClock.now - WebViewDeparture.hideApplication() - XCTAssertLessThan(started.duration(to: .now), .seconds(1)) - - // AppKit orders hide asynchronously; return after that transition, - // rather than asking to unhide before Hide has taken effect. - try await Task.sleep(for: .milliseconds(100)) - NSApp.unhideWithoutActivation() - try await Task.sleep(for: .milliseconds(100)) - XCTAssertFalse(NSApp.isHidden) - try await seedHover(in: webView, window: window) + // Keep the hidden-state guard true: only return cancellation can stop + // this exit, regardless of whether AppKit can hide on the runner. + let pendingExit = WebViewDeparture.scheduleHideExit(in: [webView], isHidden: { true }) + await Task.yield() + NotificationCenter.default.post(name: NSApplication.willUnhideNotification, object: NSApp) let exitsBeforeWait = try await webView.evaluateJavaScriptValue("window.trustedExits") as? Int - try await Task.sleep(for: .seconds(2.6)) + await pendingExit.value + try await Task.sleep(for: .milliseconds(100)) let exits = try await webView.evaluateJavaScriptValue("window.trustedExits") as? Int XCTAssertEqual(exits, exitsBeforeWait, "A stale hide must not end hover after return") let hovered = try await webView.evaluateJavaScriptValue("document.getElementById('target').matches(':hover')") XCTAssertEqual(hovered as? Bool, true) } + func testPendingHideEndsHoverWithoutReturn() async throws { + let (window, webView) = try await hoveredFixture() + defer { window.close() } + InputSettle.shared.recordDeparture(in: webView) + let pendingExit = WebViewDeparture.scheduleHideExit(in: [webView], isHidden: { true }) + await pendingExit.value + try await waitUntil(webView, "window.trustedExits > 0 && !document.getElementById('target').matches(':hover')") + } + func testEmptyPageDoesNotWaitForAnotherPagesDeparture() async throws { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() From c0036748c1ba62bb23178fdcc3153cf001ff3547 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 14:57:46 -0500 Subject: [PATCH 05/10] fix: mask retained pages behind an opaque current-page backdrop --- Chorus/Views/WebView/WebViewContainer.swift | 14 ++++++++++ ChorusTests/WebViewDepartureTests.swift | 30 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/Chorus/Views/WebView/WebViewContainer.swift b/Chorus/Views/WebView/WebViewContainer.swift index 55b10fb..f5a3806 100644 --- a/Chorus/Views/WebView/WebViewContainer.swift +++ b/Chorus/Views/WebView/WebViewContainer.swift @@ -19,6 +19,7 @@ struct WebViewContainer: NSViewRepresentable { /// rounded corners. final class WebViewHostView: NSView { private weak var currentWebView: WKWebView? + private let pageBackdrop = NSHostingView(rootView: ChorusColor.card) private var pendingDetachments: [ObjectIdentifier: Task] = [:] /// The size the page area last had, for the pool to make new web views at. @@ -41,6 +42,15 @@ final class WebViewHostView: NSView { layer?.cornerRadius = ChorusCard.webCornerRadius layer?.cornerCurve = .continuous layer?.masksToBounds = true + pageBackdrop.translatesAutoresizingMaskIntoConstraints = false + pageBackdrop.setAccessibilityHidden(true) + addSubview(pageBackdrop) + NSLayoutConstraint.activate([ + pageBackdrop.topAnchor.constraint(equalTo: topAnchor), + pageBackdrop.bottomAnchor.constraint(equalTo: bottomAnchor), + pageBackdrop.leadingAnchor.constraint(equalTo: leadingAnchor), + pageBackdrop.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) } @available(*, unavailable) @@ -73,6 +83,10 @@ final class WebViewHostView: NSView { let transfersFocus = outgoing.map { focused === $0 || focused?.isDescendant(of: $0) == true } ?? false currentWebView = webView + // Separate the visible page from retained pages, including when the + // incoming document (or WebKit's backing) is transparent. Keep the + // outgoing page attached below this opaque card until departure ends. + addSubview(pageBackdrop) if let webView { let alreadyAttached = webView.superview === self webView.translatesAutoresizingMaskIntoConstraints = false diff --git a/ChorusTests/WebViewDepartureTests.swift b/ChorusTests/WebViewDepartureTests.swift index 986aa89..adb6026 100644 --- a/ChorusTests/WebViewDepartureTests.swift +++ b/ChorusTests/WebViewDepartureTests.swift @@ -157,6 +157,36 @@ final class WebViewDepartureTests: XCTestCase { XCTAssertTrue(replacement.superview === host) } + func testTransparentIncomingPageCannotRevealRetainedOutgoingPage() async throws { + let (window, outgoing, hosting, service) = try await selectedCardFixture() + defer { window.close() } + try await outgoing.evaluateJavaScriptValue("document.getElementById('target').textContent = ''; null") + try await Task.sleep(for: .milliseconds(100)) + let beforeBitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: beforeBitmap) + let beforePixel = try XCTUnwrap(beforeBitmap.colorAt(x: beforeBitmap.pixelsWide / 2, y: beforeBitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + XCTAssertTrue(beforePixel.redComponent > 0.8 && beforePixel.greenComponent < 0.2 && beforePixel.blueComponent < 0.2, "Capture must see the outgoing red page: \(beforePixel)") + let incoming = WKWebView(frame: outgoing.frame, configuration: outgoing.configuration) + // Transparent CSS alone still leaves WebKit's default opaque backing. + // Disable it only in the fixture to cover a fully transparent page. + incoming.setValue(false, forKey: "drawsBackground") + incoming.underPageBackgroundColor = .clear + incoming.loadHTMLString("", baseURL: nil) + try await waitUntil(incoming, "window.transparentReady === true") + InputSettle.shared.recordDeparture(in: outgoing) + hosting.rootView = card(service: service, webView: incoming) + hosting.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(100)) + XCTAssertTrue(outgoing.window === window, "Keep the outgoing page attached during its grace period") + XCTAssertTrue(incoming.superview === outgoing.superview) + let bitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let pixel = try XCTUnwrap(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + XCTAssertFalse(pixel.redComponent > 0.8 && pixel.greenComponent < 0.2 && pixel.blueComponent < 0.2, + "The retained red page must not show through the transparent incoming page: \(pixel)") + try await waitForDetachment(outgoing) + } + func testSelectingAMacAppSettlesTheOutgoingPageBeforeDetaching() async throws { let (window, webView, hosting, _) = try await selectedCardFixture() defer { window.close() } From 731639ed5547e8ed1bdc053026a03a8c04b07293 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 15:04:37 -0500 Subject: [PATCH 06/10] ci: remove unrelated failed-test diagnostics upload --- .github/workflows/test.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31b9aa2..b5c83d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,13 +85,3 @@ jobs: -scheme Chorus \ -configuration Debug \ -destination 'platform=macOS' - - - name: Save failed test diagnostics - if: failure() - uses: actions/upload-artifact@v4 - with: - name: test-diagnostics-${{ matrix.os }} - path: | - ~/Library/Developer/Xcode/DerivedData/Chorus-*/Logs/Test/*.xcresult - ~/Library/Logs/DiagnosticReports/Chorus*.ips - retention-days: 1 From 52c87ec41b49242a9647532fbd2fc1eca1657fa3 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 15:18:41 -0500 Subject: [PATCH 07/10] test: make retained-page transparency check portable --- ChorusTests/WebViewDepartureTests.swift | 42 ++++++++++++++----------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/ChorusTests/WebViewDepartureTests.swift b/ChorusTests/WebViewDepartureTests.swift index adb6026..8bbc419 100644 --- a/ChorusTests/WebViewDepartureTests.swift +++ b/ChorusTests/WebViewDepartureTests.swift @@ -160,31 +160,37 @@ final class WebViewDepartureTests: XCTestCase { func testTransparentIncomingPageCannotRevealRetainedOutgoingPage() async throws { let (window, outgoing, hosting, service) = try await selectedCardFixture() defer { window.close() } - try await outgoing.evaluateJavaScriptValue("document.getElementById('target').textContent = ''; null") - try await Task.sleep(for: .milliseconds(100)) - let beforeBitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) - hosting.cacheDisplay(in: hosting.bounds, to: beforeBitmap) - let beforePixel = try XCTUnwrap(beforeBitmap.colorAt(x: beforeBitmap.pixelsWide / 2, y: beforeBitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) - XCTAssertTrue(beforePixel.redComponent > 0.8 && beforePixel.greenComponent < 0.2 && beforePixel.blueComponent < 0.2, "Capture must see the outgoing red page: \(beforePixel)") + let host = try XCTUnwrap(outgoing.superview as? WebViewHostView) let incoming = WKWebView(frame: outgoing.frame, configuration: outgoing.configuration) - // Transparent CSS alone still leaves WebKit's default opaque backing. - // Disable it only in the fixture to cover a fully transparent page. - incoming.setValue(false, forKey: "drawsBackground") - incoming.underPageBackgroundColor = .clear + // CSS expresses the page's intent without private drawsBackground KVC. + // The separator's structural invariant protects retained pages even + // when WebKit's backing is transparent; view caching cannot prove it. incoming.loadHTMLString("", baseURL: nil) try await waitUntil(incoming, "window.transparentReady === true") InputSettle.shared.recordDeparture(in: outgoing) hosting.rootView = card(service: service, webView: incoming) - hosting.layoutSubtreeIfNeeded() - try await Task.sleep(for: .milliseconds(100)) + // SwiftUI can apply the new root on a later AppKit turn on macOS 15. + for _ in 0..<100 { + hosting.layoutSubtreeIfNeeded() + if incoming.superview === host { break } + try await Task.sleep(for: .milliseconds(10)) + } + host.layoutSubtreeIfNeeded() XCTAssertTrue(outgoing.window === window, "Keep the outgoing page attached during its grace period") - XCTAssertTrue(incoming.superview === outgoing.superview) - let bitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) - hosting.cacheDisplay(in: hosting.bounds, to: bitmap) - let pixel = try XCTUnwrap(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) - XCTAssertFalse(pixel.redComponent > 0.8 && pixel.greenComponent < 0.2 && pixel.blueComponent < 0.2, - "The retained red page must not show through the transparent incoming page: \(pixel)") + XCTAssertTrue(outgoing.superview === host) + XCTAssertTrue(incoming.superview === host) + let separators = host.subviews.filter { !($0 is WKWebView) } + XCTAssertEqual(separators.count, 1, "The host must have one separator between pages") + let separator = try XCTUnwrap(separators.first) + let outgoingIndex = try XCTUnwrap(host.subviews.firstIndex { $0 === outgoing }) + let separatorIndex = try XCTUnwrap(host.subviews.firstIndex { $0 === separator }) + let incomingIndex = try XCTUnwrap(host.subviews.firstIndex { $0 === incoming }) + XCTAssertLessThan(outgoingIndex, separatorIndex, "The separator must cover the retained outgoing page") + XCTAssertLessThan(separatorIndex, incomingIndex, "The incoming page must sit above the separator") + XCTAssertEqual(separator.frame, host.bounds, "The separator must cover the entire page area") + XCTAssertFalse(separator.isHidden) try await waitForDetachment(outgoing) + XCTAssertNil(outgoing.superview) } func testSelectingAMacAppSettlesTheOutgoingPageBeforeDetaching() async throws { From f0b87d442624faf6a298f0585d1a1027e80bacd2 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 15:25:34 -0500 Subject: [PATCH 08/10] test: verify retained-page separator opacity --- ChorusTests/WebViewDepartureTests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ChorusTests/WebViewDepartureTests.swift b/ChorusTests/WebViewDepartureTests.swift index 8bbc419..0df6d8d 100644 --- a/ChorusTests/WebViewDepartureTests.swift +++ b/ChorusTests/WebViewDepartureTests.swift @@ -163,8 +163,6 @@ final class WebViewDepartureTests: XCTestCase { let host = try XCTUnwrap(outgoing.superview as? WebViewHostView) let incoming = WKWebView(frame: outgoing.frame, configuration: outgoing.configuration) // CSS expresses the page's intent without private drawsBackground KVC. - // The separator's structural invariant protects retained pages even - // when WebKit's backing is transparent; view caching cannot prove it. incoming.loadHTMLString("", baseURL: nil) try await waitUntil(incoming, "window.transparentReady === true") InputSettle.shared.recordDeparture(in: outgoing) @@ -189,6 +187,13 @@ final class WebViewDepartureTests: XCTestCase { XCTAssertLessThan(separatorIndex, incomingIndex, "The incoming page must sit above the separator") XCTAssertEqual(separator.frame, host.bounds, "The separator must cover the entire page area") XCTAssertFalse(separator.isHidden) + XCTAssertEqual(separator.alphaValue, 1) + // The separator has no WebKit subviews, so caching it alone avoids + // WKWebView compositing failures on macOS 14/15. + let bitmap = try XCTUnwrap(separator.bitmapImageRepForCachingDisplay(in: separator.bounds)) + separator.cacheDisplay(in: separator.bounds, to: bitmap) + let centrePixel = try XCTUnwrap(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)) + XCTAssertEqual(centrePixel.alphaComponent, 1.0, "The separator must draw an opaque background") try await waitForDetachment(outgoing) XCTAssertNil(outgoing.superview) } From 54543e6234a79d7bbc82ab9e261897da55b42d32 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 11:59:29 -0500 Subject: [PATCH 09/10] feat: open approved mail links in account-scoped draft windows Preserve the system mail app for in-service clicks. Ask before accepting an exact handler, and use explicit Gmail-only Chrome discovery with bounded coalescing and cancellation. Share service browser capabilities with floating drafts without inferring completion from editor disappearance. Freeze the pre-mail schema and migrate to the proposed 1.5.29 shape; verified against an offline real-store copy. --- CHANGELOG.md | 6 + Chorus.xcodeproj/project.pbxproj | 72 ++ Chorus/App/AppDependencies.swift | 55 ++ Chorus/App/AppState.swift | 365 +++++-- Chorus/App/ChorusApp.swift | 7 +- Chorus/App/ChorusAppDelegate.swift | 17 +- Chorus/Info.plist | 18 + Chorus/Models/Schema/ChorusSchema.swift | 114 ++- Chorus/Models/ServiceInstance.swift | 49 +- Chorus/Services/DataStoreManager.swift | 7 +- Chorus/Services/MailLinkRouter.swift | 286 ++++++ Chorus/Services/SetupArchive.swift | 4 + Chorus/Services/UserScriptManager.swift | 174 ++++ Chorus/Utilities/UserAgentProvider.swift | 13 + .../Views/AddService/EditServiceSheet.swift | 21 +- Chorus/Views/MainWindow/ContentView.swift | 5 + .../MainWindow/MailLinkPresentation.swift | 165 ++++ .../WebView/MailComposeWindowSession.swift | 176 ++++ Chorus/Views/WebView/WebViewCoordinator.swift | 158 +-- Chorus/Views/WebView/WebViewDialogs.swift | 85 ++ Chorus/Views/WebView/WebViewPool.swift | 232 ++++- ChorusTests/AppStateMailRoutingTests.swift | 653 +++++++++++++ ChorusTests/ChorusTests.swift | 23 +- ChorusTests/ComposeFixtureServer.swift | 143 +++ ChorusTests/Fixtures/loopback-certificate.der | Bin 0 -> 859 bytes ChorusTests/Fixtures/loopback-private-key.der | Bin 0 -> 1192 bytes .../MailComposeWindowSessionTests.swift | 910 ++++++++++++++++++ ChorusTests/MailLinkRouterTests.swift | 410 ++++++++ ChorusTests/MailRoutingTestSupport.swift | 20 + ChorusTests/MailSchemaMigrationTests.swift | 52 + ChorusTests/ManifestDiscoveryTests.swift | 308 ++++++ 31 files changed, 4348 insertions(+), 200 deletions(-) create mode 100644 Chorus/App/AppDependencies.swift create mode 100644 Chorus/Services/MailLinkRouter.swift create mode 100644 Chorus/Views/MainWindow/MailLinkPresentation.swift create mode 100644 Chorus/Views/WebView/MailComposeWindowSession.swift create mode 100644 Chorus/Views/WebView/WebViewDialogs.swift create mode 100644 ChorusTests/AppStateMailRoutingTests.swift create mode 100644 ChorusTests/ComposeFixtureServer.swift create mode 100644 ChorusTests/Fixtures/loopback-certificate.der create mode 100644 ChorusTests/Fixtures/loopback-private-key.der create mode 100644 ChorusTests/MailComposeWindowSessionTests.swift create mode 100644 ChorusTests/MailLinkRouterTests.swift create mode 100644 ChorusTests/MailRoutingTestSupport.swift create mode 100644 ChorusTests/MailSchemaMigrationTests.swift create mode 100644 ChorusTests/ManifestDiscoveryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ace17b5..7cef0c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to Chorus are documented here. Format loosely follows ## [Unreleased] +### Added + +- Chorus can open mail links from macOS in a floating window for a chosen account. Choose Save and detect mail links in that service’s settings, then approve its page for new drafts. Links clicked inside services still use the Mac’s default mail app. + ### Changed - Chorus tells sites it is Safari 27 rather than 26 unless the service has a custom browser setting. @@ -15,6 +19,8 @@ All notable changes to Chorus are documented here. Format loosely follows - Service tabs no longer cover the top of the page. Returning from Hide cancels its delayed hover exit, and repeated reload requests share one pending reload. - Chorus no longer crashes on macOS 14 when a page action returns no result. +- Mail compose windows stay open when an editor disappears and hide with their popups when Chorus locks. + ## [1.5.28] - 2026-10-11 ### Changed diff --git a/Chorus.xcodeproj/project.pbxproj b/Chorus.xcodeproj/project.pbxproj index 1862880..9aa6d16 100644 --- a/Chorus.xcodeproj/project.pbxproj +++ b/Chorus.xcodeproj/project.pbxproj @@ -18,7 +18,9 @@ 091825995DF2FD9C4A7BC86F /* KeyboardShortcutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AC7E996360A4BC660FD194 /* KeyboardShortcutManager.swift */; }; 17274B7DA79560457403E050 /* ServiceTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E0761BBE5A85E34BC73573 /* ServiceTabs.swift */; }; 1C5CD1937EFECD06DA479F86 /* Space.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC9767949C165F7DCD9A281 /* Space.swift */; }; + 1C641774B76C01A3835A79B3 /* MailRoutingTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF6147D07264ECB9C18F1F9 /* MailRoutingTestSupport.swift */; }; 1CC3A629AEC1BBF9ACDC5FB0 /* SpaceHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D93229EEC510DC0850005E /* SpaceHeaderView.swift */; }; + 21CE2C121A9DC52E1C664978 /* MailLinkRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988355570538EDEA88BD6577 /* MailLinkRouterTests.swift */; }; 230144499E6BE9FD41C03338 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7458024AA17BF508D4807018 /* ContentView.swift */; }; 27E1865FA1BB65766DBDB808 /* WhatsNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 464C332182D96B75A17F363C /* WhatsNew.swift */; }; 2D9A102388157D9B3C660F70 /* ServiceCatalogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3506A7D95472D8F3E822A39 /* ServiceCatalogEntry.swift */; }; @@ -31,31 +33,39 @@ 3C5338A54D7C824BCF6C642C /* StoreInventory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67FC16493C168FFB5B6C7385 /* StoreInventory.swift */; }; 3DEA98A98656EE981A29A71D /* FindInPageBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76865DDEE671C34CC88CF4D8 /* FindInPageBar.swift */; }; 4129642FB0AD19AC83F01A30 /* UserAgentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E431CF7CE145D536081A23 /* UserAgentProvider.swift */; }; + 43F2F54AB0E48E47CC3A10D5 /* MailLinkPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73EA2E5F9D38FFF48A599167 /* MailLinkPresentation.swift */; }; 4B199EBC47BA21CE22ECC893 /* EmojiData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDCAF66B6DD05BA3A244E9E /* EmojiData.swift */; }; + 4E00B49F3398D7B93F12073D /* ManifestDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFA9CBF59B562BD16C90419 /* ManifestDiscoveryTests.swift */; }; 53615BD3734128E5CEE21D0A /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E88CC47CBB66B4749ACDC068 /* AppLogger.swift */; }; 53C0B5A438EDC6C98FE081AB /* QuickSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA55A0E026084DC20071FBCE /* QuickSwitcherView.swift */; }; 542D3E32DA8EA90EC36BB6A0 /* DataStoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD49B64B44CC548574DA2348 /* DataStoreManager.swift */; }; 5561BE9B6C6D8AA46CD42884 /* WebViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09D9F34755F65BE5A3C76C88 /* WebViewContainer.swift */; }; 57B17C6907B888254011D1C3 /* DownloadCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0856C776E775A4CB34F964C5 /* DownloadCenter.swift */; }; + 5D3C490E569CC809BE959C07 /* AppDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C7C102F7F878633629C1DF /* AppDependencies.swift */; }; 5E59730D46AE1AED21F82ECA /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C307C75776869CE8A82618 /* AppPreferences.swift */; }; + 632CDF4C91A8628D7FA66813 /* loopback-certificate.der in Resources */ = {isa = PBXBuildFile; fileRef = 458CA7F7B84762CD3C61D41B /* loopback-certificate.der */; }; 6335C06C8BEA4BAFCC16F334 /* WindowBackdrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AEA5DCBB5AB96E2C962A90 /* WindowBackdrop.swift */; }; + 67CB33B7E9BA34A2B4E5A3FD /* MailLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51156BEDADFBA8829DF009A8 /* MailLinkRouter.swift */; }; 68FE172BCA70FBB55022F7B1 /* StoreRelocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71CC3F7D7BD76E4100BC126 /* StoreRelocation.swift */; }; 69AC657C2E3D81BFB330050A /* AppRelauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA77C4C73BEB532F6AC1C339 /* AppRelauncher.swift */; }; 69FF2932CDD4A1208CA5DA71 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9D707C0F9A04B5E0744F59 /* SettingsView.swift */; }; 6C15E37D4717938D4C31384A /* ModelContext+Save.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313680424148829FCBEAA9BC /* ModelContext+Save.swift */; }; 701984CFE765170D8EAE50EE /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D811CC9D75730152E17859C /* MenuBarView.swift */; }; 71E467BDECC180049BF6708B /* AppPresenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1D24379DD182D4A7B6DD11 /* AppPresenceManager.swift */; }; + 7426CA113607179764D9EB99 /* AppStateMailRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295FAFF6AB055EB54B1856FD /* AppStateMailRoutingTests.swift */; }; 7597D8FC2B31233D86929C45 /* THIRD_PARTY_NOTICES.md in Resources */ = {isa = PBXBuildFile; fileRef = 1B679EE1300566DA259FFF77 /* THIRD_PARTY_NOTICES.md */; }; 75C395B384D9E21AF94FBC4F /* UserScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBBF69E002BB39BB6EFF9BDB /* UserScriptManager.swift */; }; 783EF6C467034D74DE8D5F52 /* WebViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192C907C2215D62E02FF8408 /* WebViewState.swift */; }; 78C80EEE6E3E6F9D2DB819D5 /* RailSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542BCDA03CE248BE3E75D814 /* RailSupport.swift */; }; 817A70A10923352BBAC3F63E /* CatalogIconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0693A705E1552F5809BA3399 /* CatalogIconCache.swift */; }; 8325C3DEC3242E7E2C19EB07 /* SpacePaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E9D5B98CFDDD0B2E7FD42C /* SpacePaletteView.swift */; }; + 836B68FAD5C0AF8AA94A86EF /* loopback-private-key.der in Resources */ = {isa = PBXBuildFile; fileRef = FCABC2B7B9CA937F4628A4B2 /* loopback-private-key.der */; }; 863BAA64BC341E5286D8915E /* UnifiedRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2B28970B7AEF9B0EB53AFD /* UnifiedRailView.swift */; }; 8AEFF9F928EBEA5194112A21 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DD7FF3D960F0FCA3A2F310B1 /* Assets.xcassets */; }; 8DEAA301843C94622A4061F4 /* ServiceTabStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2B240CAC6C5AD94A056FA8 /* ServiceTabStrip.swift */; }; 8E4B7701A7A18F51C6109E10 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3640458AF427BC8D3C8F2A /* NetworkMonitor.swift */; }; 8F6E2C62BD277E5577FBE480 /* WebContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4617D503719C1701307F28 /* WebContentView.swift */; }; + 90646898FE48C1288A3AE375 /* MailComposeWindowSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9CACA110AA1437DC59C0B2 /* MailComposeWindowSessionTests.swift */; }; 92BF212550953DD2E21A12E7 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01EC3720B49A4D8B953CDA90 /* EmojiPickerView.swift */; }; 9722D48056956FA71E911D98 /* ServicePopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200FB7DAF9279B0E2764474D /* ServicePopup.swift */; }; 97D0EDAE4F2712616A66B8C2 /* WebViewPoolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E589AEBC286712A2556141FC /* WebViewPoolTests.swift */; }; @@ -70,10 +80,12 @@ B095E6333D67F97829F12E97 /* ChorusSchema.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311EEF2D99711EA929371A79 /* ChorusSchema.swift */; }; BAA5421430BDF6F9B918B7B5 /* WebViewPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B27F538D778C6FC4B1697 /* WebViewPool.swift */; }; BAEE46A4791ABB6963914A5D /* NativeAppSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F91EDD3C2858890FA0B343 /* NativeAppSupport.swift */; }; + BAFA348635B413979EDEC95B /* WebViewDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E47060ADA2CA332562BBC32 /* WebViewDialogs.swift */; }; BD6B6CD0E903780B610BD6B5 /* StoreRecoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05727F83BFEFB338D97E4DF /* StoreRecoveryView.swift */; }; BEB67E84D36E90ADBC75AEF6 /* WebViewDepartureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F3C6319147E80D9FCD5260E /* WebViewDepartureTests.swift */; }; C14E7A689411E8DCCC20ED74 /* fanboy-annoyance.json in Resources */ = {isa = PBXBuildFile; fileRef = 0D9CBE47687FB4FF2EF6BED5 /* fanboy-annoyance.json */; }; C1E4CE7879BB117DBA721C35 /* SpaceStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537DF7ABA3D6DED64273776A /* SpaceStripView.swift */; }; + C43BAE5029D071C59D92E852 /* ComposeFixtureServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9CA095367EB2D4AB24EFE3 /* ComposeFixtureServer.swift */; }; C4603B5D275EEABB2283D850 /* InputSettle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89214AFC85F9FE43143A36EC /* InputSettle.swift */; }; C4ADA7C24E806E00FF4709D1 /* ChorusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA2B8A19B41207F66E51CC3 /* ChorusTests.swift */; }; C4F49AC21F05DCC84FE0D96F /* SetupTransferSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132D2C07876ECAB3E3073872 /* SetupTransferSection.swift */; }; @@ -96,8 +108,10 @@ E28F6C4DD1E61484CEA773F0 /* ContentBlockerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 728A9599471A2AB033991157 /* ContentBlockerManager.swift */; }; E2BFB66EDC3B4F7CD92A1836 /* TrafficLights.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6161E11C236922FC7AEFB3DF /* TrafficLights.swift */; }; F1E3E4A757954012789BD79D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = F63EF11A2754495AEB09C97E /* Sparkle */; }; + F6C5D86226A4F29A721C5301 /* MailSchemaMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF753516F9E8111DD6CCEDE2 /* MailSchemaMigrationTests.swift */; }; F709F5524B2146C260A0BCB0 /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 706D85D3C09C182826CD4EBA /* BadgeManager.swift */; }; FC24793EEE444C3F48F17559 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEC6FCF1B60E1B9CD89A3A2 /* NotificationManager.swift */; }; + FC701AAEFD7FC59901AB6C86 /* MailComposeWindowSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF0CEB73B831B9248D9CC59 /* MailComposeWindowSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -128,6 +142,8 @@ 1D9D707C0F9A04B5E0744F59 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 200FB7DAF9279B0E2764474D /* ServicePopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePopup.swift; sourceTree = ""; }; 24EE83C0600AFD4A12914883 /* HibernatedBadgePoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HibernatedBadgePoller.swift; sourceTree = ""; }; + 295FAFF6AB055EB54B1856FD /* AppStateMailRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateMailRoutingTests.swift; sourceTree = ""; }; + 2A9CA095367EB2D4AB24EFE3 /* ComposeFixtureServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFixtureServer.swift; sourceTree = ""; }; 311EEF2D99711EA929371A79 /* ChorusSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChorusSchema.swift; sourceTree = ""; }; 312995FF05808961FD519D76 /* SpaceServiceLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceLink.swift; sourceTree = ""; }; 313680424148829FCBEAA9BC /* ModelContext+Save.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModelContext+Save.swift"; sourceTree = ""; }; @@ -138,11 +154,14 @@ 3BEC6FCF1B60E1B9CD89A3A2 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 42C2BEF1C847F26AD98EABF0 /* DemoControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoControl.swift; sourceTree = ""; }; 438D78C9503E732B7792FA10 /* WebViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewCoordinator.swift; sourceTree = ""; }; + 458CA7F7B84762CD3C61D41B /* loopback-certificate.der */ = {isa = PBXFileReference; path = "loopback-certificate.der"; sourceTree = ""; }; 464C332182D96B75A17F363C /* WhatsNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNew.swift; sourceTree = ""; }; 4666C49AA129F26D936B0715 /* ServiceInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceInstance.swift; sourceTree = ""; }; 4D2B28970B7AEF9B0EB53AFD /* UnifiedRailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedRailView.swift; sourceTree = ""; }; + 4EFA9CBF59B562BD16C90419 /* ManifestDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestDiscoveryTests.swift; sourceTree = ""; }; 4FF59EADC9EE4BBF5C9E0B8E /* WebViewDeparture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewDeparture.swift; sourceTree = ""; }; 50260A359445D2B4C556E1E6 /* ChorusStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChorusStyle.swift; sourceTree = ""; }; + 51156BEDADFBA8829DF009A8 /* MailLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailLinkRouter.swift; sourceTree = ""; }; 52D93229EEC510DC0850005E /* SpaceHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceHeaderView.swift; sourceTree = ""; }; 537DF7ABA3D6DED64273776A /* SpaceStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceStripView.swift; sourceTree = ""; }; 538A1641C210F10E3CD43E14 /* WebToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebToolbarView.swift; sourceTree = ""; }; @@ -155,8 +174,10 @@ 67FC16493C168FFB5B6C7385 /* StoreInventory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInventory.swift; sourceTree = ""; }; 690DB6DBF74EAD8B040C1460 /* InAppBrowserWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppBrowserWindow.swift; sourceTree = ""; }; 6935142E75345640B2C4D7F0 /* DarkReaderSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkReaderSupport.swift; sourceTree = ""; }; + 6E47060ADA2CA332562BBC32 /* WebViewDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewDialogs.swift; sourceTree = ""; }; 706D85D3C09C182826CD4EBA /* BadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeManager.swift; sourceTree = ""; }; 728A9599471A2AB033991157 /* ContentBlockerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentBlockerManager.swift; sourceTree = ""; }; + 73EA2E5F9D38FFF48A599167 /* MailLinkPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailLinkPresentation.swift; sourceTree = ""; }; 7458024AA17BF508D4807018 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 76865DDEE671C34CC88CF4D8 /* FindInPageBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindInPageBar.swift; sourceTree = ""; }; 7C3DF954AFE066BC42761ABC /* licenses */ = {isa = PBXFileReference; lastKnownFileType = folder; path = licenses; sourceTree = SOURCE_ROOT; }; @@ -164,10 +185,12 @@ 8223D66D602364046AF98EDC /* Chorus.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Chorus.entitlements; sourceTree = ""; }; 88C307C75776869CE8A82618 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = ""; }; 89214AFC85F9FE43143A36EC /* InputSettle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSettle.swift; sourceTree = ""; }; + 8E9CACA110AA1437DC59C0B2 /* MailComposeWindowSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposeWindowSessionTests.swift; sourceTree = ""; }; 8F17BFF4DCC563429E130A13 /* CatalogGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogGridView.swift; sourceTree = ""; }; 8F3C6319147E80D9FCD5260E /* WebViewDepartureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewDepartureTests.swift; sourceTree = ""; }; 90198C05F204DF1B75DC485C /* InputSettleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSettleTests.swift; sourceTree = ""; }; 94326A5DEE01135F945E9DF3 /* Chorus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chorus.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 988355570538EDEA88BD6577 /* MailLinkRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailLinkRouterTests.swift; sourceTree = ""; }; 9D811CC9D75730152E17859C /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 9DD5F8F7C2450C88FA1A3886 /* EditServiceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServiceSheet.swift; sourceTree = ""; }; 9EDCAF66B6DD05BA3A244E9E /* EmojiData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiData.swift; sourceTree = ""; }; @@ -178,13 +201,17 @@ B3506A7D95472D8F3E822A39 /* ServiceCatalogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceCatalogEntry.swift; sourceTree = ""; }; B44B9AB3EC52D565BFB46183 /* ServiceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceRowView.swift; sourceTree = ""; }; B5AEA5DCBB5AB96E2C962A90 /* WindowBackdrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowBackdrop.swift; sourceTree = ""; }; + B5C7C102F7F878633629C1DF /* AppDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDependencies.swift; sourceTree = ""; }; B71CC3F7D7BD76E4100BC126 /* StoreRelocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreRelocation.swift; sourceTree = ""; }; B8291060BF6847242E1F03C6 /* WebViewJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewJavaScript.swift; sourceTree = ""; }; B8E0761BBE5A85E34BC73573 /* ServiceTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceTabs.swift; sourceTree = ""; }; + BBF0CEB73B831B9248D9CC59 /* MailComposeWindowSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposeWindowSession.swift; sourceTree = ""; }; BFD9BB4DBF6536D1AD0CA298 /* ServiceHealth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceHealth.swift; sourceTree = ""; }; CA55A0E026084DC20071FBCE /* QuickSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherView.swift; sourceTree = ""; }; CA77C4C73BEB532F6AC1C339 /* AppRelauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRelauncher.swift; sourceTree = ""; }; CBBF69E002BB39BB6EFF9BDB /* UserScriptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserScriptManager.swift; sourceTree = ""; }; + CBF6147D07264ECB9C18F1F9 /* MailRoutingTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailRoutingTestSupport.swift; sourceTree = ""; }; + CF753516F9E8111DD6CCEDE2 /* MailSchemaMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailSchemaMigrationTests.swift; sourceTree = ""; }; D2AC7E996360A4BC660FD194 /* KeyboardShortcutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutManager.swift; sourceTree = ""; }; D533B667501DF0913FF62B9A /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; D9E64C2450C98941A48AC7BD /* hagezi-light.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "hagezi-light.json"; sourceTree = ""; }; @@ -204,6 +231,7 @@ ECCC4A3133903E40BFB42AA6 /* AddServiceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServiceSheet.swift; sourceTree = ""; }; FBA2B8A19B41207F66E51CC3 /* ChorusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChorusTests.swift; sourceTree = ""; }; FBCB337C1EF863BC5957ABE9 /* BlocklistSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlocklistSupport.swift; sourceTree = ""; }; + FCABC2B7B9CA937F4628A4B2 /* loopback-private-key.der */ = {isa = PBXFileReference; path = "loopback-private-key.der"; sourceTree = ""; }; FEC9767949C165F7DCD9A281 /* Space.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Space.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -223,11 +251,13 @@ isa = PBXGroup; children = ( 690DB6DBF74EAD8B040C1460 /* InAppBrowserWindow.swift */, + BBF0CEB73B831B9248D9CC59 /* MailComposeWindowSession.swift */, 200FB7DAF9279B0E2764474D /* ServicePopup.swift */, B8E0761BBE5A85E34BC73573 /* ServiceTabs.swift */, 09D9F34755F65BE5A3C76C88 /* WebViewContainer.swift */, 438D78C9503E732B7792FA10 /* WebViewCoordinator.swift */, 4FF59EADC9EE4BBF5C9E0B8E /* WebViewDeparture.swift */, + 6E47060ADA2CA332562BBC32 /* WebViewDialogs.swift */, 608B27F538D778C6FC4B1697 /* WebViewPool.swift */, 192C907C2215D62E02FF8408 /* WebViewState.swift */, ); @@ -243,6 +273,15 @@ path = SpaceEditor; sourceTree = ""; }; + 25A122208C040FF556CD5EF4 /* Fixtures */ = { + isa = PBXGroup; + children = ( + 458CA7F7B84762CD3C61D41B /* loopback-certificate.der */, + FCABC2B7B9CA937F4628A4B2 /* loopback-private-key.der */, + ); + path = Fixtures; + sourceTree = ""; + }; 2F68812BE3F937F3228F7FBA /* AddService */ = { isa = PBXGroup; children = ( @@ -265,6 +304,7 @@ 0856C776E775A4CB34F964C5 /* DownloadCenter.swift */, 24EE83C0600AFD4A12914883 /* HibernatedBadgePoller.swift */, D2AC7E996360A4BC660FD194 /* KeyboardShortcutManager.swift */, + 51156BEDADFBA8829DF009A8 /* MailLinkRouter.swift */, 06F91EDD3C2858890FA0B343 /* NativeAppSupport.swift */, 3A3640458AF427BC8D3C8F2A /* NetworkMonitor.swift */, 3BEC6FCF1B60E1B9CD89A3A2 /* NotificationManager.swift */, @@ -369,6 +409,7 @@ 7458024AA17BF508D4807018 /* ContentView.swift */, 63600DD5EE558B052FFC8073 /* DownloadsButton.swift */, 76865DDEE671C34CC88CF4D8 /* FindInPageBar.swift */, + 73EA2E5F9D38FFF48A599167 /* MailLinkPresentation.swift */, 542BCDA03CE248BE3E75D814 /* RailSupport.swift */, BFD9BB4DBF6536D1AD0CA298 /* ServiceHealth.swift */, E84C935C8A82FD6B6B969E6B /* ServiceIconView.swift */, @@ -449,6 +490,7 @@ isa = PBXGroup; children = ( 7E0D298EDEBDC1F1EB614912 /* AppCapabilities.swift */, + B5C7C102F7F878633629C1DF /* AppDependencies.swift */, D533B667501DF0913FF62B9A /* AppState.swift */, E143D686890F381C5B42761F /* ChorusApp.swift */, B2E3F3C66C05EA91501A73EF /* ChorusAppDelegate.swift */, @@ -463,10 +505,18 @@ FD762D2848988AECEFD4AEF0 /* ChorusTests */ = { isa = PBXGroup; children = ( + 295FAFF6AB055EB54B1856FD /* AppStateMailRoutingTests.swift */, FBA2B8A19B41207F66E51CC3 /* ChorusTests.swift */, + 2A9CA095367EB2D4AB24EFE3 /* ComposeFixtureServer.swift */, 90198C05F204DF1B75DC485C /* InputSettleTests.swift */, + 8E9CACA110AA1437DC59C0B2 /* MailComposeWindowSessionTests.swift */, + 988355570538EDEA88BD6577 /* MailLinkRouterTests.swift */, + CBF6147D07264ECB9C18F1F9 /* MailRoutingTestSupport.swift */, + CF753516F9E8111DD6CCEDE2 /* MailSchemaMigrationTests.swift */, + 4EFA9CBF59B562BD16C90419 /* ManifestDiscoveryTests.swift */, 8F3C6319147E80D9FCD5260E /* WebViewDepartureTests.swift */, E589AEBC286712A2556141FC /* WebViewPoolTests.swift */, + 25A122208C040FF556CD5EF4 /* Fixtures */, ); path = ChorusTests; sourceTree = ""; @@ -479,6 +529,7 @@ buildConfigurationList = A93140D1CC77A4EE396C5397 /* Build configuration list for PBXNativeTarget "ChorusTests" */; buildPhases = ( CA7BD454FF6BCEDC820D2D31 /* Sources */, + 3A24609DF5492F07D8BC37B7 /* Resources */, ); buildRules = ( ); @@ -547,6 +598,15 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 3A24609DF5492F07D8BC37B7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 632CDF4C91A8628D7FA66813 /* loopback-certificate.der in Resources */, + 836B68FAD5C0AF8AA94A86EF /* loopback-private-key.der in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; E94009386810F8E81AF8C17C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -571,6 +631,7 @@ files = ( 0551F23E1A9E226486C1A0BB /* AddServiceSheet.swift in Sources */, DB9B6FD9265D63DCA11B6CFC /* AppCapabilities.swift in Sources */, + 5D3C490E569CC809BE959C07 /* AppDependencies.swift in Sources */, 53615BD3734128E5CEE21D0A /* AppLogger.swift in Sources */, 5E59730D46AE1AED21F82ECA /* AppPreferences.swift in Sources */, 71E467BDECC180049BF6708B /* AppPresenceManager.swift in Sources */, @@ -603,6 +664,9 @@ A98FA6B9454EC0BCEDEED093 /* InAppBrowserWindow.swift in Sources */, C4603B5D275EEABB2283D850 /* InputSettle.swift in Sources */, 091825995DF2FD9C4A7BC86F /* KeyboardShortcutManager.swift in Sources */, + FC701AAEFD7FC59901AB6C86 /* MailComposeWindowSession.swift in Sources */, + 43F2F54AB0E48E47CC3A10D5 /* MailLinkPresentation.swift in Sources */, + 67CB33B7E9BA34A2B4E5A3FD /* MailLinkRouter.swift in Sources */, 701984CFE765170D8EAE50EE /* MenuBarView.swift in Sources */, 6C15E37D4717938D4C31384A /* ModelContext+Save.swift in Sources */, BAEE46A4791ABB6963914A5D /* NativeAppSupport.swift in Sources */, @@ -642,6 +706,7 @@ 5561BE9B6C6D8AA46CD42884 /* WebViewContainer.swift in Sources */, DF712EA56B5AE10C7BF77176 /* WebViewCoordinator.swift in Sources */, D887CCC00E8FE67581C132C4 /* WebViewDeparture.swift in Sources */, + BAFA348635B413979EDEC95B /* WebViewDialogs.swift in Sources */, 0294214694FE69AB69B03C55 /* WebViewJavaScript.swift in Sources */, BAA5421430BDF6F9B918B7B5 /* WebViewPool.swift in Sources */, 783EF6C467034D74DE8D5F52 /* WebViewState.swift in Sources */, @@ -654,8 +719,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7426CA113607179764D9EB99 /* AppStateMailRoutingTests.swift in Sources */, C4ADA7C24E806E00FF4709D1 /* ChorusTests.swift in Sources */, + C43BAE5029D071C59D92E852 /* ComposeFixtureServer.swift in Sources */, D1C6FFBDDA20E8172D0BC347 /* InputSettleTests.swift in Sources */, + 90646898FE48C1288A3AE375 /* MailComposeWindowSessionTests.swift in Sources */, + 21CE2C121A9DC52E1C664978 /* MailLinkRouterTests.swift in Sources */, + 1C641774B76C01A3835A79B3 /* MailRoutingTestSupport.swift in Sources */, + F6C5D86226A4F29A721C5301 /* MailSchemaMigrationTests.swift in Sources */, + 4E00B49F3398D7B93F12073D /* ManifestDiscoveryTests.swift in Sources */, BEB67E84D36E90ADBC75AEF6 /* WebViewDepartureTests.swift in Sources */, 97D0EDAE4F2712616A66B8C2 /* WebViewPoolTests.swift in Sources */, ); diff --git a/Chorus/App/AppDependencies.swift b/Chorus/App/AppDependencies.swift new file mode 100644 index 0000000..bf38291 --- /dev/null +++ b/Chorus/App/AppDependencies.swift @@ -0,0 +1,55 @@ +import SwiftData +import WebKit + +/// Construction only. Persistence recovery, observers and preloading belong to +/// the production launch path, not to these shared dependencies. +@MainActor +struct AppDependencies { + let dataStoreManager: DataStoreManager + let userScriptManager: UserScriptManager + let badgeManager: BadgeManager + let notificationManager: NotificationManager + let transientBadgeFetcher: TransientBadgeFetcher + let contentBlocker: ContentBlockerManager + let webViewPool: WebViewPool + let networkMonitor: NetworkMonitor + + init( + modelContainer: ModelContainer, + dataStoreManager: DataStoreManager, + loadMailProbe: @escaping (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) }, + loadMailComposer: @escaping (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) } + ) { + self.dataStoreManager = dataStoreManager + let scripts = UserScriptManager() + let badges = BadgeManager() + userScriptManager = scripts + badgeManager = badges + scripts.isServiceMuted = { @Sendable serviceID in + MainActor.assumeIsolated { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == serviceID }) + descriptor.fetchLimit = 1 + return (try? modelContainer.mainContext.fetch(descriptor).first)?.isEffectivelyMuted ?? false + } + } + scripts.isServiceNotifyingOS = { @Sendable serviceID in + MainActor.assumeIsolated { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == serviceID }) + descriptor.fetchLimit = 1 + return (try? modelContainer.mainContext.fetch(descriptor).first)?.notifiesOSEffective ?? false + } + } + scripts.isDoNotDisturbActive = { @Sendable in + MainActor.assumeIsolated { badges.doNotDisturb } + } + notificationManager = NotificationManager(badgeManager: badges) + transientBadgeFetcher = TransientBadgeFetcher(badgeManager: badges, dataStoreManager: dataStoreManager) + let blocker = ContentBlockerManager() + contentBlocker = blocker + webViewPool = WebViewPool( + dataStoreManager: dataStoreManager, userScriptManager: scripts, + contentBlocker: blocker, loadMailProbe: loadMailProbe, loadMailComposer: loadMailComposer + ) + networkMonitor = NetworkMonitor() + } +} diff --git a/Chorus/App/AppState.swift b/Chorus/App/AppState.swift index 473a443..80a8d78 100644 --- a/Chorus/App/AppState.swift +++ b/Chorus/App/AppState.swift @@ -22,22 +22,23 @@ enum StoreLoadOutcome: Equatable { @Observable final class AppState { let modelContainer: ModelContainer - let webViewPool: WebViewPool - let contentBlocker: ContentBlockerManager - let dataStoreManager: DataStoreManager - let userScriptManager: UserScriptManager - let badgeManager: BadgeManager + private let dependencies: AppDependencies + var webViewPool: WebViewPool { dependencies.webViewPool } + var contentBlocker: ContentBlockerManager { dependencies.contentBlocker } + var dataStoreManager: DataStoreManager { dependencies.dataStoreManager } + var userScriptManager: UserScriptManager { dependencies.userScriptManager } + var badgeManager: BadgeManager { dependencies.badgeManager } /// Every download this session, for the toolbar's download list. let downloadCenter = DownloadCenter() /// Navigation state (back/forward/loading) for the active service's web view, /// shared so the top tab bar can host the nav buttons. let webViewState = WebViewState() - let notificationManager: NotificationManager - let transientBadgeFetcher: TransientBadgeFetcher + var notificationManager: NotificationManager { dependencies.notificationManager } + var transientBadgeFetcher: TransientBadgeFetcher { dependencies.transientBadgeFetcher } /// Copies Mac-app services' Dock badges onto the rail. let nativeAppBadgeReader = NativeAppBadgeReader() - let networkMonitor: NetworkMonitor + var networkMonitor: NetworkMonitor { dependencies.networkMonitor } var selectedSpaceID: UUID? var selectedServiceID: UUID? { @@ -89,6 +90,39 @@ final class AppState { /// main actor; answered via `answerMediaRequest(allow:)`. private(set) var pendingMediaRequest: MediaPermissionRequest? + struct PendingMailLink: Identifiable, Equatable { + let id: UUID + let candidates: [MailLinkAccount] + } + + /// The account chooser for the oldest queued mail link. The URL itself is + /// intentionally not exposed to the view, which only needs account labels. + private(set) var pendingMailLink: PendingMailLink? + + struct MailHandlerApproval: Identifiable { + let id = UUID() + let serviceID: UUID + let serviceLabel: String + let serviceURL: String + let handler: MailtoHandler + } + private var mailHandlerApprovals: [MailHandlerApproval] = [] + private var declinedMailHandlers: [UUID: MailtoHandler] = [:] + var pendingMailHandlerApproval: MailHandlerApproval? { + guard !isLocked, pendingMailLink == nil, mailLinkError == nil else { return nil } + return mailHandlerApprovals.first + } + struct MailLinkError: Identifiable { + let id = UUID() + let message: String + var consumesRequest = true + } + + private(set) var mailLinkError: MailLinkError? + var mailLinkErrorMessage: String? { mailLinkError?.message } + @ObservationIgnored private var mailLinkQueue = MailLinkRequestQueue() + @ObservationIgnored var openMainWindow: (() -> Void)? + /// A pending "always appear active?" offer, shown once right after the user /// adds a presence-sensitive service (Teams). Drives an alert in ContentView; /// nil when nothing is offered. Answered via `answerPresencePrompt(_:enable:)`. @@ -240,7 +274,13 @@ final class AppState { var appLockEnabled = false var lockOnLaunch = true var lockOnSleep = true - var isLocked = false + var isLocked = false { + didSet { + mailLinkQueue.isLocked = isLocked + webViewPool.setMailComposersLocked(isLocked) + if !isLocked { processNextMailLinkIfPossible() } + } + } /// Global content-blocking toggle, mirrored from AppPreferences at launch. /// The Settings switch writes both this and the persisted value via @@ -380,6 +420,24 @@ final class AppState { !isStoreInMemoryFallback && !storeWasDamagedAtLaunch && !storeWasRestoredAtLaunch } + /// Isolated application wiring without launch-time persistence, observers, + /// preloading, or recovery. Callers own the container and account stores. + init( + modelContainer: ModelContainer, + dataStoreManager: DataStoreManager, + loadMailProbe: @escaping (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) }, + loadMailComposer: @escaping (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) } + ) { + self.modelContainer = modelContainer + dependencies = AppDependencies(modelContainer: modelContainer, dataStoreManager: dataStoreManager, loadMailProbe: loadMailProbe, loadMailComposer: loadMailComposer) + storeURL = URL(fileURLWithPath: "/dev/null") + storeFileName = "isolated.store" + storeWasDamagedAtLaunch = false + storeWasRestoredAtLaunch = false + isStoreInMemoryFallback = true + setupExternalLinkRouting() + } + init() { // The current shipping shape, pinned as an explicit `VersionedSchema`. // `loadContainer` opens it through `ChorusMigrationPlan`, so an older @@ -462,62 +520,7 @@ final class AppState { self.isStoreInMemoryFallback = true } - self.dataStoreManager = DataStoreManager() - self.userScriptManager = UserScriptManager() - self.badgeManager = BadgeManager() - - // Capture the modelContainer locally so the @Sendable closure below - // doesn't capture `self` before all stored properties are assigned. - let container = self.modelContainer - let badgeManager = self.badgeManager - self.userScriptManager.isServiceMuted = { @Sendable serviceID in - // WKScriptMessageHandler.didReceive is invoked on the main - // thread, so we can safely hop into the main actor here to - // read the persisted mute state. A service is muted when its - // own isMuted flag is true *or* any of its parent spaces is - // muted (mute-the-space cascades to its members). - MainActor.assumeIsolated { - let context = container.mainContext - // Indexed single-row fetch, not a full-table scan — this runs on - // every intercepted web notification (matches isServiceNotifyingOS). - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == serviceID } - ) - descriptor.fetchLimit = 1 - guard let service = try? context.fetch(descriptor).first else { return false } - return service.isEffectivelyMuted - } - } - self.userScriptManager.isServiceNotifyingOS = { @Sendable serviceID in - // Per-service flag (not cascaded, unlike mute); nil → enabled. Runs - // on every intercepted web notification, so use an indexed single-row - // fetch rather than a full-table scan. Fails silent: a missing or - // deleted service does not post a banner. - MainActor.assumeIsolated { - let context = container.mainContext - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == serviceID } - ) - descriptor.fetchLimit = 1 - guard let service = try? context.fetch(descriptor).first else { return false } - return service.notifiesOSEffective - } - } - self.userScriptManager.isDoNotDisturbActive = { @Sendable in - MainActor.assumeIsolated { badgeManager.doNotDisturb } - } - self.notificationManager = NotificationManager(badgeManager: badgeManager) - self.transientBadgeFetcher = TransientBadgeFetcher( - badgeManager: badgeManager, - dataStoreManager: dataStoreManager - ) - self.contentBlocker = ContentBlockerManager() - self.webViewPool = WebViewPool( - dataStoreManager: dataStoreManager, - userScriptManager: userScriptManager, - contentBlocker: contentBlocker - ) - self.networkMonitor = NetworkMonitor() + dependencies = AppDependencies(modelContainer: modelContainer, dataStoreManager: DataStoreManager()) loadAppPreferences() startContentBlocker() @@ -587,6 +590,22 @@ final class AppState { webViewPool.externalLinkHandler = { [weak self] url, sourceServiceID in self?.handleExternalLink(url, from: sourceServiceID) } + webViewPool.canProbeMailHandler = { [weak self] id, url in + guard let self, !self.isLocked, let service = self.fetchService(id: id) else { return false } + return URL(string: service.url) == url && service.nativeAppBundleID == nil + && service.mailtoHandlerEnabledEffective + } + webViewPool.onMailHandlerProbeFailed = { [weak self] id in + guard let self, !self.isLocked, let service = self.fetchService(id: id), + service.mailtoHandlerEnabledEffective, self.mailLinkError == nil else { return } + self.mailLinkError = MailLinkError( + message: "Chorus could not find a page for new drafts. Sign in to this service, then try Save and detect mail links in its settings again.", + consumesRequest: false) + self.bringMainWindowForward() + } + userScriptManager.onMailHandlerDeclaration = { [weak self] declaration in + self?.acceptMailHandlerDeclaration(declaration) + } webViewPool.serviceOwnsURL = { [weak self] url, sourceServiceID in guard let self, let host = url.host else { return false } return self.serviceOwning(host: host, excluding: sourceServiceID) != nil @@ -629,7 +648,8 @@ final class AppState { // remove) invalidates a pending prompt for it — deny + drain so a stale // prompt can't linger and block a later service's prompt. webViewPool.onServiceTornDown = { [weak self] serviceID in - self?.drainMediaRequests(for: serviceID) + guard let self else { return } + self.drainMediaRequests(for: serviceID) } } @@ -917,6 +937,194 @@ final class AppState { } } + // MARK: - Mail links + + /// Entry point shared by macOS URL delivery and clicks inside a service. + /// Requests are queued without logging their contents. + func enqueueMailLink(_ url: URL) { + guard url.scheme?.caseInsensitiveCompare("mailto") == .orderedSame else { return } + mailLinkQueue.enqueue(url) + if isLocked { bringMainWindowForward() } + processNextMailLinkIfPossible() + } + + private func acceptMailHandlerDeclaration(_ declaration: MailHandlerDeclaration) { + guard !isLocked, let service = fetchService(id: declaration.serviceID), + let registration = MailLinkRouter.registration( + for: service, + protocolName: declaration.protocolName, + handlerTemplate: declaration.handlerTemplate, + declaringPageURL: declaration.declaringPageURL.absoluteString, + isMainFrame: declaration.isMainFrame, + enabled: service.mailtoHandlerEnabledEffective, + declaringSecurityOrigin: declaration.securityOrigin + ) + else { + AppLogger.webView.info("Rejected mail handler declaration for service \(declaration.serviceID)") + return + } + + webViewPool.completeMailHandlerProbe(for: declaration.serviceID) + let handler = MailtoHandler(template: registration.handlerTemplate, declaringOrigin: registration.declaringOrigin) + guard service.mailtoHandler != handler, + declinedMailHandlers[service.id] != handler, + !mailHandlerApprovals.contains(where: { $0.serviceID == service.id && $0.handler == handler }) + else { return } + // Keep the approved handler intact until the user approves its replacement. + mailHandlerApprovals.removeAll { $0.serviceID == service.id } + mailHandlerApprovals.append(MailHandlerApproval( + serviceID: service.id, serviceLabel: service.label, serviceURL: service.url, handler: handler + )) + } + + func answerMailHandlerApproval(_ id: UUID, allow: Bool) { + guard !isLocked, let approval = pendingMailHandlerApproval, approval.id == id else { return } + mailHandlerApprovals.removeFirst() + defer { processNextMailLinkIfPossible() } + guard let service = fetchService(id: approval.serviceID), service.url == approval.serviceURL, + MailLinkRouter.registration(for: service, protocolName: "mailto", + handlerTemplate: approval.handler.template, declaringPageURL: approval.handler.declaringOrigin, + isMainFrame: true, enabled: true) != nil else { return } + guard allow else { + declinedMailHandlers[service.id] = approval.handler + return + } + service.rememberMailtoHandler(approval.handler) + do { + try modelContainer.mainContext.save() + } catch { + modelContainer.mainContext.rollback() + AppLogger.dataStore.error("Failed to persist mail handler for service \(service.id): \(error.localizedDescription)") + } + } + + /// Explicit compatibility discovery: never loads a second copy merely + /// because an unrelated service finished navigating. + func detectMailHandler(for serviceID: UUID) { + guard !isLocked, let service = fetchService(id: serviceID), service.nativeAppBundleID == nil else { return } + guard service.mailtoHandlerEnabledEffective else { + mailLinkError = MailLinkError( + message: "Turn on Use for mail links in this service’s settings before trying detection.", + consumesRequest: false) + bringMainWindowForward() + return + } + guard let url = URL(string: service.url), url.scheme?.lowercased() == "https", Origin(url) != nil else { + mailLinkError = MailLinkError( + message: "Use a service address that starts with https:// to detect mail links.", + consumesRequest: false) + bringMainWindowForward() + return + } + declinedMailHandlers.removeValue(forKey: serviceID) + webViewPool.probeMailHandler(for: service, userRequested: true) + } + + private func currentMailRegistrations() -> [MailLinkRegistration] { + let services = (try? modelContainer.mainContext.fetch(FetchDescriptor())) ?? [] + return services.compactMap { service in + guard service.modelContext != nil, let handler = service.mailtoHandler else { return nil } + let provider = service.catalogEntryID.flatMap { ServiceCatalog.shared.entry(for: $0)?.name } + let spaces = service.spaceLinks + .filter { $0.modelContext != nil } + .compactMap(\.liveSpace) + .map(\.name) + return MailLinkRegistration( + account: MailLinkAccount( + serviceID: service.id, label: service.label, + providerLabel: provider, spaceLabels: Array(Set(spaces)).sorted() + ), + serviceURL: service.url, + handlerTemplate: handler.template, + declaringOrigin: handler.declaringOrigin, + enabled: service.mailtoHandlerEnabledEffective + ) + } + } + + private func processNextMailLinkIfPossible() { + mailHandlerApprovals.removeAll { approval in + guard let service = fetchService(id: approval.serviceID) else { return true } + return service.url != approval.serviceURL + } + guard !isLocked, + pendingMailLink == nil, + mailLinkErrorMessage == nil, + mailHandlerApprovals.isEmpty, + let request = mailLinkQueue.current + else { return } + + switch MailLinkRouter.route(request, registrations: currentMailRegistrations()) { + case .rejectInvalidRequest: + mailLinkQueue.completeCurrent() + processNextMailLinkIfPossible() + case .noEligibleService: + mailLinkError = MailLinkError(message: "No mail service is ready. Choose Save and detect mail links in your mail service’s settings, then approve its page for new drafts.") + bringMainWindowForward() + case .open(let destination): + if openMailDestination(destination) { + mailLinkQueue.completeCurrent() + processNextMailLinkIfPossible() + } else { + mailLinkError = MailLinkError(message: "That mail service is no longer available.") + bringMainWindowForward() + } + case .choose(let candidates): + pendingMailLink = PendingMailLink(id: UUID(), candidates: candidates) + bringMainWindowForward() + } + } + + func chooseMailService(_ serviceID: UUID, requestID: UUID) { + // A dismissed or lock-hidden chooser may still deliver a stale action. + // It must not replace a newer chooser or reveal details behind the lock. + guard !isLocked, pendingMailLink?.id == requestID else { return } + guard let request = mailLinkQueue.current, + let destination = MailLinkRouter.destination( + for: request, + serviceID: serviceID, + registrations: currentMailRegistrations() + ), + openMailDestination(destination) + else { + pendingMailLink = nil + mailLinkError = MailLinkError(message: "That mail service is no longer available.") + bringMainWindowForward() + return + } + pendingMailLink = nil + mailLinkQueue.completeCurrent() + processNextMailLinkIfPossible() + } + + func cancelMailLink(_ requestID: UUID) { + guard !isLocked, pendingMailLink?.id == requestID else { return } + pendingMailLink = nil + mailLinkQueue.completeCurrent() + processNextMailLinkIfPossible() + } + + func dismissMailLinkError(_ presentationID: UUID) { + guard !isLocked, mailLinkError?.id == presentationID else { return } + let consumesRequest = mailLinkError?.consumesRequest == true + mailLinkError = nil + if consumesRequest { mailLinkQueue.completeCurrent() } + processNextMailLinkIfPossible() + } + + private func openMailDestination(_ destination: MailLinkDestination) -> Bool { + guard let service = fetchService(id: destination.serviceID), service.modelContext != nil else { return false } + webViewPool.openMailComposer(for: service, at: destination.url) + return true + } + + private func bringMainWindowForward() { + webViewPool.cancelMailComposerFocusRequests() + NSApp.activate(ignoringOtherApps: true) + openMainWindow?() + NSApp.windows.first(where: { $0.identifier?.rawValue == "main" })?.makeKeyAndOrderFront(nil) + } + /// Decides what to do with a link that the user clicked in one service /// and which targets a different origin. If any other Chorus service owns /// that domain (same registrable domain, or the exact host for @@ -1058,8 +1266,9 @@ final class AppState { }) } - /// Gives live pages bounded time before quit, relaunch and installation. - /// This grace period does not confirm a provider's server save. + /// Gives live account pages and floating drafts bounded time before quit. + /// This is a grace period, not confirmation of a provider's server save. + /// Called for menu quit, ⌘Q, relaunch and Sparkle installation. func releasePagesForQuit() async { let webViews = webViewPool.liveWebViews await WebViewDeparture.prepareForQuit(in: webViews) @@ -1220,6 +1429,21 @@ final class AppState { presenceChanged: Bool = false ) { guard let service = currentServiceInstance(id: serviceID) else { return } + if urlChanged || !service.mailtoHandlerEnabledEffective { + mailHandlerApprovals.removeAll { $0.serviceID == serviceID } + declinedMailHandlers.removeValue(forKey: serviceID) + webViewPool.completeMailHandlerProbe(for: serviceID) + } + if urlChanged, let handler = service.mailtoHandler, + MailLinkRouter.registration( + for: service, protocolName: "mailto", handlerTemplate: handler.template, + declaringPageURL: handler.declaringOrigin, + // Stored declarations were accepted from a main frame; this + // revalidates the template/origin pair, not the opt-out. + isMainFrame: true, enabled: true + ) == nil { + service.clearMailtoHandler() + } webViewPool.setNeverHibernate(service.hibernationPolicyEffective == .never, for: serviceID) // A policy change may add or drop the only per-service timer, so re-gate // the sweep; drop any pending grace teardown so a switch away from @@ -1499,6 +1723,11 @@ final class AppState { func lock() { guard appLockEnabled else { return } isLocked = true + mailHandlerApprovals.removeAll() + // Hide mail account names and status copy while locked, but leave the + // request at the head of the queue so unlock presents it again. + pendingMailLink = nil + mailLinkError = nil // Don't leave a capture prompt hanging over the lock screen. drainAllMediaRequests() } diff --git a/Chorus/App/ChorusApp.swift b/Chorus/App/ChorusApp.swift index c180fcf..343904c 100644 --- a/Chorus/App/ChorusApp.swift +++ b/Chorus/App/ChorusApp.swift @@ -17,7 +17,8 @@ struct ChorusApp: App { #endif init() { - _appState = State(initialValue: AppState()) + let state = AppState() + _appState = State(initialValue: state) #if canImport(Sparkle) updaterController = SPUStandardUpdaterController( startingUpdater: true, @@ -25,6 +26,10 @@ struct ChorusApp: App { userDriverDelegate: nil ) #endif + appDelegate.appState = state + appDelegate.onOpenURL = { [weak state] url in + state?.enqueueMailLink(url) + } } var body: some Scene { diff --git a/Chorus/App/ChorusAppDelegate.swift b/Chorus/App/ChorusAppDelegate.swift index 9be70ed..888f9ca 100644 --- a/Chorus/App/ChorusAppDelegate.swift +++ b/Chorus/App/ChorusAppDelegate.swift @@ -4,9 +4,20 @@ import AppKit /// `AppState.releasePagesForQuit`. @MainActor final class ChorusAppDelegate: NSObject, NSApplicationDelegate { - /// Set once the main window appears. Until then there are no pages to - /// release, and a quit goes straight through. + /// URL delivery stays outside the main scene so direct compose routes do + /// not bring the inbox window forward. + var onOpenURL: ((URL) -> Void)? + + func application(_ application: NSApplication, open urls: [URL]) { + for url in urls { onOpenURL?(url) } + } + + /// Available before the main window appears: URL delivery may already + /// have opened a floating draft. weak var appState: AppState? + var terminationReply: @MainActor (NSApplication, Bool) -> Void = { + $0.reply(toApplicationShouldTerminate: $1) + } /// The handoff runs once. The second `terminate` it triggers, and any quit /// asked for while it runs, must not start it again. @@ -57,7 +68,7 @@ final class ChorusAppDelegate: NSObject, NSApplicationDelegate { isReleasingPages = true Task { @MainActor in await appState.releasePagesForQuit() - sender.reply(toApplicationShouldTerminate: true) + terminationReply(sender, true) } return .terminateLater } diff --git a/Chorus/Info.plist b/Chorus/Info.plist index ed54277..01748a8 100644 --- a/Chorus/Info.plist +++ b/Chorus/Info.plist @@ -5,6 +5,24 @@ NSUserNotificationsUsageDescription Chorus forwards notifications from your web services to macOS. + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + Mail links + LSHandlerRank + Alternate + CFBundleURLSchemes + + mailto + + + + diff --git a/Chorus/Models/Schema/ChorusSchema.swift b/Chorus/Models/Schema/ChorusSchema.swift index a2b6272..1497c3d 100644 --- a/Chorus/Models/Schema/ChorusSchema.swift +++ b/Chorus/Models/Schema/ChorusSchema.swift @@ -389,7 +389,103 @@ enum ChorusSchemaV1_5_13: VersionedSchema { } } -// MARK: - Current (1.5.19: SpaceServiceLink's two ends become optional) +// MARK: - V1.5.19 (optional SpaceServiceLink ends, before mail handlers) + +enum ChorusSchemaV1_5_19: VersionedSchema { + nonisolated(unsafe) static let versionIdentifier = Schema.Version(1, 5, 19) + + static var models: [any PersistentModel.Type] { + [ServiceInstance.self, Space.self, SpaceServiceLink.self, ChorusSchemaV1_5_11.AppPreferences.self] + } + + @Model + final class ServiceInstance { + @Attribute(.unique) var id: UUID + var label: String + var url: String + var customIconData: Data? + var fetchedIconData: Data? + var faviconFetchedAt: Date? + var catalogEntryID: String? + var isMuted: Bool + var showBadge: Bool + var neverHibernate: Bool + var userAgent: String? + var dataStoreIdentifier: UUID + var pageZoom: Double? + var osNotificationsEnabled: Bool? + var customCSS: String? + var forceDarkMode: Bool? + var darkModeRaw: String? + var cameraPolicyRaw: String? + var microphonePolicyRaw: String? + var openExternalLinksInApp: Bool? + var stayActiveInBackground: Bool? + var hasSeenPasskeyNotice: Bool? + var hibernationPolicyRaw: String? + var hibernateAfterMinutes: Int? + + @Relationship(deleteRule: .cascade, inverse: \SpaceServiceLink.service) + var spaceLinks: [SpaceServiceLink] + + var createdAt: Date + var lastAccessedAt: Date + + init(id: UUID = UUID(), label: String = "", url: String = "") { + self.id = id + self.label = label + self.url = url + self.isMuted = false + self.showBadge = true + self.neverHibernate = false + self.dataStoreIdentifier = UUID() + self.spaceLinks = [] + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } + + @Model + final class Space { + @Attribute(.unique) var id: UUID + var name: String + var emoji: String + var sortOrder: Int + var isMuted: Bool? + + @Relationship(deleteRule: .cascade, inverse: \SpaceServiceLink.space) + var serviceLinks: [SpaceServiceLink] + + var createdAt: Date + + init(id: UUID = UUID(), name: String = "", emoji: String = "", sortOrder: Int = 0) { + self.id = id + self.name = name + self.emoji = emoji + self.sortOrder = sortOrder + self.serviceLinks = [] + self.createdAt = Date() + } + } + + @Model + final class SpaceServiceLink { + @Attribute(.unique) var id: UUID + var sortOrder: Int + + @Relationship var space: Space? + @Relationship var service: ServiceInstance? + + init(id: UUID = UUID(), sortOrder: Int = 0, space: Space? = nil, service: ServiceInstance? = nil) { + self.id = id + self.sortOrder = sortOrder + self.space = space + self.service = service + } + } +} + +// MARK: - Current (1.5.29: optional approved mail-handler fields) // // Reuses the live top-level model types — today's model files are the single // source of truth for the shipping shape. When a stored property changes, freeze @@ -400,11 +496,9 @@ enum ChorusSchemaVCurrent: VersionedSchema { // NOTE: `versionIdentifier` is a schema-SHAPE label, not the app's marketing // version. Bump it only when the stored shape changes — and to a value not // already used by a different shape (do not blindly mint the next marketing - // number). (1,5,13) now belongs to the frozen `ChorusSchemaV1_5_13`, which - // is the shape 1.5.13 through 1.5.18 shipped; this one is (1,5,19) because - // making `SpaceServiceLink.space` and `.service` optional is a new shape and - // 1.5.19 is where it first ships. - nonisolated(unsafe) static let versionIdentifier = Schema.Version(1, 5, 19) + // number). (1,5,19) belongs to the frozen pre-mail-handler shape. 1.5.29 + // is the proposed release for this unshipped shape; confirm it at release. + nonisolated(unsafe) static let versionIdentifier = Schema.Version(1, 5, 29) static var models: [any PersistentModel.Type] { [ServiceInstance.self, Space.self, SpaceServiceLink.self, AppPreferences.self] @@ -415,7 +509,7 @@ enum ChorusSchemaVCurrent: VersionedSchema { enum ChorusMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [ChorusSchemaV1_5_11.self, ChorusSchemaV1_5_12.self, ChorusSchemaV1_5_13.self, ChorusSchemaVCurrent.self] + [ChorusSchemaV1_5_11.self, ChorusSchemaV1_5_12.self, ChorusSchemaV1_5_13.self, ChorusSchemaV1_5_19.self, ChorusSchemaVCurrent.self] } static var stages: [MigrationStage] { @@ -424,11 +518,13 @@ enum ChorusMigrationPlan: SchemaMigrationPlan { .lightweight(fromVersion: ChorusSchemaV1_5_11.self, toVersion: ChorusSchemaV1_5_12.self), // 1.5.12 → 1.5.13: adds ServiceInstance.hibernationPolicyRaw + hibernateAfterMinutes (optional). .lightweight(fromVersion: ChorusSchemaV1_5_12.self, toVersion: ChorusSchemaV1_5_13.self), - // 1.5.13 → current: relaxes SpaceServiceLink.space and .service to + // 1.5.13 → 1.5.19: relaxes SpaceServiceLink.space and .service to // optional. Lightweight because it only drops a constraint: every // existing row already has both ends set, so nothing has to be // rewritten and no row can fail to satisfy the looser shape. - .lightweight(fromVersion: ChorusSchemaV1_5_13.self, toVersion: ChorusSchemaVCurrent.self), + .lightweight(fromVersion: ChorusSchemaV1_5_13.self, toVersion: ChorusSchemaV1_5_19.self), + // 1.5.19 → current: adds three optional mail-handler fields. + .lightweight(fromVersion: ChorusSchemaV1_5_19.self, toVersion: ChorusSchemaVCurrent.self), ] } } diff --git a/Chorus/Models/ServiceInstance.swift b/Chorus/Models/ServiceInstance.swift index 1f6ba76..4e36055 100644 --- a/Chorus/Models/ServiceInstance.swift +++ b/Chorus/Models/ServiceInstance.swift @@ -18,6 +18,16 @@ enum HibernationPolicy: String, CaseIterable { case followGlobal, never, immediate, after } +/// A service's discovered mail-handler declaration. Template and origin are only +/// meaningful together: the template is trusted only while it resolves to the +/// origin that declared it, so code passes them as one value. The opt-out flag +/// stays separate storage — it is a user preference that survives a declaration +/// refresh, and SwiftData migrates the three fields independently. +struct MailtoHandler: Equatable { + let template: String + let declaringOrigin: String +} + /// Pure policy → idle-threshold math for auto-hibernation, kept free of AppState /// and WebKit so the truth table is unit-testable in isolation. Mirrors /// `MediaPermissionResolver`. @@ -225,6 +235,13 @@ final class ServiceInstance { /// which clamps to 1...120 (nil → 10). Ignored for the other policies. var hibernateAfterMinutes: Int? + /// Standards-declared mailto handler for this isolated service instance. + /// All fields are optional for lightweight migration. A nil enabled flag is + /// treated as enabled because newly discovered handlers are opt-out. + var mailtoHandlerTemplate: String? + var mailtoHandlerOrigin: String? + var mailtoHandlerEnabled: Bool? + @Relationship(deleteRule: .cascade, inverse: \SpaceServiceLink.service) var spaceLinks: [SpaceServiceLink] @@ -317,6 +334,30 @@ final class ServiceInstance { min(120, max(1, hibernateAfterMinutes ?? 10)) } + var mailtoHandlerEnabledEffective: Bool { mailtoHandlerEnabled ?? true } + + /// The stored declaration as one value, nil unless both stored fields are set. + var mailtoHandler: MailtoHandler? { + guard let mailtoHandlerTemplate, let mailtoHandlerOrigin else { return nil } + return MailtoHandler(template: mailtoHandlerTemplate, declaringOrigin: mailtoHandlerOrigin) + } + + /// Records a discovered declaration. A first discovery defaults to enabled; + /// an explicit opt-out survives a provider updating its declaration. + func rememberMailtoHandler(_ handler: MailtoHandler) { + mailtoHandlerTemplate = handler.template + mailtoHandlerOrigin = handler.declaringOrigin + if mailtoHandlerEnabled == nil { mailtoHandlerEnabled = true } + } + + /// Drops the declaration and the opt-out with it, matching a failed + /// revalidation: the service no longer offers mail handling at all. + func clearMailtoHandler() { + mailtoHandlerTemplate = nil + mailtoHandlerOrigin = nil + mailtoHandlerEnabled = nil + } + /// True if this service is muted directly, or via any space it belongs to /// (muting a space cascades to its members). Use this when the model object /// is already in hand — it avoids AppState's fetch-all-then-scan lookup. @@ -356,7 +397,10 @@ final class ServiceInstance { openExternalLinksInApp: Bool? = nil, stayActiveInBackground: Bool? = nil, hibernationPolicyRaw: String? = nil, - hibernateAfterMinutes: Int? = nil + hibernateAfterMinutes: Int? = nil, + mailtoHandlerTemplate: String? = nil, + mailtoHandlerOrigin: String? = nil, + mailtoHandlerEnabled: Bool? = nil ) { self.id = id self.label = label @@ -382,6 +426,9 @@ final class ServiceInstance { self.stayActiveInBackground = stayActiveInBackground self.hibernationPolicyRaw = hibernationPolicyRaw self.hibernateAfterMinutes = hibernateAfterMinutes + self.mailtoHandlerTemplate = mailtoHandlerTemplate + self.mailtoHandlerOrigin = mailtoHandlerOrigin + self.mailtoHandlerEnabled = mailtoHandlerEnabled self.spaceLinks = [] self.createdAt = Date() self.lastAccessedAt = Date() diff --git a/Chorus/Services/DataStoreManager.swift b/Chorus/Services/DataStoreManager.swift index cb4d4c1..1aa2aca 100644 --- a/Chorus/Services/DataStoreManager.swift +++ b/Chorus/Services/DataStoreManager.swift @@ -9,6 +9,11 @@ import WebKit @MainActor final class DataStoreManager { private var cache: [UUID: WKWebsiteDataStore] = [:] + private let makeStore: (UUID) -> WKWebsiteDataStore + + init(makeStore: @escaping (UUID) -> WKWebsiteDataStore = { WKWebsiteDataStore(forIdentifier: $0) }) { + self.makeStore = makeStore + } func dataStore(for instance: ServiceInstance) -> WKWebsiteDataStore { dataStore(forIdentifier: instance.dataStoreIdentifier) @@ -18,7 +23,7 @@ final class DataStoreManager { if let cached = cache[identifier] { return cached } - let store = WKWebsiteDataStore(forIdentifier: identifier) + let store = makeStore(identifier) Self.disableTrackingPrevention(on: store) cache[identifier] = store return store diff --git a/Chorus/Services/MailLinkRouter.swift b/Chorus/Services/MailLinkRouter.swift new file mode 100644 index 0000000..a0fee29 --- /dev/null +++ b/Chorus/Services/MailLinkRouter.swift @@ -0,0 +1,286 @@ +import Foundation + +/// A snapshot of a service's persisted mail declaration, revalidated on routing. +struct MailLinkRegistration: Equatable, Sendable { + let account: MailLinkAccount + var serviceID: UUID { account.serviceID } + let serviceURL: String + let handlerTemplate: String + let declaringOrigin: String + let enabled: Bool + + /// A copy that revalidates against a different stored service URL. + func withServiceURL(_ serviceURL: String) -> MailLinkRegistration { + MailLinkRegistration( + account: account, + serviceURL: serviceURL, + handlerTemplate: handlerTemplate, + declaringOrigin: declaringOrigin, + enabled: enabled + ) + } +} + +/// Transient sending-account identity and display context, also used by the chooser. +struct MailLinkAccount: Identifiable, Equatable, Sendable { + var id: UUID { serviceID } + let serviceID: UUID + let label: String + let providerLabel: String? + let spaceLabels: [String] + + /// The line under the label in the account chooser: provider and space + /// context, with the account's own label dropped when it would repeat. + var chooserSubtitle: String { + let pieces = ([providerLabel].compactMap { $0 } + spaceLabels) + .filter { !$0.isEmpty && $0 != label } + return pieces.joined(separator: " · ") + } +} + +struct MailLinkDestination: Equatable, Sendable { + let serviceID: UUID + let url: URL +} + +enum MailLinkRouteOutcome: Equatable, Sendable { + case noEligibleService + case open(MailLinkDestination) + case choose([MailLinkAccount]) + case rejectInvalidRequest +} + +/// Serialises short-lived compatibility probes so discovering handlers cannot +/// double the app's live web-view count all at once during space preload. +struct MailHandlerProbeQueue: Sendable { + private var waiting: [UUID] = [] + private(set) var active: UUID? + + mutating func enqueue(_ id: UUID) -> Bool { + guard active != id, !waiting.contains(id) else { return false } + waiting.append(id) + return true + } + + mutating func beginNext() -> UUID? { + guard active == nil, !waiting.isEmpty else { return nil } + let id = waiting.removeFirst() + active = id + return id + } + + mutating func finish(_ id: UUID) -> Bool { + if active == id { + active = nil + return true + } + let oldCount = waiting.count + waiting.removeAll { $0 == id } + return waiting.count != oldCount + } +} + +struct MailLinkRequestQueue: Sendable { + private(set) var requests: [URL] = [] + var isLocked = false + + var current: URL? { isLocked ? nil : requests.first } + + mutating func enqueue(_ url: URL) { requests.append(url) } + + @discardableResult + mutating func completeCurrent() -> URL? { + guard !requests.isEmpty else { return nil } + return requests.removeFirst() + } +} + +/// The trust and routing seam for mail links. Callers provide current service +/// registrations; this type validates them afresh and never knows providers. +enum MailLinkRouter { + static func registration( + account: MailLinkAccount, + serviceURL: String, + protocolName: String, + handlerTemplate: String, + declaringPageURL: String, + isMainFrame: Bool, + enabled: Bool, + declaringSecurityOrigin: Origin? = nil + ) -> MailLinkRegistration? { + guard isMainFrame, + protocolName.caseInsensitiveCompare("mailto") == .orderedSame, + let declaringURL = URL(string: declaringPageURL), + let declaringOrigin = declaringSecurityOrigin ?? Origin(declaringURL), + let resolvedTemplate = resolveTemplate(handlerTemplate, against: declaringURL), + let handlerURL = URL(string: resolvedTemplate), + validatedHandlerOrigin( + template: handlerTemplate, resolvedURL: handlerURL, + serviceURL: serviceURL, declaringOrigin: declaringOrigin.serialized + ) != nil + else { return nil } + + return MailLinkRegistration( + account: account, + serviceURL: serviceURL, + handlerTemplate: resolvedTemplate, + declaringOrigin: declaringOrigin.serialized, + enabled: enabled + ) + } + + /// Registration for `service`'s own declaration, using the minimal account + /// shape routing needs before an account becomes a chooser candidate. + static func registration( + for service: ServiceInstance, + protocolName: String, + handlerTemplate: String, + declaringPageURL: String, + isMainFrame: Bool, + enabled: Bool, + declaringSecurityOrigin: Origin? = nil + ) -> MailLinkRegistration? { + registration( + account: MailLinkAccount(serviceID: service.id, label: service.label, providerLabel: nil, spaceLabels: []), + serviceURL: service.url, + protocolName: protocolName, + handlerTemplate: handlerTemplate, + declaringPageURL: declaringPageURL, + isMainFrame: isMainFrame, + enabled: enabled, + declaringSecurityOrigin: declaringSecurityOrigin + ) + } + + static func route(_ request: URL, registrations: [MailLinkRegistration]) -> MailLinkRouteOutcome { + guard request.scheme?.caseInsensitiveCompare("mailto") == .orderedSame else { + return .rejectInvalidRequest + } + + var seen = Set() + let eligible = registrations.filter { registration in + // Mark the service seen only after it proves eligible: an invalid + // registration must not consume the slot its own valid one needs. + guard !seen.contains(registration.serviceID), + validatedDestination(for: request, registration: registration) != nil + else { return false } + seen.insert(registration.serviceID) + return true + } + guard !eligible.isEmpty else { return .noEligibleService } + if eligible.count == 1, + let destination = validatedDestination(for: request, registration: eligible[0]) { + return .open(destination) + } + return .choose(eligible.map(\.account)) + } + + static func destination( + for request: URL, + serviceID: UUID, + registrations: [MailLinkRegistration] + ) -> MailLinkDestination? { + guard request.scheme?.caseInsensitiveCompare("mailto") == .orderedSame, + let registration = registrations.first(where: { $0.serviceID == serviceID }) + else { return nil } + return validatedDestination(for: request, registration: registration) + } + + private static func validatedDestination( + for request: URL, + registration: MailLinkRegistration + ) -> MailLinkDestination? { + guard registration.enabled, + let handlerURL = URL(string: registration.handlerTemplate), + let handlerOrigin = validatedHandlerOrigin( + template: registration.handlerTemplate, resolvedURL: handlerURL, + serviceURL: registration.serviceURL, declaringOrigin: registration.declaringOrigin + ) + else { return nil } + + let encodedRequest = percentEncodeForSubstitution(request.absoluteString) + let destinationString = registration.handlerTemplate.replacingOccurrences(of: "%s", with: encodedRequest) + guard let destination = URL(string: destinationString), + Origin(destination) == handlerOrigin + else { return nil } + return MailLinkDestination(serviceID: registration.serviceID, url: destination) + } + + /// Declaration acceptance supplies the raw template and its resolved URL; + /// routing supplies the persisted absolute template. Metadata and boundary- + /// specific frame, protocol and enablement checks stay with their callers. + private static func validatedHandlerOrigin( + template: String, + resolvedURL: URL, + serviceURL: String, + declaringOrigin: String + ) -> Origin? { + guard placeholderCount(in: template) == 1, + resolvedURL.scheme?.lowercased() == "https", + let handlerOrigin = Origin(resolvedURL), + let service = URL(string: serviceURL), + let serviceOrigin = Origin(service), + handlerOrigin.serialized == declaringOrigin, + serviceOrigin.serialized == declaringOrigin + else { return nil } + return handlerOrigin + } + + private static func placeholderCount(in string: String) -> Int { + string.components(separatedBy: "%s").count - 1 + } + + /// Foundation treats `%s` as malformed percent encoding in some URL paths. + /// Resolve with a URL-safe sentinel, then put the protocol placeholder back. + private static func resolveTemplate(_ template: String, against base: URL) -> String? { + var sentinel = "CHORUS_MAILTO_PLACEHOLDER_8E7A1D" + // Resolution can inherit path text from the declaring page as well. + while template.contains(sentinel) || base.absoluteString.contains(sentinel) { + sentinel += "_" + } + let protected = template.replacingOccurrences(of: "%s", with: sentinel) + guard let components = URLComponents(string: protected) else { return nil } + if components.scheme == nil { + // A relative path's first segment cannot contain a raw colon. + // Older Foundation parsers otherwise accept malformed scheme-like input. + let firstSegment = protected.prefix { $0 != "/" && $0 != "?" && $0 != "#" } + guard !firstSegment.contains(":") else { return nil } + } + guard let resolved = URL(string: protected, relativeTo: base)?.absoluteURL.absoluteString else { return nil } + return resolved.replacingOccurrences(of: sentinel, with: "%s") + } + + private static func percentEncodeForSubstitution(_ string: String) -> String { + let unreserved = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".utf8) + return string.utf8.map { byte in + unreserved.contains(byte) ? String(UnicodeScalar(byte)) : String(format: "%%%02X", byte) + }.joined() + } + +} + +/// Normalized origin components; callers retain their own trust policy. +struct Origin: Equatable { + let scheme: String + let host: String + let port: Int + + init?(_ url: URL) { + guard let scheme = url.scheme?.lowercased(), + let host = url.host, !host.isEmpty, + scheme == "https" || scheme == "http" + else { return nil } + self.init(scheme: scheme, host: host, port: url.port) + } + + init(scheme: String, host: String, port: Int?) { + self.scheme = scheme.lowercased() + self.host = host.lowercased() + self.port = port ?? (scheme.lowercased() == "https" ? 443 : 80) + } + + var serialized: String { + let defaultPort = scheme == "https" ? 443 : 80 + return port == defaultPort ? "\(scheme)://\(host)" : "\(scheme)://\(host):\(port)" + } +} diff --git a/Chorus/Services/SetupArchive.swift b/Chorus/Services/SetupArchive.swift index 1f55e00..20b10f3 100644 --- a/Chorus/Services/SetupArchive.swift +++ b/Chorus/Services/SetupArchive.swift @@ -80,10 +80,14 @@ struct SetupArchive: Codable, Equatable { /// person on a Mac, not carried in a file. A file that could grant camera /// access to an address of its choosing, under a familiar name and icon, /// would be a way in. An imported service asks, as a new one does. + /// - `mailtoHandlerTemplate`, `mailtoHandlerOrigin`, `mailtoHandlerEnabled`: + /// declarations belong to the signed-in instance, not a portable setup. + /// A new instance must discover its own handler. static let excludedServiceFields: Set = [ "id", "dataStoreIdentifier", "fetchedIconData", "faviconFetchedAt", "forceDarkMode", "hasSeenPasskeyNotice", "createdAt", "lastAccessedAt", "cameraPolicyRaw", "microphonePolicyRaw", + "mailtoHandlerTemplate", "mailtoHandlerOrigin", "mailtoHandlerEnabled", ] // MARK: - Limits diff --git a/Chorus/Services/UserScriptManager.swift b/Chorus/Services/UserScriptManager.swift index dc7fc5b..613d6e3 100644 --- a/Chorus/Services/UserScriptManager.swift +++ b/Chorus/Services/UserScriptManager.swift @@ -12,6 +12,11 @@ struct NotificationPayload: Codable { @MainActor final class UserScriptManager { private var messageHandlers: [UUID: NotificationMessageHandler] = [:] + private var mailHandlerMessageHandlers: [ObjectIdentifier: MailHandlerMessageHandler] = [:] + + /// Receives untrusted page declarations. The owner must perform all origin, + /// protocol, frame, and template validation before persisting one. + var onMailHandlerDeclaration: ((MailHandlerDeclaration) -> Void)? var isServiceMuted: (@Sendable (UUID) -> Bool)? /// Per-service "forward notifications to macOS" flag. Defaults to true when @@ -60,6 +65,43 @@ final class UserScriptManager { ) controller.add(handler, name: "chorusNotification") messageHandlers[instance.id] = handler + + installMailHandler(for: instance.id, on: controller) + } + + /// Narrow setup for an invisible compatibility probe. It shares the service's + /// website data store but installs only standards-discovery scripts: no page + /// notifications, CSS, theming, cookie actions, or focus overrides. + func configureMailHandlerDiscovery( + for instanceID: UUID, + on controller: WKUserContentController + ) { + installMailHandler(for: instanceID, on: controller) + installMailDiscoveryScripts(on: controller) + } + + private func installMailDiscoveryScripts(on controller: WKUserContentController) { + controller.addUserScript(WKUserScript( + source: Self.makeMailHandlerRegistrationScript(), + injectionTime: .atDocumentStart, + forMainFrameOnly: true + )) + controller.addUserScript(WKUserScript( + source: Self.makeManifestDiscoveryScript(), + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + )) + } + + private func installMailHandler( + for instanceID: UUID, + on controller: WKUserContentController + ) { + let handler = MailHandlerMessageHandler(serviceID: instanceID) { [weak self] declaration in + self?.onMailHandlerDeclaration?(declaration) + } + controller.add(handler, name: "chorusMailHandler") + mailHandlerMessageHandlers[ObjectIdentifier(controller)] = handler } /// Adds all user scripts. Safe to call again after `removeAllUserScripts()` @@ -81,6 +123,13 @@ final class UserScriptManager { ) controller.addUserScript(userScript) + // Passive declarations need no extra page/manifest load. Explicit + // compatibility discovery installs the manifest script separately. + controller.addUserScript(WKUserScript( + source: Self.makeMailHandlerRegistrationScript(), + injectionTime: .atDocumentStart, forMainFrameOnly: true + )) + // Page Visibility override — makes preloaded/off-screen views report as // "visible" so services that only write their unread count into the // title while visible (WhatsApp, Messenger, Discord, …) still surface it @@ -195,6 +244,80 @@ final class UserScriptManager { func removeHandler(for instanceID: UUID) { messageHandlers.removeValue(forKey: instanceID) + mailHandlerMessageHandlers = mailHandlerMessageHandlers.filter { $0.value.serviceID != instanceID } + } + + /// Releases the explicit strong reference held for a short-lived probe. + /// The controller's own message-handler reference is removed by its owner. + func removeMailHandler(on controller: WKUserContentController) { + mailHandlerMessageHandlers.removeValue(forKey: ObjectIdentifier(controller))?.onDeclaration = nil + } + + nonisolated static func makeWindowCloseInterceptionScript(handlerName: String = "chorusMailHandler") -> String { + """ + (function() { + var originalClose = window.close; + window.close = function() { + try { + window.webkit.messageHandlers[\(jsStringLiteral(handlerName))].postMessage({ windowClose: true }); + } catch (e) {} + return originalClose.apply(this, arguments); + }; + })(); + """ + } + + nonisolated static func makeMailHandlerRegistrationScript() -> String { + """ + (function() { + function report(protocol, url) { + try { + window.webkit.messageHandlers.chorusMailHandler.postMessage({ + protocol: String(protocol), template: String(url) + }); + } catch (e) {} + } + var original = navigator.registerProtocolHandler; + if (typeof original === 'function') { + navigator.registerProtocolHandler = function(protocol, url) { + report(protocol, url); + return original.apply(this, arguments); + }; + } else { + navigator.registerProtocolHandler = function(protocol, url) { + if (String(protocol).toLowerCase() !== 'mailto') { + throw new DOMException('Unsupported protocol', 'NotSupportedError'); + } + report(protocol, url); + }; + } + })(); + """ + } + + nonisolated static func makeManifestDiscoveryScript() -> String { + """ + (function() { + var link = document.querySelector('link[rel~="manifest"]'); + if (!link || !link.href) return; + fetch(link.href, { credentials: 'include' }).then(function(response) { + if (!response.ok) return null; + var base = response.url; + return response.json().then(function(manifest) { return { manifest: manifest, base: base }; }); + }).then(function(result) { + if (!result || !Array.isArray(result.manifest.protocol_handlers)) return; + result.manifest.protocol_handlers.forEach(function(entry) { + if (!entry || typeof entry.protocol !== 'string' || typeof entry.url !== 'string') return; + try { + window.webkit.messageHandlers.chorusMailHandler.postMessage({ + protocol: entry.protocol, + template: new URL(entry.url, result.base).href + }); + } catch (e) {} + }); + }).catch(function() {}); + })(); + """ } /// Encodes a Swift string as a JS string literal (quotes included) so it can @@ -576,6 +699,57 @@ final class NotificationMessageHandler: NSObject, WKScriptMessageHandler, @unche } } +/// One standards declaration a page sent over the discovery bridge. The bridge +/// reports what the frame observed; trust decisions stay with the router. +struct MailHandlerDeclaration { + let serviceID: UUID + let protocolName: String + let handlerTemplate: String + let declaringPageURL: URL + let isMainFrame: Bool + var securityOrigin: Origin? +} + +/// Delivers bridge messages without deciding trust itself: the router validates +/// the declaration, its frame, and the destination before persistence. +final class MailHandlerMessageHandler: NSObject, WKScriptMessageHandler, @unchecked Sendable { + let serviceID: UUID + var onDeclaration: (@MainActor (MailHandlerDeclaration) -> Void)? + + init( + serviceID: UUID, + onDeclaration: @escaping @MainActor (MailHandlerDeclaration) -> Void + ) { + self.serviceID = serviceID + self.onDeclaration = onDeclaration + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard message.name == "chorusMailHandler", + let body = message.body as? [String: Any], + let proto = body["protocol"] as? String, + let template = body["template"] as? String, + let pageURL = message.frameInfo.request.url + else { return } + let origin = message.frameInfo.securityOrigin + guard origin.protocol == "https", !origin.host.isEmpty else { return } + let declaration = MailHandlerDeclaration( + serviceID: serviceID, + protocolName: proto, + handlerTemplate: template, + declaringPageURL: pageURL, + isMainFrame: message.frameInfo.isMainFrame, + securityOrigin: Origin(scheme: origin.protocol, host: origin.host, port: origin.port == 0 ? nil : origin.port) + ) + MainActor.assumeIsolated { + onDeclaration?(declaration) + } + } +} + // MARK: - Custom CSS presets and per-service defaults /// Baked-in default CSS for known catalog services, plus the rule that decides diff --git a/Chorus/Utilities/UserAgentProvider.swift b/Chorus/Utilities/UserAgentProvider.swift index 39e090e..e8dec6e 100644 --- a/Chorus/Utilities/UserAgentProvider.swift +++ b/Chorus/Utilities/UserAgentProvider.swift @@ -7,6 +7,19 @@ enum UserAgentProvider { /// the installed WebKit engine. Validate provider behavior before each bump. static let safariDefault = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15" + /// Chromium identity for explicit Gmail discovery. Other services keep + /// their configured identity, including during sign-in redirects. + static let chromiumMailHandlerDiscovery = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + + static func usesGmailMailHandlerCompatibility(for url: URL) -> Bool { + url.scheme?.lowercased() == "https" && url.host?.lowercased() == "mail.google.com" + && (url.port == nil || url.port == 443) && url.user == nil && url.password == nil + } + + static func mailHandlerDiscovery(for url: URL, normalUserAgent: String) -> String { + usesGmailMailHandlerCompatibility(for: url) ? chromiumMailHandlerDiscovery : normalUserAgent + } + /// Mobile Safari (iOS) user agent used by a service's "Mobile view" toggle, /// so sites serve their phone/tablet web layout instead of the desktop one. /// Keep the iOS and `Version/N` tokens roughly current when bumping diff --git a/Chorus/Views/AddService/EditServiceSheet.swift b/Chorus/Views/AddService/EditServiceSheet.swift index 26e2e2c..ba69020 100644 --- a/Chorus/Views/AddService/EditServiceSheet.swift +++ b/Chorus/Views/AddService/EditServiceSheet.swift @@ -16,6 +16,7 @@ struct EditServiceSheet: View { @State private var hibernationPolicy: HibernationPolicy = .followGlobal @State private var hibernateAfterMinutes: Int = 10 @State private var mobileView: Bool = false + @State private var handlesMailLinks: Bool = true /// The service's own outside-link choice; nil follows the Settings default. @State private var openLinksInApp: Bool? = nil @AppStorage(OutsideLinkDefault.defaultsKey) private var linksOpenInChorusByDefault = false @@ -189,6 +190,19 @@ struct EditServiceSheet: View { } .help("Where a link opens when it points somewhere no Chorus service covers. A link that another service covers still switches to that service.") + if service.mailtoHandler != nil { + Toggle("Use for mail links", isOn: $handlesMailLinks) + .help("Offer this signed-in service when Chorus opens a mail link. Turning this off keeps the approved draft page.") + } + Button(appState.webViewPool.isDetectingMailHandler(for: service.id) + ? "Detecting mail links…" : "Save and detect mail links") { + saveEdits { appState.detectMailHandler(for: service.id) } + } + .disabled(appState.webViewPool.isDetectingMailHandler(for: service.id) + || label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Loads another copy of this service with the same sign-in, using Chrome’s browser identity only for Gmail. Chorus asks before using any draft page it finds.") + Toggle("Always appear active", isOn: $stayActive) .help("Keeps this service from showing you as away or idle while Chorus is in the background, so your status stays active even when you work in other apps. Useful for Microsoft Teams. May hold back some of this service's notifications, since it now thinks you're looking at it.") @@ -235,6 +249,7 @@ struct EditServiceSheet: View { hibernateAfterMinutes = service.hibernateAfterMinutesEffective mobileView = service.userAgent == UserAgentProvider.mobileSafari initialUserAgent = service.userAgent + handlesMailLinks = service.mailtoHandlerEnabledEffective openLinksInApp = service.openExternalLinksInApp stayActive = service.staysActiveInBackgroundEffective darkMode = service.darkMode @@ -340,7 +355,7 @@ struct EditServiceSheet: View { } } - private func saveEdits() { + private func saveEdits(onSaved: (() -> Void)? = nil) { if nativeBundleID != nil { saveNativeAppEdits() return @@ -400,6 +415,9 @@ struct EditServiceSheet: View { service.showBadge = badge // Read fresh at each link click, so no rebuild is needed. service.openExternalLinksInApp = openLinksInApp + if service.mailtoHandler != nil { + service.mailtoHandlerEnabled = handlesMailLinks + } service.stayActiveInBackground = stayActive // Pin a camera/mic policy only if the user actually changed it, so // opening the sheet to edit something else doesn't stop the service @@ -420,6 +438,7 @@ struct EditServiceSheet: View { appState.refreshBadgeState(for: service.id) } dismiss() + onSaved?() } } diff --git a/Chorus/Views/MainWindow/ContentView.swift b/Chorus/Views/MainWindow/ContentView.swift index 0c9c8d3..8f21a41 100644 --- a/Chorus/Views/MainWindow/ContentView.swift +++ b/Chorus/Views/MainWindow/ContentView.swift @@ -3,6 +3,7 @@ import SwiftData struct ContentView: View { @Environment(AppState.self) private var appState + @Environment(\.openWindow) private var openWindow /// Whether the hybrid layout's space strip carries names, read here because /// it sets the strip's width, and the service bar beside it has to start @@ -53,6 +54,9 @@ struct ContentView: View { // normal title-bar drag. .background(WindowMovableConfigurator(isMovable: !appState.railLayout.hasTopBar)) .background(TrafficLightsPositioner(bandHeight: ChorusCard.topBand)) + .onAppear { + appState.openMainWindow = { [openWindow] in openWindow(id: "main") } + } // Ask for macOS notification permission here, not in AppState.init: // requesting during App.init (before the scene exists) can fail with // "Notifications are not allowed for this application" and leave the app @@ -121,6 +125,7 @@ struct ContentView: View { } message: { name in Text("Chorus needs it for two things: to read \(name)'s unread count from the Dock, and to move \(name)'s window over the space its tab would take. It never reads what's inside \(name) or any other app. macOS asks next. You can turn it off any time in System Settings, under Privacy & Security, then Accessibility.") } + .modifier(MailLinkPresentation(appState: appState)) .sheet(isPresented: $state.isShowingStoreRecovery, onDismiss: { // Only quits when the user actually picked a backup. It has to // happen here rather than in the button: a quit requested while diff --git a/Chorus/Views/MainWindow/MailLinkPresentation.swift b/Chorus/Views/MainWindow/MailLinkPresentation.swift new file mode 100644 index 0000000..9e3de89 --- /dev/null +++ b/Chorus/Views/MainWindow/MailLinkPresentation.swift @@ -0,0 +1,165 @@ +import SwiftUI + +/// The production mail interaction wiring, also hosted without launch UI in tests. +struct MailLinkPresentation: ViewModifier { + let appState: AppState + @State private var displayedChoice: AppState.PendingMailLink? + @State private var choiceIsPresented = false + + func body(content: Content) -> some View { + let choice = displayedChoice + content.sheet(isPresented: Binding( + get: { !appState.isLocked && choiceIsPresented }, + set: { shown in + guard !shown, let choice, displayedChoice?.id == choice.id else { return } + choiceIsPresented = false + appState.cancelMailLink(choice.id) + } + ), onDismiss: { + // Retain the old identity until native dismissal finishes. A yield + // can merge hide/show into one update and cancel the next request. + guard let choice, displayedChoice?.id == choice.id else { return } + choiceIsPresented = false + displayedChoice = nil + showPendingChoice() + }) { + if !appState.isLocked, let choice { + MailServiceChooser(request: choice).environment(appState) + } + } + .onChange(of: appState.pendingMailLink?.id, initial: true) { + if let displayedChoice { + if displayedChoice.id != appState.pendingMailLink?.id { + choiceIsPresented = false + } + } else { + showPendingChoice() + } + } + .modifier(MailLinkErrorPresentation(appState: appState)) + .modifier(MailHandlerApprovalPresentation(appState: appState)) + } + + private func showPendingChoice() { + guard displayedChoice == nil, !appState.isLocked, + let choice = appState.pendingMailLink else { return } + displayedChoice = choice + choiceIsPresented = true + } +} + +/// Keeps the displayed error separate from the next queued interaction, so +/// SwiftUI can close one alert before presenting another. +struct MailLinkErrorPresentation: ViewModifier { + let appState: AppState + @State private var displayedError: AppState.MailLinkError? + + func body(content: Content) -> some View { + let error = displayedError + content.alert( + "Mail link unavailable", + isPresented: Binding( + get: { !appState.isLocked && displayedError != nil }, + set: { shown in + guard !shown, let error, displayedError?.id == error.id else { return } + displayedError = nil + appState.dismissMailLinkError(error.id) + } + ), + presenting: error + ) { _ in + // The presentation binding is the only completion path, including + // Return/Escape. Its captured identity makes late delivery harmless. + Button("OK", role: .cancel) {} + .keyboardShortcut(.defaultAction) + } message: { error in + Text(error.message) + } + .task(id: appState.mailLinkError?.id) { + displayedError = nil + // Give the dismissed presentation a separate SwiftUI update before + // making the next error visible, even when its message is identical. + await Task.yield() + guard !Task.isCancelled else { return } + displayedError = appState.mailLinkError + } + } +} + +private struct MailHandlerApprovalPresentation: ViewModifier { + let appState: AppState + @State private var displayed: AppState.MailHandlerApproval? + + func body(content: Content) -> some View { + let approval = displayed + content.alert("Use this service for mail links?", isPresented: Binding( + get: { !appState.isLocked && displayed != nil }, + set: { shown in + guard !shown, let approval, displayed?.id == approval.id else { return } + displayed = nil + appState.answerMailHandlerApproval(approval.id, allow: false) + } + ), presenting: approval) { approval in + Button("Allow") { appState.answerMailHandlerApproval(approval.id, allow: true) } + Button("Not now", role: .cancel) { appState.answerMailHandlerApproval(approval.id, allow: false) } + } message: { approval in + let path = URL(string: approval.handler.template.replacingOccurrences(of: "%s", with: "mail"))?.path ?? "" + Text("\(approval.serviceLabel) wants to open mail drafts at \(approval.handler.declaringOrigin)\(path).") + } + .task(id: appState.pendingMailHandlerApproval?.id) { + displayed = nil + await Task.yield() + guard !Task.isCancelled else { return } + displayed = appState.pendingMailHandlerApproval + } + } +} + +private struct MailServiceChooser: View { + @Environment(AppState.self) private var appState + let request: AppState.PendingMailLink + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Write email with") + .font(.headline) + Text("Choose the signed-in service that should open this draft.") + .foregroundStyle(.secondary) + VStack(spacing: 8) { + ForEach(request.candidates) { candidate in + Button { + appState.chooseMailService(candidate.serviceID, requestID: request.id) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(candidate.label) + if !candidate.chooserSubtitle.isEmpty { + Text(candidate.chooserSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(10) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(candidate.chooserSubtitle.isEmpty + ? candidate.label + : "\(candidate.label), \(candidate.chooserSubtitle)") + } + } + HStack { + Spacer() + Button("Cancel", role: .cancel) { + appState.cancelMailLink(request.id) + } + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 420) + } +} diff --git a/Chorus/Views/WebView/MailComposeWindowSession.swift b/Chorus/Views/WebView/MailComposeWindowSession.swift new file mode 100644 index 0000000..9d05f2d --- /dev/null +++ b/Chorus/Views/WebView/MailComposeWindowSession.swift @@ -0,0 +1,176 @@ +import AppKit +import WebKit + +/// Owns a floating draft with its own controller and browser delegates, +/// using the account's store and settings without replacing its inbox. +@MainActor +final class MailComposeWindowSession: NSObject, NSWindowDelegate { + let window: NSWindow + private let webView: WKWebView + private let coordinator: WebViewCoordinator + private let messageHandler: MailComposeMessageHandler + private let onClose: () -> Void + private let allowedOrigin: Origin + private var isFinished = false + private var isLocked = false + private var lockedWindows: [NSWindow] = [] + private var focusTask: Task? + + init( + dataStore: WKWebsiteDataStore, + userAgent: String, + title: String, + url: URL, + configuration: WKWebViewConfiguration? = nil, + coordinator: WebViewCoordinator? = nil, + loadPage: (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) }, + onMailRequest: ((URL) -> Void)? = nil, + onClose: @escaping () -> Void + ) { + let configuration = configuration ?? WKWebViewConfiguration() + configuration.websiteDataStore = dataStore + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + configuration.preferences.isElementFullscreenEnabled = true + let controller = configuration.userContentController + let messageHandler = MailComposeMessageHandler() + controller.add(messageHandler, name: "chorusMailComposer") + controller.addUserScript(WKUserScript( + source: UserScriptManager.makeWindowCloseInterceptionScript(handlerName: "chorusMailComposer"), + injectionTime: .atDocumentStart, forMainFrameOnly: false + )) + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = userAgent + let coordinator = coordinator ?? WebViewCoordinator() + // Child working pages need their own windows here: there is no tab strip + // in a floating composer, and closing a draft must not reload the inbox. + coordinator.opensServiceTabs = false + coordinator.fallbackURL = url + coordinator.onNavigationFinished = nil + coordinator.onHealthEvent = nil + coordinator.onTabOpened = nil + coordinator.onTabClosed = nil + coordinator.servicePage = { [weak webView] in webView } + if let onMailRequest { + coordinator.systemLinkHandler = { url in + if url.scheme?.lowercased() == "mailto" { onMailRequest(url) } + else { WebViewCoordinator.openExternally(url) } + } + } + self.webView = webView + self.coordinator = coordinator + self.messageHandler = messageHandler + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 720), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, defer: false + ) + self.onClose = onClose + allowedOrigin = Origin(scheme: url.scheme ?? "https", host: url.host ?? "", port: url.port) + super.init() + messageHandler.onMessage = { [weak self] message in self?.handleMessage(message) } + coordinator.onRootClosed = { [weak self] in self?.finish(closeWindow: true) } + webView.uiDelegate = coordinator + webView.navigationDelegate = coordinator + window.delegate = self + window.isReleasedWhenClosed = false + window.title = title + window.contentView = webView + window.center() + loadPage(webView, url) + } + + /// Account-owned pages that need the bounded quit handoff. + var liveWebViews: [WKWebView] { + isFinished ? [] : [webView] + coordinator.auxiliaryWebViews + } + + func show() { + guard !isFinished, !isLocked else { return } + cancelPendingFocus() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(webView) + focusTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled, let self, !self.isFinished, !self.isLocked, NSApp.isActive else { return } + self.window.makeKeyAndOrderFront(nil) + } + } + + func cancelPendingFocus() { + focusTask?.cancel() + focusTask = nil + } + + private var ownedWindows: [NSWindow] { + var seen = Set() + func descendants(_ window: NSWindow) -> [NSWindow] { + guard seen.insert(ObjectIdentifier(window)).inserted else { return [] } + return [window] + (window.childWindows ?? []).flatMap { descendants($0) } + } + return ([window] + coordinator.auxiliaryWindows + [webView.window].compactMap { $0 }).flatMap { descendants($0) } + } + + /// Hide drafts and all owned dialogs/popups, but keep their state alive. + func setLocked(_ locked: Bool) { + guard !isFinished, isLocked != locked else { return } + isLocked = locked + coordinator.isBrowserLocked = locked + cancelPendingFocus() + if locked { + lockedWindows = ownedWindows.filter(\.isVisible) + for window in ownedWindows { + window.contentView?.setAccessibilityHidden(true) + window.orderOut(nil) + } + } else { + let live = Set(ownedWindows.map(ObjectIdentifier.init)) + for window in ownedWindows { window.contentView?.setAccessibilityHidden(false) } + for window in lockedWindows where live.contains(ObjectIdentifier(window)) { + window.orderFront(nil) + } + lockedWindows.removeAll() + } + } + + private func handleMessage(_ message: WKScriptMessage) { + guard !isFinished, message.webView === webView, + message.name == "chorusMailComposer", + let body = message.body as? [String: Any], body["windowClose"] as? Bool == true else { return } + let origin = message.frameInfo.securityOrigin + let frameOrigin = Origin(scheme: origin.protocol, host: origin.host, port: origin.port == 0 ? nil : origin.port) + guard message.frameInfo.isMainFrame || frameOrigin == allowedOrigin else { return } + finish(closeWindow: true) + } + + func windowWillClose(_ notification: Notification) { finish(closeWindow: false) } + + private func finish(closeWindow: Bool) { + guard !isFinished else { return } + isFinished = true + cancelPendingFocus() + coordinator.onRootClosed = nil + coordinator.systemLinkHandler = nil + coordinator.servicePage = nil + coordinator.closeAuxiliaryWindows() + lockedWindows.removeAll() + webView.configuration.userContentController.removeScriptMessageHandler(forName: "chorusMailComposer") + webView.configuration.userContentController.removeAllUserScripts() + messageHandler.onMessage = nil + webView.stopLoading() + webView.uiDelegate = nil + webView.navigationDelegate = nil + window.delegate = nil + if closeWindow { window.close() } + window.contentView = nil + onClose() + } +} + +private final class MailComposeMessageHandler: NSObject, WKScriptMessageHandler, @unchecked Sendable { + var onMessage: (@MainActor (WKScriptMessage) -> Void)? + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + MainActor.assumeIsolated { onMessage?(message) } + } +} diff --git a/Chorus/Views/WebView/WebViewCoordinator.swift b/Chorus/Views/WebView/WebViewCoordinator.swift index 4361209..11b7b5d 100644 --- a/Chorus/Views/WebView/WebViewCoordinator.swift +++ b/Chorus/Views/WebView/WebViewCoordinator.swift @@ -52,11 +52,24 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK /// (`instanceID`), so AppState can honour that service's "open links in /// Chorus" choice. When nil the coordinator falls back to `NSWorkspace.open` /// directly. + var externalLinkHandler: ((URL, UUID?) -> Void)? + /// System-scheme handoff; injected only where the OS boundary is controlled. + var systemLinkHandler: ((URL) -> Void)? + var onRootClosed: (() -> Void)? + var isBrowserLocked = false + var opensServiceTabs = true + var auxiliaryWindows: [NSWindow] { popups.map(\.window) } var auxiliaryWebViews: [WKWebView] { popups.map(\.webView) + closingTabs.map(\.webView) } - var externalLinkHandler: ((URL, UUID?) -> Void)? + func closeAuxiliaryWindows() { + pendingOpenerReload?.cancel() + closePopups(popups.startIndex.. NonWebNavigationAction? { + guard let scheme = url.scheme?.lowercased(), nonWebSchemes.contains(scheme) else { return nil } + guard navigationType == .linkActivated else { return .cancel } + return .openSystem + } + /// Whether a URL may be handed to `NSWorkspace.open`. Only http/https and the /// curated `nonWebSchemes` qualify. /// @@ -179,10 +205,16 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK // user gesture, on repeat. Cancel either way so WebKit doesn't then // try to load the unsupported scheme; only a `.linkActivated` // navigation actually reaches the system handler. - if let scheme = url.scheme?.lowercased(), - Self.nonWebSchemes.contains(scheme) { - if navigationAction.navigationType == .linkActivated { - Self.openExternally(url) + if let action = Self.nonWebNavigationAction( + for: url, + navigationType: navigationAction.navigationType + ) { + switch action { + case .cancel: + break + case .openSystem: + if let systemLinkHandler { systemLinkHandler(url) } + else { Self.openExternally(url) } } return .cancel } @@ -583,6 +615,7 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { + guard !isBrowserLocked else { return nil } // If the new-window request is for the same service — e.g. Slack opening // a workspace via a target=_blank link — or is a clicked sign-in link // (Gmail's "Sign in" to accounts.google.com), load it in the existing @@ -633,7 +666,7 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK // A page the service opens for itself — a Canva design, a Figma file — // shows as a tab in the service's card, not in a loose window. - if let url = navigationAction.request.url, + if opensServiceTabs, let url = navigationAction.request.url, Self.opensAsTab( navigationType: navigationAction.navigationType, openerIsPopup: openerIndex != nil, @@ -955,6 +988,8 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK // The page called window.close() on itself — the shape an OAuth // popup takes when it finishes. closePopup(at: index, selfClosed: true) + } else { + onRootClosed?() } } @@ -978,6 +1013,7 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor ([URL]?) -> Void ) { + guard !isBrowserLocked else { completionHandler(nil); return } let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = parameters.allowsDirectories @@ -1046,8 +1082,9 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK /// the no-subject prompt), so a signed-in user clicking Send just saw nothing /// happen. Implementing all three restores the expected behaviour. /// - /// Match the SDK's optional witness exactly: newer SDKs annotate the - /// alert completion with MainActor; Xcode 16 imports it as Sendable. + /// Match the SDK's optional witness exactly. Xcode 27 annotates the alert + /// completion with MainActor; older SDKs import it as Sendable only. A + /// mismatch omits the Obj-C selector, so WebKit silently skips the alert. #if compiler(>=6.4) typealias JavaScriptAlertCompletion = @MainActor () -> Void #else @@ -1060,37 +1097,26 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping JavaScriptAlertCompletion ) { - AppLogger.webView.info("JS alert panel") - let alert = NSAlert() - alert.messageText = message - alert.addButton(withTitle: "OK") - let session = JSDialogSession(cancelValue: (), completionHandler) - present(alert, over: webView, session: session) { _ in () } + guard !isBrowserLocked else { completionHandler(); return } + WebViewDialogs.alert(message, over: webView, completion: completionHandler) } /// Presents a native OK / Cancel panel for `window.confirm()`. Returns `true` /// only when the user chooses OK; window-close-first resolves to `false`, the - /// same as clicking Cancel. See the alert method above for the `@MainActor` - /// witness requirement — it applies identically here. + /// same as clicking Cancel. Its completion is MainActor on every supported SDK. func webView( _ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor (Bool) -> Void ) { - AppLogger.webView.info("JS confirm panel") - let alert = NSAlert() - alert.messageText = message - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") - let session = JSDialogSession(cancelValue: false, completionHandler) - present(alert, over: webView, session: session) { $0 == .alertFirstButtonReturn } + guard !isBrowserLocked else { completionHandler(false); return } + WebViewDialogs.confirm(message, over: webView, completion: completionHandler) } /// Presents a native text-input panel for `window.prompt()`. Returns the /// entered text on OK, or nil on Cancel / window-close-first (which the page - /// reads as a dismissed prompt). See the alert method above for the - /// `@MainActor` witness requirement. + /// reads as a dismissed prompt). Match the SDK's MainActor completion. func webView( _ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, @@ -1098,43 +1124,8 @@ final class WebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WK initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor (String?) -> Void ) { - AppLogger.webView.info("JS text-input panel") - let alert = NSAlert() - alert.messageText = prompt - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") - - let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) - field.stringValue = defaultText ?? "" - alert.accessoryView = field - alert.window.initialFirstResponder = field - - let session = JSDialogSession(cancelValue: String?.none, completionHandler) - present(alert, over: webView, session: session) { response in - response == .alertFirstButtonReturn ? field.stringValue : nil - } - } - - /// Runs `alert` as a sheet on the web view's window when there is one, falling - /// back to an app-modal panel otherwise (e.g. an OAuth popup web view). The - /// session guarantees the page's completion handler fires exactly once — on a - /// button press or, for a sheet, if the host window closes first — mirroring - /// the file-picker path. `map` turns the modal response into the value the - /// page's handler expects. - private func present( - _ alert: NSAlert, - over webView: WKWebView, - session: JSDialogSession, - map: @escaping (NSApplication.ModalResponse) -> T - ) { - if let window = webView.window { - session.observeClose(of: window) - alert.beginSheetModal(for: window) { response in - session.finish(map(response)) - } - } else { - session.finish(map(alert.runModal())) - } + guard !isBrowserLocked else { completionHandler(nil); return } + WebViewDialogs.prompt(prompt, defaultText: defaultText, over: webView, completion: completionHandler) } // MARK: - Context Menu @@ -1798,46 +1789,3 @@ private final class FilePickerSession { completion(urls) } } - -/// Drives a JavaScript dialog (`alert` / `confirm` / `prompt`) to a single -/// completion. WebKit blocks the page's script until the handler fires exactly -/// once, so this guarantees it fires — on a button press or the host window -/// closing first — and never twice. `cancelValue` is what a window-close-first -/// resolves to (`()` for alert, `false` for confirm, nil for prompt). `@MainActor` -/// (hence Sendable) so the close observer can hold it. -@MainActor -private final class JSDialogSession { - private var completion: (@MainActor (T) -> Void)? - private var closeObserver: NSObjectProtocol? - private let cancelValue: T - - init(cancelValue: T, _ completion: @escaping @MainActor (T) -> Void) { - self.cancelValue = cancelValue - self.completion = completion - } - - /// Fires the completion with `cancelValue` if `window` closes before the sheet - /// resolves, releasing the page's blocked script instead of leaving it hung. - func observeClose(of window: NSWindow) { - closeObserver = NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, object: window, queue: .main - ) { [weak self] _ in - // The .main queue delivers this on the main thread, so assuming main - // isolation to reach the @MainActor method is safe here. - MainActor.assumeIsolated { self?.finish(self?.cancelValue) } - } - } - - /// Idempotent: the first call fires the handler and detaches the observer; - /// later calls are no-ops. `value` is nil only on the close path above, where - /// it falls back to `cancelValue`. - func finish(_ value: T?) { - guard let completion else { return } - self.completion = nil - if let closeObserver { - NotificationCenter.default.removeObserver(closeObserver) - self.closeObserver = nil - } - completion(value ?? cancelValue) - } -} diff --git a/Chorus/Views/WebView/WebViewDialogs.swift b/Chorus/Views/WebView/WebViewDialogs.swift new file mode 100644 index 0000000..2db2e4d --- /dev/null +++ b/Chorus/Views/WebView/WebViewDialogs.swift @@ -0,0 +1,85 @@ +import AppKit +import WebKit + +/// Presents provider dialogs without logging their contents. Both inbox and +/// standalone compose views must answer these; WebKit otherwise cancels them. +@MainActor +enum WebViewDialogs { + static func alert(_ message: String, over webView: WKWebView, completion: @escaping @MainActor () -> Void) { + let alert = NSAlert() + alert.messageText = message + alert.addButton(withTitle: "OK") + present(alert, over: webView, session: DialogSession(cancelValue: (), completion)) { _ in () } + } + + static func confirm(_ message: String, over webView: WKWebView, completion: @escaping @MainActor (Bool) -> Void) { + let alert = NSAlert() + alert.messageText = message + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + present(alert, over: webView, session: DialogSession(cancelValue: false, completion)) { + $0 == .alertFirstButtonReturn + } + } + + static func prompt(_ message: String, defaultText: String?, over webView: WKWebView, completion: @escaping @MainActor (String?) -> Void) { + let alert = NSAlert() + alert.messageText = message + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + field.stringValue = defaultText ?? "" + alert.accessoryView = field + alert.window.initialFirstResponder = field + present(alert, over: webView, session: DialogSession(cancelValue: String?.none, completion)) { + $0 == .alertFirstButtonReturn ? field.stringValue : nil + } + } + + private static func present( + _ alert: NSAlert, over webView: WKWebView, session: DialogSession, + map: @escaping (NSApplication.ModalResponse) -> T + ) { + if let window = webView.window { + session.observeClose(of: window) + alert.beginSheetModal(for: window) { session.finish(map($0)) } + } else { + session.finish(map(alert.runModal())) + } + } +} + +/// Resolves WebKit's blocked script exactly once, including when its host closes +/// before the sheet answers. The sheet retains this session until it completes. +@MainActor +private final class DialogSession { + private var completion: (@MainActor (T) -> Void)? + private var closeObserver: NSObjectProtocol? + private let cancelValue: T + + init(cancelValue: T, _ completion: @escaping @MainActor (T) -> Void) { + self.cancelValue = cancelValue + self.completion = completion + } + + func observeClose(of window: NSWindow) { + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: window, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.finish(self.cancelValue) + } + } + } + + func finish(_ value: T) { + guard let completion else { return } + self.completion = nil + if let closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + self.closeObserver = nil + } + completion(value) + } +} diff --git a/Chorus/Views/WebView/WebViewPool.swift b/Chorus/Views/WebView/WebViewPool.swift index 21b79b0..40a71d2 100644 --- a/Chorus/Views/WebView/WebViewPool.swift +++ b/Chorus/Views/WebView/WebViewPool.swift @@ -8,6 +8,19 @@ final class WebViewPool { private var webViews: [UUID: WKWebView] = [:] private var lastAccessTimes: [UUID: Date] = [:] private var coordinators: [UUID: WebViewCoordinator] = [:] + + /// Explicit standards discovery. Only Gmail gets a compatibility identity. + private var mailHandlerProbeQueue = MailHandlerProbeQueue() + private var mailHandlerProbeRequests: [UUID: (url: URL, instance: ServiceInstance, dataStore: WKWebsiteDataStore)] = [:] + private var activeMailHandlerProbe: ActiveMailHandlerProbe? + private let loadMailProbe: (WKWebView, URL) -> Void + private let mailProbeLifetime: Duration + private let mailProbeObservationWindow: Duration + var canProbeMailHandler: ((UUID, URL) -> Bool)? + var onMailHandlerProbeFailed: ((UUID) -> Void)? + private let loadMailComposer: (WKWebView, URL) -> Void + private var mailComposeWindows: [UUID: (serviceID: UUID, session: MailComposeWindowSession)] = [:] + private var suspendedURLs: [UUID: String] = [:] private var snapshots: [UUID: NSImage] = [:] /// Snapshot ids in the order they were stored, oldest first. Drives the cap @@ -161,6 +174,9 @@ final class WebViewPool { /// The app-wide download list. Passed to each coordinator. var downloadCenter: DownloadCenter? + /// The OS boundary for clicked system links. Nil uses NSWorkspace. + var systemLinkHandler: ((URL) -> Void)? + /// Wired up at AppState init and applied to every coordinator. Resolves a /// camera/microphone capture request to a WebKit decision from the persisted /// per-service policy. The pool is a pass-through — it owns neither the policy @@ -224,8 +240,16 @@ final class WebViewPool { init( dataStoreManager: DataStoreManager, userScriptManager: UserScriptManager, - contentBlocker: ContentBlockerManager + contentBlocker: ContentBlockerManager, + loadMailProbe: @escaping (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) }, + mailProbeLifetime: Duration = .seconds(20), + mailProbeObservationWindow: Duration = .seconds(3), + loadMailComposer: @escaping (WKWebView, URL) -> Void = { view, url in view.load(URLRequest(url: url)) } ) { + self.loadMailProbe = loadMailProbe + self.mailProbeLifetime = mailProbeLifetime + self.mailProbeObservationWindow = mailProbeObservationWindow + self.loadMailComposer = loadMailComposer self.dataStoreManager = dataStoreManager self.userScriptManager = userScriptManager self.contentBlocker = contentBlocker @@ -288,6 +312,85 @@ final class WebViewPool { return webView } + /// Runs a serial, invisible compatibility load with the service's existing + /// session. The page must still make a standards declaration; the probe does + /// not infer providers or compose URLs. Duplicate requests are coalesced. + func probeMailHandler(for instance: ServiceInstance, userRequested: Bool = false) { + guard userRequested, instance.nativeAppBundleID == nil, instance.mailtoHandlerEnabledEffective, + let url = URL(string: instance.url), url.scheme?.lowercased() == "https", + Origin(url) != nil else { return } + let id = instance.id + guard mailHandlerProbeQueue.enqueue(id) else { return } + mailHandlerProbeRequests[id] = (url, instance, dataStoreManager.dataStore(for: instance)) + startNextMailHandlerProbe() + } + + func isDetectingMailHandler(for instanceID: UUID) -> Bool { + mailHandlerProbeRequests[instanceID] != nil + } + + /// Stops a successful probe as soon as AppState accepts its declaration. + func completeMailHandlerProbe(for instanceID: UUID) { + finishMailHandlerProbe(instanceID) + } + + private func startNextMailHandlerProbe() { + guard let id = mailHandlerProbeQueue.beginNext(), + let request = mailHandlerProbeRequests[id] + else { return } + guard canProbeMailHandler?(id, request.url) ?? true, + URL(string: request.instance.url) == request.url, + request.instance.nativeAppBundleID == nil, request.instance.mailtoHandlerEnabledEffective else { + finishMailHandlerProbe(id) + return + } + + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = request.dataStore + let controller = WKUserContentController() + userScriptManager.configureMailHandlerDiscovery(for: id, on: controller) + configuration.userContentController = controller + + let webView = WKWebView(frame: .zero, configuration: configuration) + let normalUserAgent = request.instance.userAgent ?? UserAgentProvider.safariDefault + webView.customUserAgent = UserAgentProvider.mailHandlerDiscovery(for: request.url, normalUserAgent: normalUserAgent) + let delegate = MailHandlerProbeDelegate(normalUserAgent: normalUserAgent, + allowsGmailCompatibility: UserAgentProvider.usesGmailMailHandlerCompatibility(for: request.url)) { [weak self] in + guard let self, self.activeMailHandlerProbe?.id == id else { return } + // Give late page startup code a brief window after didFinish to make + // its registration call, then release the duplicate web process. + self.activeMailHandlerProbe?.observationTimeout?.cancel() + let observationWindow = self.mailProbeObservationWindow + self.activeMailHandlerProbe?.observationTimeout = Task { @MainActor [weak self] in + try? await Task.sleep(for: observationWindow) + guard !Task.isCancelled else { return } + self?.finishMailHandlerProbe(id, reportFailure: true) + } + } + webView.navigationDelegate = delegate + let probe = ActiveMailHandlerProbe(id: id, webView: webView, delegate: delegate) + activeMailHandlerProbe = probe + // Hard stop for a page that never finishes or fails its navigation. + let lifetime = mailProbeLifetime + probe.timeout = Task { @MainActor [weak self] in + try? await Task.sleep(for: lifetime) + guard !Task.isCancelled else { return } + self?.finishMailHandlerProbe(id, reportFailure: true) + } + loadMailProbe(webView, request.url) + } + + private func finishMailHandlerProbe(_ id: UUID, reportFailure: Bool = false) { + guard mailHandlerProbeQueue.finish(id) else { return } + if let probe = activeMailHandlerProbe, probe.id == id { + probe.cancel(using: userScriptManager) + activeMailHandlerProbe = nil + } + mailHandlerProbeRequests.removeValue(forKey: id) + if reportFailure { onMailHandlerProbeFailed?(id) } + startNextMailHandlerProbe() + } + /// Preloads a web view for a service in the background without making it active. /// The web view is created and starts loading, but no soft-hibernation of other /// services is triggered and no notification polling starts. This makes the service @@ -356,6 +459,11 @@ final class WebViewPool { } func removeWebView(for instanceID: UUID) { + // Release account-scoped composers before its data store can be deleted. + // Ordinary hibernation uses teardownWebView and leaves composers alone. + let composers = mailComposeWindows.values.filter { $0.serviceID == instanceID } + for composer in composers { composer.session.window.close() } + finishMailHandlerProbe(instanceID) teardownWebView(instanceID) suspendedURLs.removeValue(forKey: instanceID) hibernatedServiceIDs.remove(instanceID) @@ -409,10 +517,12 @@ final class WebViewPool { } } - /// Every live service web view and open tab, for the quit handoff. + /// Account pages, tabs, floating drafts and their popups for quit. + /// Compatibility probes have no user work and stay outside this snapshot. var liveWebViews: [WKWebView] { webViews.keys.flatMap { allWebViews(for: $0) } + coordinators.values.flatMap { $0.auxiliaryWebViews } + + mailComposeWindows.values.flatMap { $0.session.liveWebViews } } /// Runs the call-detection JS for a service on the main actor, returning @@ -460,6 +570,47 @@ final class WebViewPool { webViews[id]?.load(URLRequest(url: url)) } + /// Opens a provider-declared compose endpoint in its own window while sharing + /// the service's cookies and storage. The inbox web view is left untouched. + func openMailComposer(for instance: ServiceInstance, at url: URL) { + cancelMailComposerFocusRequests() + let sessionID = UUID() + let configuration = makeConfiguration(for: instance) + let session = MailComposeWindowSession( + dataStore: dataStoreManager.dataStore(for: instance), + userAgent: instance.userAgent ?? UserAgentProvider.safariDefault, + title: "New message: \(instance.label)", + url: url, + configuration: configuration, + coordinator: makeCoordinator(for: instance), + loadPage: loadMailComposer + ) { [weak self] in + self?.userScriptManager.removeMailHandler(on: configuration.userContentController) + self?.mailComposeWindows.removeValue(forKey: sessionID) + } + mailComposeWindows[sessionID] = (instance.id, session) + session.show() + } + + /// Conceal drafts and descendants while retaining their page state. + func setMailComposersLocked(_ locked: Bool) { + if locked { + // Compatibility loads hold no drafts and must not propose handlers + // after lock has discarded the pending consent UI. + mailHandlerProbeQueue = MailHandlerProbeQueue() + mailHandlerProbeRequests.removeAll() + activeMailHandlerProbe?.cancel(using: userScriptManager) + activeMailHandlerProbe = nil + } + for composer in mailComposeWindows.values { composer.session.setLocked(locked) } + } + + /// A chooser, lock screen, error, or newer request takes precedence + /// over delayed focus repair from a previously opened composer. + func cancelMailComposerFocusRequests() { + for composer in mailComposeWindows.values { composer.session.cancelPendingFocus() } + } + /// Update a live web view's user agent (e.g. the Mobile view toggle) and /// reload so the site re-renders for the new agent. No-op without a live /// view — the new agent applies when the view is next created. @@ -749,6 +900,7 @@ final class WebViewPool { private func teardownWebView(_ instanceID: UUID) { coordinators[instanceID]?.closeAllTabs() if let webView = webViews[instanceID] { + userScriptManager.removeMailHandler(on: webView.configuration.userContentController) webView.configuration.userContentController.removeAllScriptMessageHandlers() webView.stopLoading() webView.navigationDelegate = nil @@ -802,6 +954,7 @@ final class WebViewPool { coordinator.instanceID = instance.id coordinator.fallbackURL = URL(string: instance.url) coordinator.externalLinkHandler = externalLinkHandler + coordinator.systemLinkHandler = systemLinkHandler coordinator.serviceOwnsURL = serviceOwnsURL coordinator.downloadCenter = downloadCenter coordinator.mediaCapturePolicyProvider = mediaCapturePolicyProvider @@ -1037,6 +1190,81 @@ final class WebViewPool { } } +@MainActor +private final class ActiveMailHandlerProbe { + let id: UUID + let webView: WKWebView + let delegate: MailHandlerProbeDelegate + var timeout: Task? + var observationTimeout: Task? + + init(id: UUID, webView: WKWebView, delegate: MailHandlerProbeDelegate) { + self.id = id + self.webView = webView + self.delegate = delegate + } + + deinit { + timeout?.cancel() + observationTimeout?.cancel() + } + + func cancel(using scripts: UserScriptManager) { + timeout?.cancel() + observationTimeout?.cancel() + timeout = nil + observationTimeout = nil + webView.stopLoading() + webView.navigationDelegate = nil + let controller = webView.configuration.userContentController + scripts.removeMailHandler(on: controller) + controller.removeAllScriptMessageHandlers() + controller.removeAllUserScripts() + } +} + +@MainActor +private final class MailHandlerProbeDelegate: NSObject, WKNavigationDelegate { + private let navigationEnded: () -> Void + private let normalUserAgent: String + private let allowsGmailCompatibility: Bool + + init(normalUserAgent: String, allowsGmailCompatibility: Bool, navigationEnded: @escaping () -> Void) { + self.normalUserAgent = normalUserAgent + self.allowsGmailCompatibility = allowsGmailCompatibility + self.navigationEnded = navigationEnded + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + if navigationAction.targetFrame?.isMainFrame == true, let url = navigationAction.request.url { + webView.customUserAgent = allowsGmailCompatibility + ? UserAgentProvider.mailHandlerDiscovery(for: url, normalUserAgent: normalUserAgent) + : normalUserAgent + } + return .allow + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + navigationEnded() + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + navigationEnded() + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + navigationEnded() + } +} + /// Watches WebKit's private `_isPlayingAudio` on one web view. The property is /// not in the public headers, so it can't go through a `KeyPath` observation; /// this is the string-keyed form, behind a `responds(to:)` check so a WebKit diff --git a/ChorusTests/AppStateMailRoutingTests.swift b/ChorusTests/AppStateMailRoutingTests.swift new file mode 100644 index 0000000..412708a --- /dev/null +++ b/ChorusTests/AppStateMailRoutingTests.swift @@ -0,0 +1,653 @@ +import AppKit +import SwiftData +import SwiftUI +import WebKit +import XCTest +@testable import Chorus + +@MainActor +final class AppStateMailRoutingTests: XCTestCase { + // Independent literals, not destinations recomputed through the router. + private let firstMail = "mailto:alice%2Btag@example.test?subject=Hello%20%E2%98%83&body=A%26B%3F%23" + private let firstPayload = "mailto%3Aalice%252Btag%40example.test%3Fsubject%3DHello%2520%25E2%2598%2583%26body%3DA%2526B%253F%2523" + private let secondMail = "mailto:bob@example.test?subject=Second%3F&body=Line%201%0ALine%202" + private let secondPayload = "mailto%3Abob%40example.test%3Fsubject%3DSecond%253F%26body%3DLine%25201%250ALine%25202" + private let thirdMail = "mailto:carol@example.test?subject=Third%23&body=C%2BD" + private let thirdPayload = "mailto%3Acarol%40example.test%3Fsubject%3DThird%2523%26body%3DC%252BD" + + func testPageDeclarationNeedsApprovalAndCannotReplaceApprovedHandlerSilently() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "New account", url: "https://fixture.example.test") + fixture.container.mainContext.insert(service) + try fixture.container.mainContext.save() + let declaration = MailHandlerDeclaration(serviceID: service.id, protocolName: "mailto", + handlerTemplate: "/compose?mail=%s", declaringPageURL: URL(string: service.url)!, isMainFrame: true) + fixture.app.userScriptManager.onMailHandlerDeclaration?(declaration) + + XCTAssertNil(service.mailtoHandler, "A page cannot approve itself") + let approval = try XCTUnwrap(fixture.app.pendingMailHandlerApproval) + fixture.app.userScriptManager.onMailHandlerDeclaration?(declaration) + XCTAssertEqual(fixture.app.pendingMailHandlerApproval?.id, approval.id, "Repeated declarations must not repeat the prompt") + fixture.app.answerMailHandlerApproval(approval.id, allow: true) + XCTAssertEqual(service.mailtoHandlerTemplate, "https://fixture.example.test/compose?mail=%s") + XCTAssertNil(fixture.app.pendingMailHandlerApproval) + fixture.app.userScriptManager.onMailHandlerDeclaration?(declaration) + XCTAssertNil(fixture.app.pendingMailHandlerApproval, "Unchanged approval needs no new prompt") + service.mailtoHandlerEnabled = false + let changed = MailHandlerDeclaration(serviceID: service.id, protocolName: "mailto", + handlerTemplate: "/new-compose?mail=%s", declaringPageURL: URL(string: service.url)!, isMainFrame: true) + fixture.app.userScriptManager.onMailHandlerDeclaration?(changed) + let replacement = try XCTUnwrap(fixture.app.pendingMailHandlerApproval) + XCTAssertEqual(service.mailtoHandlerTemplate, "https://fixture.example.test/compose?mail=%s") + fixture.app.answerMailHandlerApproval(replacement.id, allow: true) + XCTAssertEqual(service.mailtoHandlerTemplate, "https://fixture.example.test/new-compose?mail=%s") + XCTAssertEqual(service.mailtoHandlerEnabled, false, "Approval must preserve explicit opt-out") + } + + func testDeniedRepeatedDeclarationAndStaleLockedApprovalCannotEnableMail() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "Unapproved", url: "https://fixture.example.test") + fixture.container.mainContext.insert(service) + try fixture.container.mainContext.save() + let declaration = MailHandlerDeclaration(serviceID: service.id, protocolName: "mailto", + handlerTemplate: "/compose?mail=%s", declaringPageURL: URL(string: service.url)!, isMainFrame: true) + fixture.app.userScriptManager.onMailHandlerDeclaration?(declaration) + let declined = try XCTUnwrap(fixture.app.pendingMailHandlerApproval) + fixture.app.answerMailHandlerApproval(declined.id, allow: false) + fixture.app.userScriptManager.onMailHandlerDeclaration?(declaration) + XCTAssertNil(fixture.app.pendingMailHandlerApproval) + XCTAssertNil(service.mailtoHandler) + let replacement = MailHandlerDeclaration(serviceID: service.id, protocolName: "mailto", + handlerTemplate: "/new?mail=%s", declaringPageURL: URL(string: service.url)!, isMainFrame: true) + fixture.app.userScriptManager.onMailHandlerDeclaration?(replacement) + let old = try XCTUnwrap(fixture.app.pendingMailHandlerApproval) + fixture.app.appLockEnabled = true + fixture.app.lock() + fixture.app.answerMailHandlerApproval(old.id, allow: true) + fixture.app.isLocked = false + fixture.app.answerMailHandlerApproval(old.id, allow: true) + XCTAssertNil(service.mailtoHandler) + fixture.app.userScriptManager.onMailHandlerDeclaration?(replacement) + let current = try XCTUnwrap(fixture.app.pendingMailHandlerApproval) + XCTAssertNotEqual(current.id, old.id) + fixture.app.answerMailHandlerApproval(old.id, allow: true) + XCTAssertEqual(fixture.app.pendingMailHandlerApproval?.id, current.id) + fixture.app.answerMailHandlerApproval(current.id, allow: true) + XCTAssertEqual(service.mailtoHandlerTemplate, "https://fixture.example.test/new?mail=%s") + } + + func testDeclarationReceivedWhileLockedDoesNotReappearAfterUnlock() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "Account", url: "https://fixture.example.test") + fixture.container.mainContext.insert(service) + try fixture.container.mainContext.save() + fixture.app.appLockEnabled = true + fixture.app.lock() + XCTAssertTrue(fixture.app.isLocked) + fixture.app.userScriptManager.onMailHandlerDeclaration?(MailHandlerDeclaration( + serviceID: service.id, protocolName: "mailto", handlerTemplate: "/compose?mail=%s", + declaringPageURL: URL(string: service.url)!, isMainFrame: true)) + fixture.app.isLocked = false + XCTAssertNil(fixture.app.pendingMailHandlerApproval) + XCTAssertNil(service.mailtoHandler) + } + + func testExplicitDetectionReportsUnsupportedHTTPService() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "Account", url: "http://fixture.example.test") + fixture.container.mainContext.insert(service) + fixture.app.detectMailHandler(for: service.id) + XCTAssertNotNil(fixture.app.mailLinkError) + XCTAssertEqual(fixture.app.mailLinkError?.consumesRequest, false) + } + + func testExplicitDetectionReportsDisabledMailLinksWithoutEnablingThem() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = fixture.addAccount("Account", path: "/first") + service.mailtoHandlerEnabled = false + fixture.app.detectMailHandler(for: service.id) + XCTAssertNotNil(fixture.app.mailLinkError) + XCTAssertFalse(service.mailtoHandlerEnabledEffective) + } + + func testOrdinaryServiceActivityNeverLoadsDiscoveryOrInstallsManifestFetch() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "Unrelated", url: fixture.server.url("/first").absoluteString) + fixture.container.mainContext.insert(service) + let pool = fixture.app.webViewPool + defer { pool.removeWebView(for: service.id) } + pool.preload(service) + let inbox = pool.webView(for: service) + let ready = await eventually { !inbox.isLoading && inbox.url != nil } + XCTAssertTrue(ready) + for _ in 0..<3 { + pool.navigate(service.id, to: fixture.server.url("/first")) + let finished = await eventually { !inbox.isLoading } + XCTAssertTrue(finished) + fixture.app.webViewPool.onNavigationFinished?(service.id) + _ = pool.webView(for: service) + } + pool.hibernate(service.id) + let restored = pool.webView(for: service) + XCTAssertTrue(fixture.probeLoads.isEmpty) + for view in [inbox, restored] { + let scripts = view.configuration.userContentController.userScripts.map(\.source) + XCTAssertFalse(scripts.contains(UserScriptManager.makeManifestDiscoveryScript())) + XCTAssertTrue(scripts.contains(UserScriptManager.makeMailHandlerRegistrationScript())) + } + } + + func testSavedOptOutAndURLEditCancelActiveDetectionAndLockDropsTheQueue() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = fixture.addAccount("Account", path: "/first") + for changeURL in [false, true] { + fixture.app.detectMailHandler(for: service.id) + let probe = try XCTUnwrap(fixture.probeLoads.last?.view) + if changeURL { service.url = "https://changed.example.test/" } + else { service.mailtoHandlerEnabled = false } + fixture.app.applyServiceEdits(serviceID: service.id, urlChanged: changeURL) + XCTAssertFalse(fixture.app.webViewPool.isDetectingMailHandler(for: service.id)) + XCTAssertNil(probe.navigationDelegate) + XCTAssertTrue(probe.configuration.userContentController.userScripts.isEmpty) + service.mailtoHandlerEnabled = true + } + let queued = fixture.addAccount("Queued", path: "/second") + fixture.app.detectMailHandler(for: service.id) + fixture.app.detectMailHandler(for: queued.id) + let count = fixture.probeLoads.count + fixture.app.appLockEnabled = true + fixture.app.lock() + XCTAssertFalse(fixture.app.webViewPool.isDetectingMailHandler(for: service.id)) + XCTAssertFalse(fixture.app.webViewPool.isDetectingMailHandler(for: queued.id)) + fixture.app.isLocked = false + XCTAssertEqual(fixture.probeLoads.count, count) + } + + func testExplicitDetectionUsesChromeOnlyForExactGmailHTTPSOrigin() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let cases: [(String, String?)] = [ + ("https://mail.google.com/mail/u/2/#inbox", nil), + ("https://MAIL.GOOGLE.COM:443/mail/u/1/", nil), + ("https://mail.google.com:8443/mail/u/0/", nil), + ("https://mail.google.com.evil.test/mail/u/0/", nil), + ("https://accounts.google.com/", nil), + ("https://fixture.example.test/?next=mail.google.com", nil), + ("https://fixture.example.test/", "Configured browser"), + ("https://fixture.example.test/mobile", UserAgentProvider.mobileSafari) + ] + for (index, item) in cases.enumerated() { + let service = ServiceInstance(label: "Gmail", url: item.0, catalogEntryID: "gmail") + service.userAgent = item.1 + fixture.container.mainContext.insert(service) + fixture.app.detectMailHandler(for: service.id) + let load = try XCTUnwrap(fixture.probeLoads.last) + XCTAssertEqual(fixture.probeLoads.count, index + 1) + XCTAssertEqual(load.url.absoluteString, URL(string: item.0)?.absoluteString) + XCTAssertTrue(load.view.configuration.websiteDataStore === fixture.stores.dataStore(for: service)) + XCTAssertEqual(load.view.customUserAgent, index < 2 + ? UserAgentProvider.chromiumMailHandlerDiscovery + : item.1 ?? UserAgentProvider.safariDefault) + fixture.app.webViewPool.completeMailHandlerProbe(for: service.id) + } + } + + func testDiscoveryNavigationRestoresNormalIdentityOutsideGmailAndDoesNotPromoteOtherProviders() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + for serviceURL in ["https://mail.google.com/mail/u/1/", "https://fixture.example.test/"] { + let service = ServiceInstance(label: "Account", url: serviceURL, userAgent: "FixtureBrowser/1.0") + fixture.container.mainContext.insert(service) + fixture.app.detectMailHandler(for: service.id) + let view = try XCTUnwrap(fixture.probeLoads.last?.view) + if serviceURL.contains("mail.google.com") { + XCTAssertEqual(view.customUserAgent, UserAgentProvider.chromiumMailHandlerDiscovery) + view.load(URLRequest(url: fixture.server.url("/first"))) + let loaded = await eventually { !view.isLoading && view.url == fixture.server.url("/first") } + XCTAssertTrue(loaded) + } else { + // The standards probe must not gain a provider exception by navigating + // to a Gmail document. Inline content avoids contacting Google. + view.loadHTMLString("", baseURL: URL(string: "https://mail.google.com/mail/u/0/")) + let loaded = await eventually { (try? await view.evaluateJavaScriptValue("window.fixtureReady")) as? Bool == true } + XCTAssertTrue(loaded) + } + XCTAssertEqual(view.customUserAgent, "FixtureBrowser/1.0") + let identity = try await view.evaluateJavaScriptValue("navigator.userAgent") as? String + XCTAssertEqual(identity, "FixtureBrowser/1.0") + fixture.app.userScriptManager.onMailHandlerDeclaration?(MailHandlerDeclaration( + serviceID: service.id, protocolName: "mailto", handlerTemplate: "/compose?mail=%s", + declaringPageURL: URL(string: "https://accounts.google.com/")!, isMainFrame: true)) + XCTAssertNil(fixture.app.pendingMailHandlerApproval, "The browser exception must not grant trust to Google sign-in") + XCTAssertNil(service.mailtoHandler) + fixture.app.webViewPool.completeMailHandlerProbe(for: service.id) + } + } + + func testQueuedDetectionUsesCurrentConfiguredIdentityAndCoalescesWaitingClicks() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let first = fixture.addAccount("First", path: "/first") + let queued = fixture.addAccount("Queued", path: "/second") + fixture.app.detectMailHandler(for: first.id) + fixture.app.detectMailHandler(for: queued.id) + fixture.app.detectMailHandler(for: queued.id) + queued.userAgent = "ChangedBrowser/1.0" + fixture.app.webViewPool.completeMailHandlerProbe(for: first.id) + XCTAssertEqual(fixture.probeLoads.count, 2) + XCTAssertEqual(fixture.probeLoads.last?.view.customUserAgent, "ChangedBrowser/1.0") + } + + func testExplicitDetectionCoalescesClicksAndAllowsCompletedRetry() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = fixture.addAccount("Account", path: "/first") + fixture.app.detectMailHandler(for: service.id) + fixture.app.detectMailHandler(for: service.id) + XCTAssertEqual(fixture.probeLoads.count, 1) + XCTAssertTrue(fixture.app.webViewPool.isDetectingMailHandler(for: service.id)) + fixture.app.webViewPool.completeMailHandlerProbe(for: service.id) + XCTAssertFalse(fixture.app.webViewPool.isDetectingMailHandler(for: service.id)) + fixture.app.detectMailHandler(for: service.id) + XCTAssertEqual(fixture.probeLoads.count, 2) + } + + func testQueuedDetectionRevalidatesEditedDisabledAndDeletedServices() async throws { + for change in ["url", "disabled", "deleted"] { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let first = fixture.addAccount("First", path: "/first") + let queued = ServiceInstance(label: "Gmail", url: "https://mail.google.com/mail/u/1/") + fixture.container.mainContext.insert(queued) + fixture.app.detectMailHandler(for: first.id) + fixture.app.detectMailHandler(for: queued.id) + switch change { + case "url": queued.url = "https://elsewhere.example.test/" + case "disabled": queued.mailtoHandlerEnabled = false + default: fixture.container.mainContext.delete(queued); try fixture.container.mainContext.save() + } + fixture.app.webViewPool.completeMailHandlerProbe(for: first.id) + XCTAssertEqual(fixture.probeLoads.count, 1, change) + } + } + + func testProbeEligibilityUsesParsedAccountURLAndStillRejectsChangedPaths() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "Account", url: "https://fixture.example.test/path with spaces") + fixture.container.mainContext.insert(service) + let requestedURL = try XCTUnwrap(URL(string: service.url)) + XCTAssertEqual(fixture.app.webViewPool.canProbeMailHandler?(service.id, requestedURL), true) + service.mailtoHandlerEnabled = false + XCTAssertEqual(fixture.app.webViewPool.canProbeMailHandler?(service.id, requestedURL), false) + service.mailtoHandlerEnabled = true + service.url = "https://fixture.example.test/another-account" + XCTAssertEqual(fixture.app.webViewPool.canProbeMailHandler?(service.id, requestedURL), false) + } + + func testChangedServiceWhileApprovalIsVisibleDoesNotStrandQueuedMail() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let service = ServiceInstance(label: "Account", url: "https://fixture.example.test") + fixture.container.mainContext.insert(service) + try fixture.container.mainContext.save() + fixture.app.userScriptManager.onMailHandlerDeclaration?(MailHandlerDeclaration( + serviceID: service.id, protocolName: "mailto", handlerTemplate: "/compose?mail=%s", + declaringPageURL: URL(string: service.url)!, isMainFrame: true)) + let approval = try XCTUnwrap(fixture.app.pendingMailHandlerApproval) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + service.url = "https://changed.example.test" + + fixture.app.answerMailHandlerApproval(approval.id, allow: true) + + XCTAssertNil(service.mailtoHandler) + XCTAssertNotNil(fixture.app.mailLinkError, "The queued link must get a visible outcome") + XCTAssertTrue(fixture.loads.isEmpty) + } + + func testChooserKeepsSortedDistinctSpaceContextAndSeparateInstancesWithDuplicateLabels() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let first = fixture.addAccount("Shared", path: "/first") + let second = fixture.addAccount("Shared", path: "/second") + for name in ["Beta", "Alpha", "Shared", "Alpha"] { + let space = Space(name: name, emoji: "", sortOrder: 0) + fixture.container.mainContext.insert(space) + let link = SpaceServiceLink(sortOrder: 0, space: space, service: first) + if link.modelContext == nil { fixture.container.mainContext.insert(link) } + } + try fixture.container.mainContext.save() + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + let chooser = try XCTUnwrap(fixture.app.pendingMailLink) + let firstCandidate = try XCTUnwrap(chooser.candidates.first { $0.serviceID == first.id }) + let secondCandidate = try XCTUnwrap(chooser.candidates.first { $0.serviceID == second.id }) + XCTAssertEqual(chooser.candidates.count, 2) + XCTAssertEqual(firstCandidate.label, "Shared") + XCTAssertEqual(secondCandidate.label, "Shared") + XCTAssertEqual(firstCandidate.spaceLabels, ["Alpha", "Beta", "Shared"]) + XCTAssertEqual(firstCandidate.chooserSubtitle, "Alpha · Beta") + XCTAssertEqual(secondCandidate.spaceLabels, []) + XCTAssertEqual(secondCandidate.chooserSubtitle, "") + XCTAssertNil(firstCandidate.providerLabel) + XCTAssertNil(secondCandidate.providerLabel) + fixture.app.cancelMailLink(chooser.id) + } + + func testFIFOAccountChoicesAdvanceWhileFirstSuccessfulNavigationIsHeld() async throws { + let fixture = try await RoutingFixture(holdPaths: ["/first"]) + defer { fixture.close() } + let personal = fixture.addAccount("Personal", path: "/first") + let work = fixture.addAccount("Work", path: "/second") + try fixture.container.mainContext.save() + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + let firstChoice = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: secondMail))) + fixture.app.chooseMailService(personal.id, requestID: firstChoice.id) + let first = try XCTUnwrap(fixture.loads.first) + XCTAssertEqual(first.url.absoluteString, "https://fixture.example.test/first?mail=" + firstPayload) + XCTAssertTrue(first.view.configuration.websiteDataStore === fixture.stores.dataStore(for: personal)) + let received = await eventually { fixture.server.requestedTargets.contains("/first?mail=" + self.firstPayload) } + XCTAssertTrue(received, "The server must have accepted a request it has not answered") + XCTAssertTrue(first.view.isLoading) + let firstWindow = try XCTUnwrap(first.view.window) + XCTAssertTrue(firstWindow.isVisible) + + let secondChoice = try XCTUnwrap(fixture.app.pendingMailLink) + XCTAssertNotEqual(secondChoice.id, firstChoice.id) + fixture.app.chooseMailService(work.id, requestID: firstChoice.id) + fixture.app.cancelMailLink(firstChoice.id) + XCTAssertEqual(fixture.app.pendingMailLink?.id, secondChoice.id) + fixture.app.chooseMailService(work.id, requestID: secondChoice.id) + let second = try XCTUnwrap(fixture.loads.last) + XCTAssertEqual(second.url.absoluteString, "https://fixture.example.test/second?mail=" + secondPayload) + XCTAssertTrue(second.view.configuration.websiteDataStore === fixture.stores.dataStore(for: work)) + XCTAssertTrue(second.view !== first.view) + let secondLoaded = await eventually { second.view.url == fixture.server.url("/second?mail=" + self.secondPayload) && !second.view.isLoading } + XCTAssertTrue(secondLoaded) + XCTAssertTrue(first.view.isLoading, "Later routing must not wait for the held response") + XCTAssertTrue(firstWindow.isVisible) + XCTAssertTrue(firstWindow.contentView === first.view) + XCTAssertNil(fixture.app.pendingMailLink) + fixture.server.releaseHeldResponses() + let firstLoaded = await eventually { first.view.url == fixture.server.url("/first?mail=" + self.firstPayload) && !first.view.isLoading } + XCTAssertTrue(firstLoaded, "The stall must end in a successful navigation, not a connection failure") + let marker = try await first.view.evaluateJavaScriptValue("window.fixtureMarker") as? String + XCTAssertEqual(marker, "retained") + } + + func testLockHidesExistingComposerAndPopupWithoutDestroyingDraft() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + _ = fixture.addAccount("Personal", path: "/first") + try fixture.container.mainContext.save() + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + let load = try XCTUnwrap(fixture.loads.first) + let ready = await eventually { load.view.url != nil && !load.view.isLoading } + XCTAssertTrue(ready) + try await load.view.evaluateJavaScriptValue("window.draftMarker = 41; window.open('/picker', 'fixturePicker', 'width=700,height=500'); null") + let coordinator = try XCTUnwrap(load.view.uiDelegate as? WebViewCoordinator) + let popupOpened = await eventually { !coordinator.auxiliaryWindows.isEmpty } + XCTAssertTrue(popupOpened) + let composer = try XCTUnwrap(load.view.window) + let popup = try XCTUnwrap(coordinator.auxiliaryWindows.first) + fixture.app.appLockEnabled = true + + fixture.app.lock() + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertFalse(composer.isVisible) + XCTAssertFalse(popup.isVisible) + XCTAssertTrue(coordinator.isBrowserLocked) + XCTAssertNotNil(composer.contentView, "Lock must not destroy the draft") + fixture.app.isLocked = false + XCTAssertTrue(composer.isVisible) + XCTAssertTrue(popup.isVisible) + let marker = try await load.view.evaluateJavaScriptValue("window.draftMarker") as? Int + XCTAssertEqual(marker, 41) + } + + func testLockResumesOldestAndCancellationRemovesOnlyMatchingDraft() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let personal = fixture.addAccount("Personal", path: "/first") + _ = fixture.addAccount("Work", path: "/second") + try fixture.container.mainContext.save() + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: thirdMail))) + let cancelled = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: secondMail))) + fixture.app.cancelMailLink(cancelled.id) + let beforeLock = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.appLockEnabled = true + fixture.app.lock() + fixture.app.chooseMailService(personal.id, requestID: beforeLock.id) + fixture.app.cancelMailLink(beforeLock.id) + XCTAssertNil(fixture.app.pendingMailLink) + XCTAssertTrue(fixture.loads.isEmpty) + fixture.app.isLocked = false + let resumed = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.cancelMailLink(beforeLock.id) + XCTAssertEqual(fixture.app.pendingMailLink?.id, resumed.id) + fixture.app.chooseMailService(personal.id, requestID: resumed.id) + let first = try XCTUnwrap(fixture.loads.first) + XCTAssertEqual(first.url.absoluteString, "https://fixture.example.test/first?mail=" + firstPayload) + fixture.app.chooseMailService(personal.id, requestID: try XCTUnwrap(fixture.app.pendingMailLink).id) + let second = try XCTUnwrap(fixture.loads.last) + XCTAssertEqual(second.url.absoluteString, "https://fixture.example.test/first?mail=" + secondPayload) + XCTAssertTrue(first.view !== second.view) + XCTAssertTrue(first.view.configuration.websiteDataStore === second.view.configuration.websiteDataStore) + XCTAssertTrue(first.view.configuration.websiteDataStore === fixture.stores.dataStore(for: personal)) + let loaded = await eventually { !first.view.isLoading && !second.view.isLoading && first.view.url != nil && second.view.url != nil } + XCTAssertTrue(loaded) + XCTAssertTrue(try XCTUnwrap(first.view.window).isVisible) + XCTAssertTrue(try XCTUnwrap(second.view.window).isVisible) + XCTAssertFalse(fixture.server.requestedTargets.contains { $0.contains(self.thirdPayload) }) + XCTAssertNil(fixture.app.pendingMailLink) + XCTAssertNil(fixture.app.mailLinkError) + } + + func testErrorToDirectRouteLeavesExistingComposerAndInboxUntouched() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + let personal = fixture.addAccount("Personal", path: "/first") + try fixture.container.mainContext.save() + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + let first = try XCTUnwrap(fixture.loads.first) + let loaded = await eventually { !first.view.isLoading && first.view.url != nil } + XCTAssertTrue(loaded) + try await first.view.evaluateJavaScriptValue("window.draftMarker = 29; null") + let inbox = fixture.app.webViewPool.webView(for: personal) + defer { fixture.app.webViewPool.removeWebView(for: personal.id) } + inbox.loadHTMLString("", baseURL: fixture.server.url()) + let inboxReady = await eventually { (try? await inbox.evaluateJavaScriptValue("window.inboxMarker")) as? Int == 17 } + XCTAssertTrue(inboxReady) + let space = UUID() + fixture.app.selectedSpaceID = space + fixture.app.selectedServiceID = personal.id + var mainPresentations = 0 + fixture.app.openMainWindow = { mainPresentations += 1 } + personal.mailtoHandlerEnabled = false + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: thirdMail))) + let failed = try XCTUnwrap(fixture.app.mailLinkError) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: secondMail))) + personal.mailtoHandlerEnabled = true + fixture.app.dismissMailLinkError(failed.id) + fixture.app.dismissMailLinkError(failed.id) + XCTAssertEqual(fixture.loads.count, 2) + XCTAssertEqual(fixture.loads.last?.url.absoluteString, "https://fixture.example.test/first?mail=" + secondPayload) + XCTAssertEqual(mainPresentations, 1, "The direct next route must not raise the main window") + XCTAssertEqual(fixture.app.selectedSpaceID, space) + XCTAssertEqual(fixture.app.selectedServiceID, personal.id) + XCTAssertTrue(try XCTUnwrap(first.view.window).isVisible) + let draftMarker = try await first.view.evaluateJavaScriptValue("window.draftMarker") as? Int + XCTAssertEqual(draftMarker, 29) + let inboxMarker = try await inbox.evaluateJavaScriptValue("window.inboxMarker") as? Int + XCTAssertEqual(inboxMarker, 17) + XCTAssertEqual(first.view.url, fixture.server.url("/first?mail=" + firstPayload)) + XCTAssertNil(fixture.app.mailLinkError) + XCTAssertNil(fixture.app.pendingMailLink) + } + + func testQuitWaitsForFloatingDraftAndCoalescesRepeatedQuitWithoutAnInbox() async throws { + let fixture = try await RoutingFixture() + defer { fixture.close() } + _ = fixture.addAccount("Personal", path: "/first") + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + XCTAssertEqual(fixture.app.webViewPool.loadedCount, 0) + XCTAssertEqual(fixture.loads.count, 1) + let delegate = ChorusAppDelegate() + delegate.appState = fixture.app + var replies = 0 + delegate.terminationReply = { _, allowed in + XCTAssertTrue(allowed) + replies += 1 + } + + XCTAssertEqual(delegate.applicationShouldTerminate(NSApp), .terminateLater) + XCTAssertEqual(delegate.applicationShouldTerminate(NSApp), .terminateLater) + let completed = await eventually { replies == 1 } + XCTAssertTrue(completed) + XCTAssertEqual(replies, 1, "Repeated quit must not bypass or duplicate the grace period") + } + + func testHostedChooserSurvivesLockWithoutCancellingItsQueuedMail() async throws { + let fixture = try await RoutingFixture() + let personal = fixture.addAccount("Personal", path: "/first") + _ = fixture.addAccount("Work", path: "/second") + let window = hostMailPresentation(fixture.app) + defer { window.close(); fixture.close() } + fixture.app.appLockEnabled = true + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + let firstShown = await eventually { window.attachedSheet != nil } + XCTAssertTrue(firstShown) + let old = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.lock() + XCTAssertTrue(fixture.app.isLocked) + let hidden = await eventually { window.attachedSheet == nil } + XCTAssertTrue(hidden) + XCTAssertNil(fixture.app.pendingMailLink) + fixture.app.isLocked = false + let restored = await eventually { window.attachedSheet != nil && fixture.app.pendingMailLink != nil } + XCTAssertTrue(restored) + let current = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.cancelMailLink(old.id) + XCTAssertEqual(fixture.app.pendingMailLink?.id, current.id) + fixture.app.chooseMailService(personal.id, requestID: current.id) + XCTAssertEqual(fixture.loads.count, 1) + XCTAssertEqual(fixture.loads.first?.url.absoluteString, "https://fixture.example.test/first?mail=" + firstPayload) + } + + func testHostedErrorCompletionPreservesNextChooser() async throws { + let fixture = try await RoutingFixture() + let window = hostMailPresentation(fixture.app) + defer { window.close(); fixture.close() } + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: thirdMail))) + let shown = await eventually { window.attachedSheet != nil } + XCTAssertTrue(shown) + let oldSheet = try XCTUnwrap(window.attachedSheet) + let oldError = try XCTUnwrap(fixture.app.mailLinkError) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: firstMail))) + let personal = fixture.addAccount("Personal", path: "/first") + _ = fixture.addAccount("Work", path: "/second") + try fixture.container.mainContext.save() + try XCTUnwrap(button(named: "OK", in: oldSheet.contentView)).performClick(nil) + let choosing = await eventually { window.attachedSheet != nil && window.attachedSheet !== oldSheet && fixture.app.pendingMailLink != nil } + XCTAssertTrue(choosing, "Closing the error must leave the production account chooser visible") + let choice = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.dismissMailLinkError(oldError.id) + XCTAssertEqual(fixture.app.pendingMailLink?.id, choice.id) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: secondMail))) + let firstChooserSheet = try XCTUnwrap(window.attachedSheet) + fixture.app.chooseMailService(personal.id, requestID: choice.id) + let nextChooser = await eventually { window.attachedSheet != nil && window.attachedSheet !== firstChooserSheet && fixture.app.pendingMailLink != nil } + XCTAssertTrue(nextChooser, "Completion callbacks for the first chooser must not cancel the next one") + let next = try XCTUnwrap(fixture.app.pendingMailLink) + fixture.app.cancelMailLink(choice.id) + XCTAssertEqual(fixture.app.pendingMailLink?.id, next.id) + fixture.app.cancelMailLink(next.id) + let closed = await eventually { window.attachedSheet == nil } + XCTAssertTrue(closed) + XCTAssertEqual(fixture.loads.count, 1) + XCTAssertEqual(fixture.loads.first?.url.absoluteString, "https://fixture.example.test/first?mail=" + firstPayload) + } + + func testHostedErrorCompletionCreatesExactlyOneNextDirectComposer() async throws { + let fixture = try await RoutingFixture() + let window = hostMailPresentation(fixture.app) + defer { window.close(); fixture.close() } + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: thirdMail))) + let shown = await eventually { window.attachedSheet != nil } + XCTAssertTrue(shown) + let oldError = try XCTUnwrap(fixture.app.mailLinkError) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: secondMail))) + _ = fixture.addAccount("Personal", path: "/first") + try fixture.container.mainContext.save() + try XCTUnwrap(button(named: "OK", in: window.attachedSheet?.contentView)).performClick(nil) + let closed = await eventually { window.attachedSheet == nil } + XCTAssertTrue(closed) + fixture.app.dismissMailLinkError(oldError.id) + XCTAssertEqual(fixture.loads.count, 1) + XCTAssertEqual(fixture.loads.first?.url.absoluteString, "https://fixture.example.test/first?mail=" + secondPayload) + XCTAssertTrue(try XCTUnwrap(fixture.loads.first?.view.window).isVisible) + XCTAssertNil(fixture.app.pendingMailLink) + XCTAssertNil(fixture.app.mailLinkError) + } + + private func hostMailPresentation(_ app: AppState) -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: Text("Synthetic routing fixture").modifier(MailLinkPresentation(appState: app))) + window.makeKeyAndOrderFront(nil) + return window + } +} + +/// Changes only the page-loading boundary from the trusted synthetic HTTPS +/// destination to loopback HTTP. AppState, the pool, views and windows are real. +@MainActor +private final class RoutingFixture { + let container: ModelContainer + let stores = DataStoreManager(makeStore: { _ in .nonPersistent() }) + let server: ComposeFixtureServer + private(set) var app: AppState! + private(set) var probeLoads: [(view: WKWebView, url: URL)] = [] + private(set) var loads: [(view: WKWebView, url: URL)] = [] + + init(holdPaths: Set = []) async throws { + server = try await ComposeFixtureServer(pages: [ + "/first": "", + "/second": "" + ], holdPaths: holdPaths) + container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + app = AppState(modelContainer: container, dataStoreManager: stores, loadMailProbe: { [weak self] view, url in + self?.probeLoads.append((view, url)) + }, loadMailComposer: { [weak self] view, destination in + guard let self else { return } + self.loads.append((view, destination)) + view.load(URLRequest(url: self.server.url(destination.path + "?" + (destination.query ?? "")))) + }) + } + + func addAccount(_ label: String, path: String) -> ServiceInstance { + let service = ServiceInstance(label: label, url: "https://fixture.example.test") + service.mailtoHandlerOrigin = service.url + service.mailtoHandlerTemplate = service.url + path + "?mail=%s" + container.mainContext.insert(service) + return service + } + + func close() { + app.webViewPool.setMailComposersLocked(true) + probeLoads.removeAll() + loads.forEach { $0.view.window?.close() } + loads.removeAll() + server.stop() + } +} diff --git a/ChorusTests/ChorusTests.swift b/ChorusTests/ChorusTests.swift index 47691af..4414417 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -3460,7 +3460,7 @@ final class ChorusTests: XCTestCase { /// polling readyState races through and every query comes back empty. private func waitForFixture(_ webView: WKWebView) async throws { for _ in 0..<100 { - let mains = try? await webView.evaluateJavaScript("document.querySelectorAll('div[role=main]').length") as? Int + let mains = try? await webView.evaluateJavaScriptValue("document.querySelectorAll('div[role=main]').length") as? Int if let mains, mains > 0 { return } try await Task.sleep(for: .milliseconds(50)) } @@ -3488,15 +3488,15 @@ final class ChorusTests: XCTestCase { """, baseURL: URL(string: "https://web.whatsapp.com/")) try await waitForFixture(webView) - let before = try await webView.evaluateJavaScript("[document.hidden, seen.join(',')].join('|')") as? String + let before = try await webView.evaluateJavaScriptValue("[document.hidden, seen.join(',')].join('|')") as? String XCTAssertEqual(before, "false|", "pinned visible, and a visibilitychange before the release is swallowed") - _ = try await webView.evaluateJavaScript(UserScriptManager.quitReleaseJS) - let after = try await webView.evaluateJavaScript("[document.hidden, seen.join(',')].join('|')") as? String + _ = try await webView.evaluateJavaScriptValue(UserScriptManager.quitReleaseJS) + let after = try await webView.evaluateJavaScriptValue("[document.hidden, seen.join(',')].join('|')") as? String XCTAssertEqual(after, "true|visibility:hidden,pagehide") - _ = try await webView.evaluateJavaScript(UserScriptManager.quitReleaseJS) - let again = try await webView.evaluateJavaScript("seen.length") as? Int + _ = try await webView.evaluateJavaScriptValue(UserScriptManager.quitReleaseJS) + let again = try await webView.evaluateJavaScriptValue("seen.length") as? Int XCTAssertEqual(again, 2, "a second release sends nothing more") } @@ -3510,7 +3510,7 @@ final class ChorusTests: XCTestCase { try await waitForFixture(webView) // The fixture reproduces the bug: the old expression still reads 101 here. - let documentWide = try await webView.evaluateJavaScript("document.querySelectorAll('tr.zA.zE').length") as? Int + let documentWide = try await webView.evaluateJavaScriptValue("document.querySelectorAll('tr.zA.zE').length") as? Int XCTAssertEqual(documentWide, 101, "fixture should hold the 101 unread rows that made the badge read 99+") let badgeManager = BadgeManager() @@ -3534,7 +3534,7 @@ final class ChorusTests: XCTestCase { guard let js = ServiceCatalog.shared.entry(for: "gmail")?.badgeJS else { return XCTFail("Gmail catalog entry should define badgeJS") } - let count = try await webView.evaluateJavaScript(js) as? Int + let count = try await webView.evaluateJavaScriptValue(js) as? Int XCTAssertEqual(count, 3, "an offscreen view should still read the inbox count from the nav label") } @@ -4562,6 +4562,7 @@ final class ChorusTests: XCTestCase { let schemas: [(String, Schema)] = [ ("V1_5_11", Schema(versionedSchema: ChorusSchemaV1_5_11.self)), ("V1_5_12", Schema(versionedSchema: ChorusSchemaV1_5_12.self)), + ("V1_5_19", Schema(versionedSchema: ChorusSchemaV1_5_19.self)), ("VCurrent", Schema(versionedSchema: ChorusSchemaVCurrent.self)), ] for (label, schema) in schemas { @@ -4607,7 +4608,8 @@ final class ChorusTests: XCTestCase { "dataStoreIdentifier", "pageZoom", "osNotificationsEnabled", "customCSS", "forceDarkMode", "darkModeRaw", "cameraPolicyRaw", "microphonePolicyRaw", "openExternalLinksInApp", "stayActiveInBackground", "hasSeenPasskeyNotice", - "hibernationPolicyRaw", "hibernateAfterMinutes", "createdAt", "lastAccessedAt", + "hibernationPolicyRaw", "hibernateAfterMinutes", "mailtoHandlerTemplate", + "mailtoHandlerOrigin", "mailtoHandlerEnabled", "createdAt", "lastAccessedAt", ], "ServiceInstance stored attributes changed without a new schema version" ) @@ -4768,6 +4770,9 @@ final class ChorusTests: XCTestCase { XCTAssertNil(s.stayActiveInBackground) XCTAssertNil(s.hibernationPolicyRaw) XCTAssertNil(s.hibernateAfterMinutes) + XCTAssertNil(s.mailtoHandlerTemplate) + XCTAssertNil(s.mailtoHandlerOrigin) + XCTAssertNil(s.mailtoHandlerEnabled) XCTAssertFalse(s.staysActiveInBackgroundEffective) XCTAssertEqual(s.hibernationPolicyEffective, .never) // legacy neverHibernate == true diff --git a/ChorusTests/ComposeFixtureServer.swift b/ChorusTests/ComposeFixtureServer.swift new file mode 100644 index 0000000..136d039 --- /dev/null +++ b/ChorusTests/ComposeFixtureServer.swift @@ -0,0 +1,143 @@ +import Foundation +import Network +import Security + +/// Loopback-only pages for real WebKit tests. Held responses stay unfinished +/// until explicitly released; stop cancels all connections, including stalls. +final class ComposeFixtureServer: @unchecked Sendable { + private let listener: NWListener + struct Response: Sendable { + let body: String + var status = "200 OK" + var headers = ["Content-Type": "text/html; charset=utf-8"] + } + + private let responses: [String: Response] + private let scheme: String + private let lock = NSLock() + private var heldPaths: Set + private var heldResponses: [(NWConnection, Data)] = [] + private var connections: [ObjectIdentifier: NWConnection] = [:] + private var targets: [String] = [] + private var stopped = false + private(set) var port: UInt16 = 0 + + var requestedTargets: [String] { lock.withLock { targets } } + + convenience init(pages: [String: String], holdPaths: Set = []) async throws { + try await self.init(responses: pages.mapValues { Response(body: $0) }, holdPaths: holdPaths) + } + + @MainActor + init(responses: [String: Response], holdPaths: Set = [], tlsIdentity: SecIdentity? = nil) async throws { + self.responses = responses + self.heldPaths = holdPaths + scheme = tlsIdentity == nil ? "http" : "https" + let parameters: NWParameters + if let tlsIdentity { + let tls = NWProtocolTLS.Options() + sec_protocol_options_set_local_identity(tls.securityProtocolOptions, sec_identity_create(tlsIdentity)!) + parameters = NWParameters(tls: tls) + } else { + parameters = .tcp + } + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + let listener = try NWListener(using: parameters) + self.listener = listener + listener.newConnectionHandler = { [weak self] connection in + guard let self else { connection.cancel(); return } + let accepted = self.lock.withLock { + guard !self.stopped else { return false } + self.connections[ObjectIdentifier(connection)] = connection + return true + } + guard accepted else { connection.cancel(); return } + connection.start(queue: .global()) + self.receiveRequest(connection, accumulated: Data()) + } + port = try await withCheckedThrowingContinuation { continuation in + listener.stateUpdateHandler = { state in + switch state { + case .ready: + listener.stateUpdateHandler = nil + continuation.resume(returning: listener.port!.rawValue) + case .failed(let error): + listener.stateUpdateHandler = nil + continuation.resume(throwing: error) + default: break + } + } + listener.start(queue: DispatchQueue(label: "ChorusComposeFixtures")) + } + } + + func url(_ path: String = "/") -> URL { + URL(string: "\(scheme)://127.0.0.1:\(port)\(path)")! + } + + func releaseHeldResponses() { + let responses = lock.withLock { + heldPaths.removeAll() + let responses = heldResponses + heldResponses.removeAll() + return responses + } + responses.forEach { send($0.1, on: $0.0) } + } + + func stop() { + let active = lock.withLock { + stopped = true + heldResponses.removeAll() + let active = Array(connections.values) + connections.removeAll() + return active + } + listener.cancel() + active.forEach { $0.cancel() } + } + + deinit { stop() } + + private func finish(_ connection: NWConnection) { + _ = lock.withLock { connections.removeValue(forKey: ObjectIdentifier(connection)) } + connection.cancel() + } + + private func send(_ response: Data, on connection: NWConnection) { + connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.finish(connection) + connection.cancel() + }) + } + + private func receiveRequest(_ connection: NWConnection, accumulated: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, complete, error in + guard let self else { connection.cancel(); return } + var request = accumulated + if let data { request.append(data) } + guard error == nil, request.count < 65536 else { self.finish(connection); return } + guard let text = String(data: request, encoding: .utf8), text.contains("\r\n\r\n") else { + if complete { self.finish(connection) } + else { self.receiveRequest(connection, accumulated: request) } + return + } + let target = text.split(separator: " ").dropFirst().first.map(String.init) ?? "/" + let path = String(target.split(separator: "?", maxSplits: 1).first ?? "/") + let fixture = self.responses[path] ?? Response(body: "

Missing fixture

", status: "404 Not Found") + let body = Data(fixture.body.utf8) + let headers = fixture.headers.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() + var response = Data("HTTP/1.1 \(fixture.status)\r\n\(headers)Content-Length: \(body.count)\r\nConnection: close\r\n\r\n".utf8) + response.append(body) + let held = self.lock.withLock { + self.targets.append(target) + if self.heldPaths.contains(path) && !self.stopped { + self.heldResponses.append((connection, response)) + return true + } + return false + } + if !held { self.send(response, on: connection) } + } + } +} diff --git a/ChorusTests/Fixtures/loopback-certificate.der b/ChorusTests/Fixtures/loopback-certificate.der new file mode 100644 index 0000000000000000000000000000000000000000..3ed66d4db79a20d94fc96ae1a5b669ff4e72408e GIT binary patch literal 859 zcmXqLVh%TGVzOVr%*4pVB%;{-p*|$+Tb1_TwsA{0h#vIDR%p>ERkzZ6=tdNtRUyziToUKrjT3n)#mRV6!T9j&_AkJ%KW?*S- zU}R)yW@r{A!Ea;;5->1>3J`Bp6QdHcGZUIX7F6Jwm(!~AJSS9o?r+cd`fJvSiak#?I5I5t=1K~Fo|d`S zCq<=Y^Oa{Y7SkV}zj40leo~C|;caF6uQZ+y+p}kvu>QTc?)u%uhSgG*Q{Vnz7n9Ex z|339_{*My#H%It$Y&>}@EJDnVbu&5qNieC}$uRx?;cbt%OC_yf7XDBsziE=l#}NJl z>#8b$w3G$z{`6n>)Q`Do{d0vi6k-+Eh%)X~)|tzHlPfpznp8p5a{ZG6_s+eu-t?q( z&6hLFQoa6XUfj$Sex;;udiRE`GZqumCjQ;C{qENDC-Yj*YSl__c2aSZsh8_hvhtcU zmx-B?fpKxALAik}FlJ==Sj1RFI&P(}v~`#w^FMC-g$Ty36)QS3MGfRZ(#k9n24W4^ z74U-;2s1MNXJIv922uuK0YMfH1GaXSdIknY17VN~RTdrtE;bHrHbz!fc4kK8$N{Di zVB|0|Z1%|7`8hoPXH(|GXa>7Ew*SA$$;~-`>%G2zUPHm+)ESbRpU=j`zjo`MeBj*+ zZP}H_qJF#yPB5!^w`9ZOfW*^srC)=z*dq%=-m}bSSMs(E>AG0`@%N+^nkmr*8fG8Z z3xt>R{@STm8+dPf>tpHZl09BNMRuQbp7lPQ?OoGwpKal{4}!r}8aJM3N;~o--&(Z6 z-?QJrYlBpDBiBk%54ZNWN9HWqap@bcK(A@*-_OqKJdtU+lQ(4N-CX?aLercDuh$g{ zKM2cj&#j-baoe077B>ooS~hq|?DjAcnzrD>3WpAx=ov9v%dcg=yVPUmZ_T-AdS%J= S8v&kS@j(yxe{cKDvH$?X>sKrQ literal 0 HcmV?d00001 diff --git a/ChorusTests/Fixtures/loopback-private-key.der b/ChorusTests/Fixtures/loopback-private-key.der new file mode 100644 index 0000000000000000000000000000000000000000..9395a7ffe86b376df8a325df595846b0a3f85529 GIT binary patch literal 1192 zcmV;Z1Xueof&`=j0RRGm0RaHaN?*K@SD`H;;B4_7GV94Cez{Z3Go(7vYN&lmLaNUU z$Rv#4w`leHn#g#(T1ddbt3X z-@~@!w;E!o0~7FeAF`4Z@l+4Mu6lX+gmzK8^8YQ$_?>2tof9Y^T_LO$0lgzEoe$Xx zZDH0La9XD?$q?So?mM#Ng{<_7J6;Wl#xG@cmo*fmj znFl_eh1!LMAkIN_UGFC0Iq%94f1TeBgwzlM1$6q#UmY$$CKrdDSX_*Yy;cbbY*b@{ z`#S&WhjDEe6HkcgyX0g_HJ0#mD|FFz)GoiuV{7OS7x~)=`Zet4vp{p?$Sijy|Nf;4 ziJ0E|b_t?o1ag=Ccm26o?t3^Wj*01aU2ZjiU>n_INLuWWi>$F}&ENd1K3SK4O~%P$ zVt*eI+)Z-xY%)qM8-JVh;>#W`!F**``qJWrAlZWbF#dZ@v|OXHC#v}^~0)c@5{O$p2lKP&~N@Ln50I|=oN}K<8ky*eyXOaH>0)c@5 z&c!$$1Ls^bH0FKz1=&aruqnWEwmjwd(7|16FX$x%@$vLW6+vr(j*o{Bw+;2d9wNkS z3|1bTsy)EftzBl}4>TUzF$k&f&zgZzq_IgC$XC?yYk(KaPDlNotYY~vmh}o{6Jiym zuE&DIf7sA#{J2aX5XXRIS{ci4@qSr50)c@5u5mbM*p?xa$J(|^&>zySGsgp(OteksdUMRE z(EO$%0FNTm(+$ZIl*rDysQ<*B+5F;SJS63@cJ9qgxIimdZDah-DX-h68#mqv+-9pS z$O>r_=PxMIHA3j{FZhzNJk?1701>^t)g<)-fq?+i=qXReQ?ffz8%^RM2A&Xf!LH!h z?whs#e^xQCccCj1nq)dPEw~TlRh?B*FI!ZymAok!z1c7de#mbf8d@hL1pV?gvW-v~ zV%$CKQ-VtET0k)sHg8z{?aS_mjP$y0EjIJXyFO2-^tVU?qv(3IN0G2MZy Gt9iKOsYl=d literal 0 HcmV?d00001 diff --git a/ChorusTests/MailComposeWindowSessionTests.swift b/ChorusTests/MailComposeWindowSessionTests.swift new file mode 100644 index 0000000..3219d99 --- /dev/null +++ b/ChorusTests/MailComposeWindowSessionTests.swift @@ -0,0 +1,910 @@ +import AppKit +import WebKit +import XCTest +import SwiftData +import SwiftUI +@testable import Chorus + +@MainActor +final class MailComposeWindowSessionTests: XCTestCase { + func testHostedErrorOKRetainsSecondRequestForSeparateAcknowledgment() async throws { + try await acknowledgeHostedErrors(usingKeyboard: false) + } + + func testHostedErrorKeyboardRetainsSecondRequestForSeparateAcknowledgment() async throws { + try await acknowledgeHostedErrors(usingKeyboard: true) + } + + private func acknowledgeHostedErrors(usingKeyboard: Bool) async throws { + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: Text("Synthetic mail fixture").modifier(MailLinkPresentation(appState: app))) + window.makeKeyAndOrderFront(nil) + defer { window.close() } + app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:first@example.test?subject=First"))) + let shown = await eventually { window.attachedSheet != nil } + XCTAssertTrue(shown) + app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:second@example.test?subject=Second"))) + let firstSheet = try XCTUnwrap(window.attachedSheet) + let staleError = try XCTUnwrap(app.mailLinkError) + if usingKeyboard { + let event = try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: firstSheet.windowNumber, context: nil, characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) + firstSheet.sendEvent(event) + } else { + try XCTUnwrap(button(named: "OK", in: firstSheet.contentView)).performClick(nil) + } + let secondShown = await eventually { window.attachedSheet != nil && window.attachedSheet !== firstSheet } + XCTAssertTrue(secondShown, "One OK click must leave the second error actionable") + XCTAssertNotNil(app.mailLinkErrorMessage) + guard secondShown else { return } + let secondError = try XCTUnwrap(app.mailLinkError) + app.dismissMailLinkError(staleError.id) + XCTAssertEqual(app.mailLinkError?.id, secondError.id) + try XCTUnwrap(button(named: "OK", in: window.attachedSheet?.contentView)).performClick(nil) + let idle = await eventually { window.attachedSheet == nil && app.mailLinkErrorMessage == nil } + XCTAssertTrue(idle) + } + + func testStaleErrorAcknowledgmentCannotConsumeRequestsAcrossLockOrEligibilityChanges() throws { + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:first@example.test"))) + let beforeLock = try XCTUnwrap(app.mailLinkError) + app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:second@example.test"))) + app.appLockEnabled = true + app.lock() + app.dismissMailLinkError(beforeLock.id) + XCTAssertNil(app.mailLinkError) + app.isLocked = false + let resumed = try XCTUnwrap(app.mailLinkError) + app.dismissMailLinkError(beforeLock.id) + XCTAssertEqual(app.mailLinkError?.id, resumed.id) + for label in ["First", "Second"] { + let service = ServiceInstance(label: label, url: "https://fixture.example.test") + service.mailtoHandlerOrigin = service.url + service.mailtoHandlerTemplate = service.url + "/compose?mail=%s" + container.mainContext.insert(service) + } + try container.mainContext.save() + app.dismissMailLinkError(resumed.id) + let choice = try XCTUnwrap(app.pendingMailLink) + app.dismissMailLinkError(resumed.id) + XCTAssertEqual(app.pendingMailLink?.id, choice.id) + app.cancelMailLink(choice.id) + XCTAssertNil(app.pendingMailLink) + XCTAssertNil(app.mailLinkError) + } + + func testDiscardConfirmationCanBeCancelledThenAccepted() async throws { + var closeCount = 0 + let session = try await openFixture(""" +
Disposable fixture
+ + """) { closeCount += 1 } + defer { session.window.close() } + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + + try await webView.evaluateJavaScriptValue("setTimeout(() => document.getElementById('discard').click(), 0); null") + let presented = await eventually { session.window.attachedSheet != nil } + XCTAssertTrue(presented, "Provider confirmation must appear instead of silently cancelling") + guard presented else { return } + session.window.endSheet(try XCTUnwrap(session.window.attachedSheet), returnCode: .alertSecondButtonReturn) + let cancelled = await eventually { session.window.attachedSheet == nil } + XCTAssertTrue(cancelled) + XCTAssertTrue(session.window.isVisible) + XCTAssertEqual(closeCount, 0) + + try await webView.evaluateJavaScriptValue("setTimeout(() => document.getElementById('discard').click(), 0); null") + let presentedAgain = await eventually { session.window.attachedSheet != nil } + XCTAssertTrue(presentedAgain) + guard presentedAgain else { return } + session.window.endSheet(try XCTUnwrap(session.window.attachedSheet), returnCode: .alertFirstButtonReturn) + let closed = await eventually { closeCount == 1 } + XCTAssertTrue(closed, "Confirmed provider window.close() must finish the compose session") + XCTAssertFalse(session.window.isVisible) + XCTAssertEqual(closeCount, 1) + } + + func testBrowserUIDelegateImplementsNativeJavaScriptDialogSelectors() { + let delegate = WebViewCoordinator() + let selectors = [ + #selector(WKUIDelegate.webView(_:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:)), + #selector(WKUIDelegate.webView(_:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:)), + #selector(WKUIDelegate.webView(_:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:)) + ] + for selector in selectors { + XCTAssertTrue(delegate.responds(to: selector), "WebKit must see \(NSStringFromSelector(selector)) on every SDK") + } + } + + func testDialogCompletionCanPresentNextDialogOnTheSameWindow() async throws { + let session = try await openFixture("") + defer { session.window.close() } + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + var response: String? + WebViewDialogs.alert("First dialog", over: webView) { + WebViewDialogs.prompt("Next dialog", defaultText: "draft", over: webView) { response = $0 } + } + let firstShown = await eventually { session.window.attachedSheet != nil } + XCTAssertTrue(firstShown) + session.window.endSheet(try XCTUnwrap(session.window.attachedSheet), returnCode: .alertFirstButtonReturn) + let nextShown = await eventually { self.textField(in: session.window.attachedSheet?.contentView) != nil } + XCTAssertTrue(nextShown, "A dialog completion may immediately request the next dialog") + guard nextShown else { return } + let sheet = try XCTUnwrap(session.window.attachedSheet) + let field = try XCTUnwrap(textField(in: sheet.contentView)) + XCTAssertEqual(field.stringValue, "draft") + field.stringValue = "saved response" + session.window.endSheet(sheet, returnCode: .alertFirstButtonReturn) + let answered = await eventually { response == "saved response" } + XCTAssertTrue(answered) + } + + func testAlertAndPromptArePresentedInsteadOfSilentlyDismissed() async throws { + let session = try await openFixture("") + defer { session.window.close() } + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + try await webView.evaluateJavaScriptValue("setTimeout(() => { alert('Fixture notice'); window.result = prompt('Fixture input', 'default'); }, 0); null") + let alertShown = await eventually { session.window.attachedSheet != nil } + XCTAssertTrue(alertShown) + guard alertShown else { return } + session.window.endSheet(try XCTUnwrap(session.window.attachedSheet), returnCode: .alertFirstButtonReturn) + let promptShown = await eventually { + guard let sheet = session.window.attachedSheet else { return false } + return self.textField(in: sheet.contentView) != nil + } + XCTAssertTrue(promptShown) + guard promptShown else { return } + let sheet = try XCTUnwrap(session.window.attachedSheet) + let field = try XCTUnwrap(textField(in: sheet.contentView)) + XCTAssertEqual(field.stringValue, "default") + field.stringValue = "fixture response" + session.window.endSheet(sheet, returnCode: .alertFirstButtonReturn) + let resumed = await eventually { + (try? await webView.evaluateJavaScriptValue("window.result")) as? String == "fixture response" + } + XCTAssertTrue(resumed) + } + + func testNativeCloseReleasesTheSessionAndWebViewExactlyOnce() async throws { + var retainedSession: MailComposeWindowSession? + var closes = 0 + retainedSession = try await openFixture("
Fixture
") { + closes += 1 + retainedSession = nil + } + let window = try XCTUnwrap(retainedSession?.window) + weak var session = retainedSession + weak var webView = window.contentView as? WKWebView + window.performClose(nil) // The standard close action used by red close and Command-W. + let released = await eventually { session == nil && webView == nil } + XCTAssertTrue(released, "Session retained: \(session != nil); web view retained: \(webView != nil); closes: \(closes)") + XCTAssertNil(window.contentView) + XCTAssertFalse(window.isVisible) + window.close() + XCTAssertEqual(closes, 1) + } + + func testHidingEstablishedDraftEditorDoesNotCloseComposer() async throws { + var closes = 0 + let session = try await openFixture("
Unsaved fixture draft
") { closes += 1 } + defer { session.window.close() } + let view = try XCTUnwrap(session.window.contentView as? WKWebView) + try await Task.sleep(for: .milliseconds(700)) + try await view.evaluateJavaScriptValue("document.getElementById('editor').style.display = 'none'; null") + try await Task.sleep(for: .milliseconds(800)) + XCTAssertEqual(closes, 0) + XCTAssertTrue(session.window.isVisible) + } + + func testBriefStartupEditorDoesNotArmCompletionDuringLoading() async throws { + let session = try await openFixture(""" +
Startup placeholder
+ + """) + defer { session.window.close() } + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + let finished = await eventually { + (try? await webView.evaluateJavaScriptValue("window.startupFinished === true")) as? Bool == true + } + XCTAssertTrue(finished) + XCTAssertTrue(session.window.isVisible, "A transient startup editor must not arm inferred closure") + } + + func testEditorReplacementAndDisappearanceRequireExplicitProviderClose() async throws { + var closes = 0 + let session = try await openFixture("
Fixture signature
") { closes += 1 } + defer { session.window.close() } + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + try await Task.sleep(for: .milliseconds(700)) + // A brief detach, then a replacement with signature-like markup, is not completion. + try await webView.evaluateJavaScriptValue(""" + var editor = document.getElementById('editor'); + editor.remove(); + setTimeout(() => document.body.appendChild(editor), 100); + null + """) + try await Task.sleep(for: .milliseconds(700)) + XCTAssertTrue(session.window.isVisible) + try await webView.evaluateJavaScriptValue(""" + document.getElementById('editor').outerHTML = '

Fresh signature

'; + null + """) + try await Task.sleep(for: .milliseconds(700)) + XCTAssertTrue(session.window.isVisible, "An ambiguous replacement must preserve the draft") + XCTAssertEqual(closes, 0) + + try await webView.evaluateJavaScriptValue("document.getElementById('editor').remove(); null") + try await Task.sleep(for: .milliseconds(150)) + XCTAssertTrue(session.window.isVisible) + try await Task.sleep(for: .milliseconds(700)) + XCTAssertEqual(closes, 0, "Editor disappearance does not mean send or discard") + XCTAssertTrue(session.window.isVisible) + try await webView.evaluateJavaScriptValue("window.close(); null") + let closed = await eventually { closes == 1 } + XCTAssertTrue(closed, "An explicit provider close must finish the session") + XCTAssertFalse(session.window.isVisible) + } + + func testErrorPageRemainsOpenAndNativeCloseCancelsAnOutstandingDialog() async throws { + var closes = 0 + let session = try await openFixture("

Provider error fixture: no editor

") { closes += 1 } + defer { session.window.close() } + try await Task.sleep(for: .milliseconds(1100)) + XCTAssertTrue(session.window.isVisible) + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + try await webView.evaluateJavaScriptValue("setTimeout(() => confirm('Fixture confirmation'), 0); null") + let shown = await eventually { session.window.attachedSheet != nil } + XCTAssertTrue(shown) + guard shown else { return } + session.window.close() + XCTAssertEqual(closes, 1) + XCTAssertFalse(session.window.isVisible) + XCTAssertNil(session.window.contentView) + } + + func testSameOriginChildComposerCanCompleteButCrossOriginSignalsCannot() async throws { + let untrusted = try await ComposeFixtureServer(pages: ["/": """ + + """]) + defer { untrusted.stop() } + let trusted = try await ComposeFixtureServer(pages: ["/": """ + + + + """, "/child": """ +
Child fixture
+ + """]) + defer { trusted.stop() } + var closes = 0 + let session = try await openURL(trusted.url()) { closes += 1 } + defer { session.window.close() } + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + try await Task.sleep(for: .milliseconds(700)) + try await webView.evaluateJavaScriptValue("document.getElementById('untrusted').contentWindow.postMessage('try', '*'); null") + let attempted = await eventually { + (try? await webView.evaluateJavaScriptValue("window.attempted === true")) as? Bool == true + } + XCTAssertTrue(attempted, "Untrusted fixture must actually attempt to close the window") + try await Task.sleep(for: .milliseconds(700)) + XCTAssertTrue(session.window.isVisible) + XCTAssertEqual(closes, 0) + try await webView.evaluateJavaScriptValue("document.getElementById('trusted').contentWindow.postMessage('complete', '*'); null") + let closed = await eventually { closes == 1 } + XCTAssertTrue(closed, "Trusted child-frame close must reach the native window") + } + + func testAccountStoresStayIsolatedAndClosingOneRequestLeavesTheOthersUntouched() async throws { + let server = try await ComposeFixtureServer(pages: ["/": """ +
Fixture
+ + """]) + defer { server.stop() } + let personal = WKWebsiteDataStore.nonPersistent() + let work = WKWebsiteDataStore.nonPersistent() + for (store, marker) in [(personal, "personal"), (work, "work")] { + let cookie = try XCTUnwrap(HTTPCookie(properties: [ + .domain: "127.0.0.1", .path: "/", .name: "fixtureAccount", .value: marker + ])) + await store.httpCookieStore.setCookie(cookie) + } + let first = try await openURL(server.url(), dataStore: personal) + let second = try await openURL(server.url(), dataStore: personal) + let third = try await openURL(server.url(), dataStore: work) + defer { first.window.close(); second.window.close(); third.window.close() } + for (session, expected) in [(first, "personal"), (second, "personal"), (third, "work")] { + let view = try XCTUnwrap(session.window.contentView as? WKWebView) + let marker = try await view.evaluateJavaScriptValue("document.cookie") as? String + XCTAssertEqual(marker, "fixtureAccount=\(expected)") + } + let otherView = try XCTUnwrap(second.window.contentView as? WKWebView) + try await otherView.evaluateJavaScriptValue("window.preserved = 17; null") + first.window.performClose(nil) + XCTAssertTrue(second.window.isVisible) + XCTAssertTrue(third.window.isVisible) + let preserved = try await otherView.evaluateJavaScriptValue("window.preserved") as? Int + XCTAssertEqual(preserved, 17, "Closing another request must not reload this view") + } + + func testInitialRedirectAndDelayedEditorRemainOpen() async throws { + let server = try await ComposeFixtureServer(pages: ["/": """ +

Redirecting fixture

+ + """, "/compose": """ +

Loading fixture

+ + """]) + defer { server.stop() } + let session = try await openURL(server.url()) + defer { session.window.close() } + XCTAssertTrue(session.window.isVisible) + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + XCTAssertEqual(webView.url, server.url("/compose")) + let userAgent = try await webView.evaluateJavaScriptValue("navigator.userAgent") as? String + XCTAssertEqual(userAgent, UserAgentProvider.safariDefault) + } + + func testComposerUsesBrowserFilePickerPopupAndDownloadDelegates() async throws { + let session = try await openFixture("") + defer { session.window.close() } + let view = try XCTUnwrap(session.window.contentView as? WKWebView) + let delegate = try XCTUnwrap(view.uiDelegate as? WebViewCoordinator) + XCTAssertTrue(view.navigationDelegate === delegate) + XCTAssertTrue(delegate.responds(to: NSSelectorFromString("webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:"))) + XCTAssertTrue(delegate.responds(to: NSSelectorFromString("webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:"))) + XCTAssertTrue(delegate is WKDownloadDelegate) + XCTAssertFalse(delegate.opensServiceTabs, "A floating window must not open inaccessible service tabs") + let requested = await eventually { + if session.window.attachedSheet != nil { return true } + _ = try? await view.evaluateJavaScriptValue("document.getElementById('attachment').click(); null") + return session.window.attachedSheet != nil + } + XCTAssertTrue(requested, "The native attachment picker must actually open") + (session.window.attachedSheet as? NSOpenPanel)?.cancel(nil) + } + + func testProgrammaticMailtoCannotOpenAnotherDraft() async throws { + var requests: [URL] = [] + let server = try await ComposeFixtureServer(pages: ["/": ""]) + defer { server.stop() } + let session = MailComposeWindowSession(dataStore: .nonPersistent(), userAgent: UserAgentProvider.safariDefault, + title: "Fixture", url: server.url(), onMailRequest: { requests.append($0) }, onClose: {}) + session.show() + defer { session.window.close() } + let view = try XCTUnwrap(session.window.contentView as? WKWebView) + let ready = await eventually { (try? await view.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true } + XCTAssertTrue(ready) + try await view.evaluateJavaScriptValue("location.href = 'mailto:script@example.test'; null") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertTrue(requests.isEmpty) + XCTAssertEqual(view.url, server.url()) + } + + func testMailLinksReachRoutingOnceWithoutReplacingTheComposer() async throws { + let mail = "mailto:alice%2Btag@example.test?subject=Hello%20%E2%98%83&body=A%26B%3F%23" + let server = try await ComposeFixtureServer(pages: ["/": """ + Mail + New target + Web link + + """, "/next": "

Ordinary navigation

"]) + defer { server.stop() } + var received: [URL] = [] + let session = MailComposeWindowSession( + dataStore: .nonPersistent(), userAgent: UserAgentProvider.safariDefault, + title: "Mail routing fixture", url: server.url(), + onMailRequest: { received.append($0) }, onClose: {} + ) + session.show() + defer { session.window.close() } + let view = try XCTUnwrap(session.window.contentView as? WKWebView) + let ready = await eventually { + (try? await view.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true + } + XCTAssertTrue(ready) + for (index, id) in ["ordinary", "newTarget"].enumerated() { + try await view.evaluateJavaScriptValue("document.getElementById('\(id)').click()") + let delivered = await eventually { received.count == index + 1 } + XCTAssertTrue(delivered) + } + try await Task.sleep(for: .milliseconds(250)) + XCTAssertEqual(received.map(\.absoluteString), [mail, mail]) + XCTAssertEqual(view.url, server.url()) + XCTAssertTrue(session.window.isVisible) + try await view.evaluateJavaScriptValue("document.getElementById('web').click()") + let navigated = await eventually { view.url == server.url("/next") } + XCTAssertTrue(navigated, "Non-mail navigation must retain its existing behavior") + XCTAssertEqual(received.count, 2) + } + + func testApplicationRoutingQueuesRevalidatesAndRetainsIndependentComposers() throws { + let container = try ModelContainer( + for: Schema(versionedSchema: ChorusSchemaVCurrent.self), + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let stores = DataStoreManager(makeStore: { _ in .nonPersistent() }) + let app = AppState(modelContainer: container, dataStoreManager: stores) + let originalWindows = Set(NSApp.windows.map(ObjectIdentifier.init)) + func composers() -> [NSWindow] { + NSApp.windows.filter { !originalWindows.contains(ObjectIdentifier($0)) && $0.contentView is WKWebView } + } + defer { composers().forEach { $0.close() } } + var presentations = 0 + app.openMainWindow = { presentations += 1 } + let mail = try XCTUnwrap(URL(string: "mailto:fixture@example.test?subject=A%26B")) + app.enqueueMailLink(mail) + XCTAssertNotNil(app.mailLinkErrorMessage) + XCTAssertEqual(presentations, 1) + app.dismissMailLinkError(try XCTUnwrap(app.mailLinkError).id) + + func addAccount(_ label: String) -> ServiceInstance { + let service = ServiceInstance(label: label, url: "https://127.0.0.1:1") + service.mailtoHandlerTemplate = "https://127.0.0.1:1/compose?mail=%s" + service.mailtoHandlerOrigin = "https://127.0.0.1:1" + container.mainContext.insert(service) + return service + } + let first = addAccount("First") + try container.mainContext.save() + let selectedSpace = UUID() + app.selectedSpaceID = selectedSpace + app.enqueueMailLink(mail) + XCTAssertEqual(composers().count, 1) + XCTAssertEqual(presentations, 1, "Direct routing must not raise the main window") + XCTAssertTrue((composers().first?.contentView as? WKWebView)?.configuration.websiteDataStore === stores.dataStore(for: first)) + XCTAssertEqual(app.selectedSpaceID, selectedSpace) + XCTAssertNil(app.selectedServiceID) + + let second = addAccount("Second") + try container.mainContext.save() + app.enqueueMailLink(mail) + let cancelled = try XCTUnwrap(app.pendingMailLink) + app.enqueueMailLink(mail) + app.cancelMailLink(cancelled.id) + let next = try XCTUnwrap(app.pendingMailLink) + XCTAssertNotEqual(next.id, cancelled.id) + second.mailtoHandlerEnabled = false + app.chooseMailService(second.id, requestID: next.id) + XCTAssertNotNil(app.mailLinkErrorMessage) + XCTAssertEqual(composers().count, 1) + app.enqueueMailLink(mail) + app.dismissMailLinkError(try XCTUnwrap(app.mailLinkError).id) + XCTAssertEqual(composers().count, 2, "The next direct route creates an independent compose window") + + second.mailtoHandlerEnabled = true + app.enqueueMailLink(mail) + let deletedChoice = try XCTUnwrap(app.pendingMailLink) + let deletedID = second.id + container.mainContext.delete(second) + try container.mainContext.save() + app.chooseMailService(deletedID, requestID: deletedChoice.id) + XCTAssertNotNil(app.mailLinkErrorMessage) + app.dismissMailLinkError(try XCTUnwrap(app.mailLinkError).id) + + app.appLockEnabled = true + app.lock() + app.enqueueMailLink(mail) + app.enqueueMailLink(mail) + XCTAssertNil(app.pendingMailLink) + XCTAssertNil(app.mailLinkErrorMessage) + XCTAssertEqual(composers().count, 2) + app.isLocked = false + XCTAssertEqual(composers().count, 4) + XCTAssertEqual(app.selectedSpaceID, selectedSpace) + XCTAssertTrue(composers().allSatisfy { $0.isVisible }) + } + + func testComposerClickUsesSystemHandoffAndOSDeliveryToApplicationChooser() async throws { + let server = try await ComposeFixtureServer(pages: ["/": """ + Mail + + """]) + defer { server.stop() } + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + // Model the OS boundary when the user has chosen Chorus as default. + app.webViewPool.systemLinkHandler = { [weak app] url in app?.enqueueMailLink(url) } + let services = ["First", "Second"].map { label in + let service = ServiceInstance(label: label, url: "https://127.0.0.1:1") + service.mailtoHandlerOrigin = service.url + service.mailtoHandlerTemplate = service.url + "/compose?mail=%s" + container.mainContext.insert(service) + return service + } + try container.mainContext.save() + let oldWindows = Set(NSApp.windows.map(ObjectIdentifier.init)) + app.webViewPool.openMailComposer(for: services[0], at: server.url()) + let window = try XCTUnwrap(NSApp.windows.first { !oldWindows.contains(ObjectIdentifier($0)) && $0.contentView is WKWebView }) + defer { + NSApp.windows.filter { !oldWindows.contains(ObjectIdentifier($0)) && $0.contentView is WKWebView }.forEach { $0.close() } + } + let inbox = app.webViewPool.webView(for: services[0]) + inbox.loadHTMLString("", baseURL: server.url()) + defer { app.webViewPool.removeWebView(for: services[0].id) } + let inboxReady = await eventually { (try? await inbox.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true } + XCTAssertTrue(inboxReady) + let selectedSpace = UUID() + app.selectedSpaceID = selectedSpace + app.selectedServiceID = services[0].id + let view = try XCTUnwrap(window.contentView as? WKWebView) + let ready = await eventually { (try? await view.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true } + XCTAssertTrue(ready) + try await view.evaluateJavaScriptValue("document.getElementById('mail').click()") + let choosing = await eventually { app.pendingMailLink != nil } + XCTAssertTrue(choosing) + XCTAssertEqual(app.pendingMailLink?.candidates.count, 2) + XCTAssertEqual(view.url, server.url()) + XCTAssertTrue(window.isVisible) + app.cancelMailLink(try XCTUnwrap(app.pendingMailLink).id) + XCTAssertNil(app.pendingMailLink, "A single click must enqueue exactly one request") + XCTAssertNil(app.mailLinkErrorMessage) + + try await view.evaluateJavaScriptValue("document.getElementById('mail').click()") + let choosingAgain = await eventually { app.pendingMailLink != nil } + XCTAssertTrue(choosingAgain) + app.chooseMailService(services[1].id, requestID: try XCTUnwrap(app.pendingMailLink).id) + let composers = NSApp.windows.filter { !oldWindows.contains(ObjectIdentifier($0)) && $0.contentView is WKWebView } + XCTAssertEqual(composers.count, 2) + let destination = try XCTUnwrap(composers.first { $0 !== window }?.contentView as? WKWebView) + XCTAssertTrue(destination.configuration.websiteDataStore === app.dataStoreManager.dataStore(for: services[1])) + XCTAssertTrue(window.isVisible) + XCTAssertEqual(view.url, server.url()) + XCTAssertEqual(app.selectedSpaceID, selectedSpace) + XCTAssertEqual(app.selectedServiceID, services[0].id) + let marker = try await inbox.evaluateJavaScriptValue("window.draftMarker") as? Int + XCTAssertEqual(marker, 17, "Composing must not navigate or reload the inbox") + } + + func testOrdinaryLinkSelectsFirstLinkedSpaceEvenWhenCurrentSpaceContainsService() throws { + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + let service = ServiceInstance(label: "Linked", url: "https://127.0.0.1:1") + container.mainContext.insert(service) + for name in ["First", "Second"] { + let space = Space(name: name, emoji: "", sortOrder: 0) + container.mainContext.insert(space) + let link = SpaceServiceLink(sortOrder: 0, space: space, service: service) + if link.modelContext == nil { container.mainContext.insert(link) } + } + try container.mainContext.save() + // Materialize the saved relationship before observing its baseline order. + _ = try container.mainContext.fetch(FetchDescriptor()) + let firstSpace = try XCTUnwrap(service.spaceLinks.first?.liveSpace) + app.selectedSpaceID = try XCTUnwrap(service.spaceLinks.last?.liveSpace).id + app.webViewPool.externalLinkHandler?(try XCTUnwrap(URL(string: service.url + "/link")), nil) + XCTAssertEqual(app.selectedSpaceID, firstSpace.id) + XCTAssertEqual(app.selectedServiceID, service.id) + app.webViewPool.removeWebView(for: service.id) + } + + func testIsolatedApplicationAcceptsConstructedWebKitMailDeclaration() async throws { + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + let service = ServiceInstance(label: "Discovery fixture", url: "https://fixture.example.test") + container.mainContext.insert(service) + try container.mainContext.save() + let inbox = app.webViewPool.webView(for: service) + defer { app.webViewPool.removeWebView(for: service.id) } + inbox.loadHTMLString("", baseURL: URL(string: service.url)) + let ready = await eventually { (try? await inbox.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true } + XCTAssertTrue(ready) + try await inbox.evaluateJavaScriptValue("try { navigator.registerProtocolHandler('mailto', '/compose?mail=%s', 'Fixture'); } catch (_) {}") + let discovered = await eventually { app.pendingMailHandlerApproval != nil } + XCTAssertTrue(discovered, "Isolated setup must install the production discovery connection") + XCTAssertNil(service.mailtoHandler) + app.answerMailHandlerApproval(try XCTUnwrap(app.pendingMailHandlerApproval).id, allow: true) + XCTAssertEqual(service.mailtoHandlerTemplate, "https://fixture.example.test/compose?mail=%s") + XCTAssertEqual(service.mailtoHandlerOrigin, "https://fixture.example.test") + } + + func testSubframeDeclarationReachesTheBridgeWithItsFrameState() async throws { + let scripts = UserScriptManager() + let stores = DataStoreManager(makeStore: { _ in .nonPersistent() }) + var declarations: [MailHandlerDeclaration] = [] + scripts.onMailHandlerDeclaration = { declarations.append($0) } + let pool = WebViewPool( + dataStoreManager: stores, userScriptManager: scripts, + contentBlocker: ContentBlockerManager(), loadMailProbe: { _, _ in } + ) + let service = ServiceInstance(label: "Subframe fixture", url: "https://subframe.example.test") + defer { pool.removeWebView(for: service.id) } + let webView = pool.webView(for: service) + webView.loadHTMLString( + subframeDeclarationPage( + template: "https://subframe.example.test/compose?mail=%s", + waitsForDelivery: false), + baseURL: URL(string: service.url)) + let ready = await fixturePageIsReady(webView) + XCTAssertTrue(ready) + let reported = await eventually { declarations.count == 1 } + XCTAssertTrue(reported, "The bridge must report what the subframe sent") + XCTAssertEqual(declarations.first?.serviceID, service.id) + XCTAssertEqual(declarations.first?.isMainFrame, false) + } + + func testSubframeDeclarationCannotPersistAHandlerAndDoesNotShadowTheMainFrame() async throws { + let container = try ModelContainer( + for: Schema(versionedSchema: ChorusSchemaVCurrent.self), + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + let service = ServiceInstance(label: "Subframe rejection fixture", url: "https://fixture.example.test") + container.mainContext.insert(service) + try container.mainContext.save() + let inbox = app.webViewPool.webView(for: service) + defer { app.webViewPool.removeWebView(for: service.id) } + let delivery = SubframeDeliveryObservation() + inbox.configuration.userContentController.add(delivery, name: "fixtureSettled") + defer { inbox.configuration.userContentController.removeScriptMessageHandler(forName: "fixtureSettled") } + inbox.loadHTMLString( + subframeDeclarationPage( + template: "https://fixture.example.test/subframe?mail=%s", + waitsForDelivery: true), + baseURL: URL(string: service.url)) + let ready = await fixturePageIsReady(inbox) + XCTAssertTrue(ready) + let subframeProcessed = await eventually { delivery.count == 1 } + XCTAssertTrue(subframeProcessed, "The subframe declaration must be handled before the intermediate assertion") + XCTAssertNil(service.mailtoHandlerTemplate, "A subframe declaration must not be stored") + XCTAssertNil(service.mailtoHandlerOrigin) + XCTAssertNil(app.pendingMailHandlerApproval, "The subframe cannot propose a handler either") + try await inbox.evaluateJavaScriptValue("try { navigator.registerProtocolHandler('mailto', '/compose?mail=%s', 'Fixture'); } catch (_) {}") + let discovered = await eventually { app.pendingMailHandlerApproval != nil } + XCTAssertTrue(discovered, "The main frame declaration must still be proposed") + app.answerMailHandlerApproval(try XCTUnwrap(app.pendingMailHandlerApproval).id, allow: true) + XCTAssertEqual( + service.mailtoHandlerTemplate, + "https://fixture.example.test/compose?mail=%s", + "The main frame declaration must not be shadowed by the rejected subframe one") + XCTAssertEqual(service.mailtoHandlerOrigin, "https://fixture.example.test") + } + + func testRepeatedNavigationCannotExtendProbeLifetimeOrBlockNextAccount() async throws { + let scripts = UserScriptManager() + var loaded: [WKWebView] = [] + let pool = WebViewPool( + dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() }), + userScriptManager: scripts, contentBlocker: ContentBlockerManager(), + loadMailProbe: { view, _ in loaded.append(view) }, + mailProbeLifetime: .milliseconds(500), mailProbeObservationWindow: .seconds(3)) + let first = ServiceInstance(label: "First", url: "https://first.example.test") + let second = ServiceInstance(label: "Second", url: "https://second.example.test") + defer { pool.removeWebView(for: first.id); pool.removeWebView(for: second.id) } + pool.probeMailHandler(for: first, userRequested: true) + pool.probeMailHandler(for: second, userRequested: true) + let view = try XCTUnwrap(loaded.first) + for _ in 0..<10 { + view.navigationDelegate?.webView?(view, didFinish: nil) + try await Task.sleep(for: .milliseconds(100)) + if loaded.count == 2 { break } + } + XCTAssertEqual(loaded.count, 2, "Navigations cannot cancel the absolute deadline") + XCTAssertNil(view.navigationDelegate, "The expired probe releases its delegate") + } + + func testDiscoveryCompletionTimeoutAndCancellationAdvanceSerialProbes() async throws { + for ending in ["completion", "timeout", "cancellation"] { + let scripts = UserScriptManager() + let stores = DataStoreManager(makeStore: { _ in .nonPersistent() }) + var loaded: [WKWebView] = [] + var declarations: [UUID] = [] + let pool = WebViewPool( + dataStoreManager: stores, userScriptManager: scripts, + contentBlocker: ContentBlockerManager(), + loadMailProbe: { view, url in + loaded.append(view) + view.loadHTMLString("", baseURL: url) + } + ) + scripts.onMailHandlerDeclaration = { declarations.append($0.serviceID) } + let first = ServiceInstance(label: "First", url: "https://first.example.test") + let second = ServiceInstance(label: "Second", url: "https://second.example.test") + pool.probeMailHandler(for: first, userRequested: true) + pool.probeMailHandler(for: second, userRequested: true) + XCTAssertEqual(loaded.count, 1) + XCTAssertTrue(loaded[0].configuration.websiteDataStore === stores.dataStore(for: first)) + XCTAssertEqual(loaded[0].customUserAgent, UserAgentProvider.safariDefault) + let ready = await eventually { (try? await loaded[0].evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true } + XCTAssertTrue(ready) + switch ending { + case "completion": pool.completeMailHandlerProbe(for: first.id) + case "cancellation": pool.removeWebView(for: first.id) + default: break // The normal post-navigation timeout must advance it. + } + let advanced = await eventually { loaded.count == 2 } + XCTAssertTrue(advanced) + guard loaded.count == 2 else { continue } + XCTAssertTrue(loaded[1].configuration.websiteDataStore === stores.dataStore(for: second)) + let secondReady = await eventually { (try? await loaded[1].evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true } + XCTAssertTrue(secondReady) + let declaration = "try { navigator.registerProtocolHandler('mailto', '/compose?mail=%s', 'Fixture'); } catch (_) {}" + try await loaded[0].evaluateJavaScriptValue(declaration) + try await loaded[1].evaluateJavaScriptValue(declaration) + let published = await eventually { declarations == [second.id] } + XCTAssertTrue(published, "Abandoned probes cannot publish late declarations") + pool.completeMailHandlerProbe(for: second.id) + } + } + + func testQuitPageSnapshotIncludesFloatingDraftWithoutLoadingItsInbox() throws { + var composed: WKWebView? + var probed: WKWebView? + let pool = WebViewPool( + dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() }), + userScriptManager: UserScriptManager(), contentBlocker: ContentBlockerManager(), + loadMailProbe: { view, _ in probed = view }, + loadMailComposer: { view, _ in composed = view }) + let service = ServiceInstance(label: "Account", url: "https://fixture.example.test") + defer { pool.removeWebView(for: service.id) } + pool.openMailComposer(for: service, at: try XCTUnwrap(URL(string: service.url + "/compose"))) + let draft = try XCTUnwrap(composed) + XCTAssertNil(pool.liveWebView(for: service.id), "Composing must not create an inbox") + XCTAssertTrue(pool.liveWebViews.contains { $0 === draft }, "Quit must give floating drafts the same bounded grace") + pool.probeMailHandler(for: service, userRequested: true) + let probe = try XCTUnwrap(probed) + XCTAssertFalse(pool.liveWebViews.contains { $0 === probe }, "Discovery owns no user draft") + draft.window?.close() + XCTAssertTrue(pool.liveWebViews.isEmpty, "Closed drafts must leave the quit snapshot") + } + + func testLockCancelsActiveAndQueuedCompatibilityLoads() { + var loaded: [URL] = [] + let pool = WebViewPool( + dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() }), + userScriptManager: UserScriptManager(), contentBlocker: ContentBlockerManager(), + loadMailProbe: { _, url in loaded.append(url) }) + let accounts = ["first", "queued", "after-unlock"].map { + ServiceInstance(label: $0, url: "https://\($0).example.test") + } + defer { accounts.forEach { pool.removeWebView(for: $0.id) } } + pool.probeMailHandler(for: accounts[0], userRequested: true) + pool.probeMailHandler(for: accounts[1], userRequested: true) + pool.setMailComposersLocked(true) + pool.setMailComposersLocked(false) + pool.probeMailHandler(for: accounts[2], userRequested: true) + XCTAssertEqual(loaded, [URL(string: accounts[0].url)!, URL(string: accounts[2].url)!], + "Lock discards hidden loads without restarting the waiting account") + } + + func testExplicitRetryAfterSignInIsNotSilentlyThrottled() { + var loaded: [URL] = [] + let pool = WebViewPool( + dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() }), + userScriptManager: UserScriptManager(), contentBlocker: ContentBlockerManager(), + loadMailProbe: { _, url in loaded.append(url) }) + let service = ServiceInstance(label: "Account", url: "https://fixture.example.test") + defer { pool.removeWebView(for: service.id) } + pool.probeMailHandler(for: service, userRequested: true) + pool.probeMailHandler(for: service, userRequested: true) + XCTAssertEqual(loaded.count, 1, "Repeated clicks still coalesce an active probe") + pool.completeMailHandlerProbe(for: service.id) + pool.probeMailHandler(for: service, userRequested: true) + XCTAssertEqual(loaded.count, 2, "A deliberate retry starts a fresh bounded attempt") + } + + func testImplicitProbeRequestsNeverLoadAService() { + var loads = 0 + let pool = WebViewPool( + dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() }), + userScriptManager: UserScriptManager(), contentBlocker: ContentBlockerManager(), + loadMailProbe: { _, _ in loads += 1 }) + let service = ServiceInstance(label: "Gmail", url: "https://mail.google.com/mail/u/0/") + for _ in 0..<3 { pool.probeMailHandler(for: service) } + XCTAssertEqual(loads, 0) + } + + func testStaleChooserActionsCannotExposeErrorsWhileLockedOrReplaceNewerRequests() throws { + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + for label in ["First", "Second"] { + let service = ServiceInstance(label: label, url: "https://127.0.0.1:1") + service.mailtoHandlerOrigin = service.url + service.mailtoHandlerTemplate = service.url + "/compose?mail=%s" + container.mainContext.insert(service) + } + try container.mainContext.save() + app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:first@example.test"))) + let stale = try XCTUnwrap(app.pendingMailLink) + let account = try XCTUnwrap(stale.candidates.first).serviceID + app.appLockEnabled = true + app.lock() + app.chooseMailService(account, requestID: stale.id) + XCTAssertNil(app.mailLinkErrorMessage) + XCTAssertNil(app.pendingMailLink) + app.isLocked = false + let resumed = try XCTUnwrap(app.pendingMailLink) + app.chooseMailService(account, requestID: stale.id) + XCTAssertEqual(app.pendingMailLink?.id, resumed.id) + XCTAssertNil(app.mailLinkErrorMessage) + app.cancelMailLink(resumed.id) + XCTAssertNil(app.pendingMailLink) + } + + private func openURL(_ url: URL, dataStore: WKWebsiteDataStore = .nonPersistent(), onClose: @escaping () -> Void = {}) async throws -> MailComposeWindowSession { + let session = MailComposeWindowSession( + dataStore: dataStore, userAgent: UserAgentProvider.safariDefault, + title: "Compose fixture", url: url, onClose: onClose + ) + session.show() + let webView = try XCTUnwrap(session.window.contentView as? WKWebView) + let ready = await eventually { + (try? await webView.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true + } + XCTAssertTrue(ready) + return session + } + + private func textField(in view: NSView?) -> NSTextField? { + if let field = view as? NSTextField, field.isEditable { return field } + return view?.subviews.compactMap { textField(in: $0) }.first + } + + private func subframeDeclarationPage(template: String, waitsForDelivery: Bool) -> String { + let settle = waitsForDelivery + ? "childWindow.webkit.messageHandlers.fixtureSettled.postMessage(null);" + : "" + return """ + + + + + """ + } + + private func fixturePageIsReady(_ webView: WKWebView) async -> Bool { + await eventually { + (try? await webView.evaluateJavaScriptValue("window.fixtureReady === true")) as? Bool == true + } + } + + private func openFixture(_ html: String, onClose: @escaping () -> Void = {}) async throws -> MailComposeWindowSession { + let fixture = FileManager.default.temporaryDirectory.appendingPathComponent("compose-\(UUID()).html") + try ("" + html + "") + .write(to: fixture, atomically: true, encoding: .utf8) + addTeardownBlock { try? FileManager.default.removeItem(at: fixture) } + return try await openURL(fixture, onClose: onClose) + } +} + +/// Counts delivery markers the fixture page posts from its subframe, so a test +/// can wait until an earlier declaration from the same frame was handled. +@MainActor +private final class SubframeDeliveryObservation: NSObject, WKScriptMessageHandler { + private(set) var count = 0 + + nonisolated func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + MainActor.assumeIsolated { count += 1 } + } +} diff --git a/ChorusTests/MailLinkRouterTests.swift b/ChorusTests/MailLinkRouterTests.swift new file mode 100644 index 0000000..c2ffdd5 --- /dev/null +++ b/ChorusTests/MailLinkRouterTests.swift @@ -0,0 +1,410 @@ +import XCTest +import SwiftData +import JavaScriptCore +@testable import Chorus + +@MainActor +final class MailLinkRouterTests: XCTestCase { + private let gmailID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private let outlookID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + + func testValidDeclarationRoutesOneServiceAndPreservesTheWholeMailURL() throws { + let request = try XCTUnwrap(URL(string: "mailto:one@example.com,two@example.com?cc=c@example.com&bcc=b@example.com&subject=Hello%20%E2%98%83&body=first%20line%0Asecond%20line%20%26%20more")) + let registration = try XCTUnwrap(MailLinkRouter.registration( + account: MailLinkAccount(serviceID: gmailID, label: "Personal Gmail", providerLabel: "Gmail", spaceLabels: ["Personal"]), + serviceURL: "https://mail.google.com/mail/u/0/", + protocolName: "MAILTO", + handlerTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + declaringPageURL: "https://mail.google.com/mail/u/0/", + isMainFrame: true, + enabled: true + )) + + let outcome = MailLinkRouter.route(request, registrations: [registration]) + guard case .open(let destination) = outcome else { + return XCTFail("Expected direct open, got \(outcome)") + } + XCTAssertEqual(destination.serviceID, gmailID) + let encoded = try XCTUnwrap(destination.url.absoluteString.components(separatedBy: "url=").last) + XCTAssertEqual(encoded.removingPercentEncoding, request.absoluteString) + } + + func testSeveralDistinctInstancesRequireAChoiceAndDuplicateInstanceIsRemoved() throws { + let gmail = try makeRegistration(id: gmailID, label: "Work", host: "mail.google.com") + let outlook = try makeRegistration(id: outlookID, label: "Work", host: "outlook.office.com") + let request = try XCTUnwrap(URL(string: "mailto:person@example.com")) + + let outcome = MailLinkRouter.route(request, registrations: [gmail, gmail, outlook]) + guard case .choose(let candidates) = outcome else { + return XCTFail("Expected chooser, got \(outcome)") + } + XCTAssertEqual(candidates.map(\.serviceID), [gmailID, outlookID]) + XCTAssertEqual(candidates.map(\.label), ["Work", "Work"]) + } + + func testIneligibleRegistrationDoesNotShadowAnEligibleOneForTheSameService() throws { + let valid = try makeRegistration(id: gmailID, label: "Mail", host: "mail.example.com") + let ineligible = MailLinkRegistration( + account: valid.account, + serviceURL: valid.serviceURL, + handlerTemplate: "https://other.example.com/compose?url=%s", + declaringOrigin: valid.declaringOrigin, + enabled: true + ) + let request = try XCTUnwrap(URL(string: "mailto:person@example.com")) + + guard case .open(let destination) = MailLinkRouter.route(request, registrations: [ineligible, valid]) else { + return XCTFail("An ineligible first registration must not shadow the eligible one") + } + XCTAssertEqual(destination.serviceID, gmailID) + } + + func testChooserPreservesDistinctIdentityAndDisplayContextForDuplicateLabels() throws { + let first = try XCTUnwrap(MailLinkRouter.registration( + account: MailLinkAccount(serviceID: gmailID, label: "Shared", providerLabel: "Shared", spaceLabels: ["", "Shared", "Personal", "Work"]), + serviceURL: "https://mail.example.test", protocolName: "mailto", handlerTemplate: "/compose?mail=%s", + declaringPageURL: "https://mail.example.test/inbox", isMainFrame: true, enabled: true + )) + let second = try XCTUnwrap(MailLinkRouter.registration( + account: MailLinkAccount(serviceID: outlookID, label: "Shared", providerLabel: "Fixture Mail", spaceLabels: ["Other"]), + serviceURL: "https://mail.example.test", protocolName: "mailto", handlerTemplate: "/compose?mail=%s", + declaringPageURL: "https://mail.example.test/inbox", isMainFrame: true, enabled: true + )) + guard case .choose(let candidates) = MailLinkRouter.route(URL(string: "mailto:fixture@example.test")!, registrations: [first, second, first]) else { + return XCTFail("Distinct instances with duplicate labels must require a choice") + } + XCTAssertEqual(candidates.map(\.id), [gmailID, outlookID]) + XCTAssertEqual(candidates.map(\.label), ["Shared", "Shared"]) + XCTAssertEqual(candidates.map(\.providerLabel), ["Shared", "Fixture Mail"]) + XCTAssertEqual(candidates.map(\.spaceLabels), [["", "Shared", "Personal", "Work"], ["Other"]]) + XCTAssertEqual(candidates.map(\.chooserSubtitle), ["Personal · Work", "Fixture Mail · Other"]) + } + + func testStoredTemplatesAreRevalidatedWithoutNormalizingUntrustedOriginStrings() throws { + for (template, origin) in [ + ("https://mail.example.test/compose", "https://mail.example.test"), + ("https://mail.example.test/compose?a=%s&b=%s", "https://mail.example.test"), + ("http://mail.example.test/compose?mail=%s", "https://mail.example.test"), + ("https://other.example.test/compose?mail=%s", "https://mail.example.test"), + ("https://mail.example.test/compose?mail=%s", "https://mail.example.test/untrusted-path") + ] { + let registration = MailLinkRegistration( + account: MailLinkAccount(serviceID: gmailID, label: "Fixture", providerLabel: nil, spaceLabels: []), + serviceURL: "https://mail.example.test", handlerTemplate: template, declaringOrigin: origin, enabled: true + ) + let mail = try XCTUnwrap(URL(string: "mailto:fixture@example.test")) + XCTAssertEqual(MailLinkRouter.route(mail, registrations: [registration]), .noEligibleService) + XCTAssertNil(MailLinkRouter.destination(for: mail, serviceID: gmailID, registrations: [registration])) + } + } + + func testNoEligibleAndInvalidRequestAreDifferentOutcomes() throws { + let disabled = try makeRegistration(id: gmailID, label: "Gmail", host: "mail.google.com", enabled: false) + XCTAssertEqual( + MailLinkRouter.route(URL(string: "mailto:a@example.com")!, registrations: [disabled]), + .noEligibleService + ) + XCTAssertEqual( + MailLinkRouter.route(URL(string: "tel:+15551212")!, registrations: [disabled]), + .rejectInvalidRequest + ) + } + + func testDeclarationValidationRejectsEveryUntrustedShape() throws { + func accepted( + page: String = "https://mail.example.com/inbox", + service: String = "https://mail.example.com/", + proto: String = "mailto", + template: String = "https://mail.example.com/compose?url=%s", + main: Bool = true + ) -> Bool { + MailLinkRouter.registration( + account: MailLinkAccount(serviceID: gmailID, label: "Mail", providerLabel: nil, spaceLabels: []), + serviceURL: service, protocolName: proto, handlerTemplate: template, + declaringPageURL: page, isMainFrame: main, enabled: true + ) != nil + } + + XCTAssertTrue(accepted()) + XCTAssertFalse(accepted(main: false)) + XCTAssertFalse(accepted(proto: "web+mail")) + XCTAssertFalse(accepted(template: "http://mail.example.com/compose?url=%s")) + XCTAssertFalse(accepted(template: "https://evil.example/compose?url=%s")) + XCTAssertFalse(accepted(template: "https://mail.example.com/compose")) + XCTAssertFalse(accepted(template: "https://mail.example.com/compose?a=%s&b=%s")) + XCTAssertFalse(accepted(template: "://not a URL %s")) + XCTAssertFalse(accepted(page: "https://sub.mail.example.com", service: "https://mail.example.com")) + XCTAssertFalse(accepted(page: "https://mail.example.com", service: "https://mail.example.com:444")) + } + + func testRelativeTemplatePreservesPlaceholderSentinelTextInTemplateAndBase() throws { + let marker = "CHORUS_MAILTO_PLACEHOLDER_8E7A1D" + for (template, page, expected) in [ + ("/compose?tag=\(marker)&url=%s", "https://mail.example.com/inbox", + "https://mail.example.com/compose?tag=\(marker)&url=%s"), + ("?url=%s", "https://mail.example.com/\(marker)", + "https://mail.example.com/\(marker)?url=%s") + ] { + let registration = try XCTUnwrap(MailLinkRouter.registration( + account: MailLinkAccount(serviceID: gmailID, label: "Mail", providerLabel: nil, spaceLabels: []), + serviceURL: "https://mail.example.com/", protocolName: "mailto", + handlerTemplate: template, declaringPageURL: page, isMainFrame: true, enabled: true)) + XCTAssertEqual(registration.handlerTemplate, expected) + let destination = try XCTUnwrap(MailLinkRouter.destination( + for: try XCTUnwrap(URL(string: "mailto:alice@example.com")), + serviceID: gmailID, registrations: [registration])) + XCTAssertTrue(destination.url.absoluteString.contains(marker)) + XCTAssertTrue(destination.url.absoluteString.contains("mailto%3Aalice%40example.com")) + } + } + + func testRelativeTemplateMayUseAnEncodedLeadingColon() { + let registration = MailLinkRouter.registration( + account: MailLinkAccount(serviceID: gmailID, label: "Mail", providerLabel: nil, spaceLabels: []), + serviceURL: "https://mail.example.com/", protocolName: "mailto", + handlerTemplate: "%3Acompose?url=%s", declaringPageURL: "https://mail.example.com/inbox", + isMainFrame: true, enabled: true) + XCTAssertEqual(registration?.handlerTemplate, "https://mail.example.com/%3Acompose?url=%s") + } + + func testDefaultHTTPSPortIsTheSameOrigin() { + XCTAssertNotNil(MailLinkRouter.registration( + account: MailLinkAccount(serviceID: gmailID, label: "Mail", providerLabel: nil, spaceLabels: []), + serviceURL: "https://mail.example.com:443/home", protocolName: "mailto", + handlerTemplate: "https://mail.example.com/compose?url=%s", + declaringPageURL: "https://mail.example.com/inbox", isMainFrame: true, enabled: true + )) + } + + func testStoredRegistrationBecomesIneligibleWhenServiceOriginChanges() throws { + let registration = try makeRegistration(id: gmailID, label: "Mail", host: "mail.example.com") + .withServiceURL("https://calendar.example.com") + XCTAssertEqual( + MailLinkRouter.route(URL(string: "mailto:a@example.com")!, registrations: [registration]), + .noEligibleService + ) + } + + func testWindowCloseShimReportsAndPreservesThePagesCloseCall() throws { + let context = try XCTUnwrap(JSContext()) + context.evaluateScript(""" + var messages = []; + var originalCloseCalls = 0; + this.window = this; + window.close = function() { originalCloseCalls += 1; return 17; }; + window.webkit = { messageHandlers: { chorusMailHandler: { + postMessage: function(value) { messages.push(value); } + } } }; + """) + context.evaluateScript(UserScriptManager.makeWindowCloseInterceptionScript()) + let result = context.evaluateScript("window.close()") + XCTAssertEqual(result?.toInt32(), 17) + XCTAssertEqual(context.evaluateScript("originalCloseCalls")?.toInt32(), 1) + XCTAssertEqual(context.evaluateScript("messages.length")?.toInt32(), 1) + XCTAssertTrue(context.evaluateScript("messages[0].windowClose")?.toBool() == true) + } + + func testRegistrationShimPreservesNativeFunctionAndCapturesArguments() throws { + let context = try XCTUnwrap(JSContext()) + context.evaluateScript(""" + var captured = []; + var nativeArgs = []; + this.window = this; + this.navigator = { + registerProtocolHandler: function() { + nativeArgs = Array.prototype.slice.call(arguments); + return 42; + } + }; + window.webkit = { messageHandlers: { chorusMailHandler: { + postMessage: function(value) { captured.push(value); } + } } }; + """) + context.evaluateScript(UserScriptManager.makeMailHandlerRegistrationScript()) + let result = context.evaluateScript("navigator.registerProtocolHandler('mailto', '/compose?u=%s', 'Mail')") + XCTAssertEqual(result?.toInt32(), 42) + XCTAssertEqual(context.evaluateScript("nativeArgs.join('|')")?.toString(), "mailto|/compose?u=%s|Mail") + XCTAssertEqual(context.evaluateScript("captured[0].protocol")?.toString(), "mailto") + XCTAssertEqual(context.evaluateScript("captured[0].template")?.toString(), "/compose?u=%s") + } + + func testRegistrationShimReportsOnceAndPreservesThrowingNativeCall() throws { + let context = try XCTUnwrap(JSContext()) + context.evaluateScript(""" + var captured = [], nativeArgs = [], nativeReceiver = null, calls = 0, order = []; + var nativeError = new Error('Synthetic native registration failure'); + this.window = this; + this.navigator = { + registerProtocolHandler: function() { + calls += 1; + order.push('native'); + nativeReceiver = this; + nativeArgs = Array.prototype.slice.call(arguments); + throw nativeError; + } + }; + window.webkit = { messageHandlers: { chorusMailHandler: { + postMessage: function(value) { order.push('bridge'); captured.push(value); } + } } }; + """) + context.evaluateScript(UserScriptManager.makeMailHandlerRegistrationScript()) + context.evaluateScript(""" + var caught = null; + var extra = { fixture: true }; + try { navigator.registerProtocolHandler('mailto', '/compose?u=%s', 'Fixture', extra); } + catch (error) { caught = error; } + """) + XCTAssertTrue(context.evaluateScript("caught === nativeError")?.toBool() == true) + XCTAssertTrue(context.evaluateScript("nativeReceiver === navigator")?.toBool() == true) + XCTAssertEqual(context.evaluateScript("nativeArgs.length")?.toInt32(), 4) + XCTAssertEqual(context.evaluateScript("nativeArgs.slice(0, 3).join('|')")?.toString(), "mailto|/compose?u=%s|Fixture") + XCTAssertTrue(context.evaluateScript("nativeArgs[3] === extra")?.toBool() == true) + XCTAssertEqual(context.evaluateScript("calls")?.toInt32(), 1) + XCTAssertEqual(context.evaluateScript("captured.length")?.toInt32(), 1) + XCTAssertEqual(context.evaluateScript("captured[0].protocol")?.toString(), "mailto") + XCTAssertEqual(context.evaluateScript("captured[0].template")?.toString(), "/compose?u=%s") + XCTAssertEqual(context.evaluateScript("order.join('|')")?.toString(), "bridge|native") + XCTAssertNil(context.exception) + } + + func testRegistrationShimSuppliesMailtoSurfaceWhenNativeFunctionIsAbsent() throws { + let context = try XCTUnwrap(JSContext()) + context.evaluateScript(""" + var captured = []; + this.window = this; + this.navigator = {}; + window.webkit = { messageHandlers: { chorusMailHandler: { + postMessage: function(value) { captured.push(value); } + } } }; + """) + context.evaluateScript(UserScriptManager.makeMailHandlerRegistrationScript()) + context.evaluateScript("navigator.registerProtocolHandler('mailto', 'https://mail.example/compose?u=%s')") + XCTAssertEqual(context.evaluateScript("captured.length")?.toInt32(), 1) + XCTAssertEqual(context.evaluateScript("captured[0].protocol")?.toString(), "mailto") + } + + func testWebNavigationUsesSystemDefaultOnlyForClickedMailto() { + let mail = URL(string: "mailto:person@example.com")! + XCTAssertEqual( + WebViewCoordinator.nonWebNavigationAction(for: mail, navigationType: .linkActivated), + .openSystem + ) + XCTAssertEqual( + WebViewCoordinator.nonWebNavigationAction(for: mail, navigationType: .other), + .cancel + ) + XCTAssertEqual( + WebViewCoordinator.nonWebNavigationAction( + for: URL(string: "tel:+15551212")!, navigationType: .linkActivated), + .openSystem + ) + XCTAssertNil(WebViewCoordinator.nonWebNavigationAction( + for: URL(string: "https://example.com")!, navigationType: .linkActivated)) + } + + func testMailHandlerProbeQueueRunsOneServiceAtATimeAndDeduplicates() { + let first = UUID(), second = UUID() + var queue = MailHandlerProbeQueue() + XCTAssertTrue(queue.enqueue(first)) + XCTAssertFalse(queue.enqueue(first)) + XCTAssertTrue(queue.enqueue(second)) + XCTAssertEqual(queue.beginNext(), first) + XCTAssertNil(queue.beginNext()) + XCTAssertFalse(queue.enqueue(first)) + XCTAssertTrue(queue.finish(first)) + XCTAssertEqual(queue.beginNext(), second) + XCTAssertTrue(queue.finish(second)) + XCTAssertNil(queue.beginNext()) + } + + func testChromiumCompatibilityUserAgentAdvertisesOnlyForDiscovery() { + XCTAssertTrue(UserAgentProvider.chromiumMailHandlerDiscovery.contains("Chrome/")) + XCTAssertTrue(UserAgentProvider.safariDefault.contains("Version/")) + XCTAssertNotEqual( + UserAgentProvider.chromiumMailHandlerDiscovery, + UserAgentProvider.safariDefault + ) + } + + func testQueueRetainsOrderAndHidesCurrentWhileLocked() { + let first = URL(string: "mailto:first@example.com")! + let second = URL(string: "mailto:second@example.com")! + var queue = MailLinkRequestQueue() + queue.enqueue(first) + queue.enqueue(second) + XCTAssertEqual(queue.current, first) + + queue.isLocked = true + XCTAssertNil(queue.current) + queue.isLocked = false + XCTAssertEqual(queue.current, first) + XCTAssertEqual(queue.completeCurrent(), first) + XCTAssertEqual(queue.current, second) + } + + func testAcceptedRequestsKeepTheirOwnAccountAndDestinationWhenQueueAdvances() throws { + let personal = try makeRegistration(id: gmailID, label: "Personal", host: "mail.example") + let work = try makeRegistration(id: outlookID, label: "Work", host: "mail.example") + var queue = MailLinkRequestQueue() + queue.enqueue(URL(string: "mailto:first@example.com")!) + queue.enqueue(URL(string: "mailto:second@example.com")!) + let first = try XCTUnwrap(MailLinkRouter.destination( + for: XCTUnwrap(queue.current), serviceID: work.serviceID, registrations: [personal, work] + )) + // The caller acknowledges window creation, not navigation or closure. + queue.completeCurrent() + let second = try XCTUnwrap(MailLinkRouter.destination( + for: XCTUnwrap(queue.current), serviceID: personal.serviceID, registrations: [personal, work] + )) + queue.completeCurrent() + XCTAssertNil(queue.current) + XCTAssertEqual(first.serviceID, outlookID) + XCTAssertEqual(first.url.absoluteString, "https://mail.example/compose?url=mailto%3Afirst%40example.com") + XCTAssertEqual(second.serviceID, gmailID) + XCTAssertEqual(second.url.absoluteString, "https://mail.example/compose?url=mailto%3Asecond%40example.com") + } + + func testMigrates1_5_19ServiceWithMailFieldsUnset() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "chorus-mail-migration-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appending(path: "default.store") + let id = UUID() + + try autoreleasepool { + let oldSchema = Schema(versionedSchema: ChorusSchemaV1_5_19.self) + let oldConfig = ModelConfiguration(schema: oldSchema, url: storeURL) + let oldContainer = try ModelContainer(for: oldSchema, configurations: [oldConfig]) + let service = ChorusSchemaV1_5_19.ServiceInstance( + id: id, label: "Existing Mail", url: "https://mail.example.com") + oldContainer.mainContext.insert(service) + try oldContainer.mainContext.save() + } + + let schema = Schema(versionedSchema: ChorusSchemaVCurrent.self) + let config = ModelConfiguration(schema: schema, url: storeURL) + let container = try ModelContainer( + for: schema, migrationPlan: ChorusMigrationPlan.self, configurations: [config]) + let service = try XCTUnwrap(try container.mainContext.fetch(FetchDescriptor()).first) + XCTAssertEqual(service.id, id) + XCTAssertEqual(service.label, "Existing Mail") + XCTAssertNil(service.mailtoHandlerTemplate) + XCTAssertNil(service.mailtoHandlerOrigin) + XCTAssertNil(service.mailtoHandlerEnabled) + } + + private func makeRegistration( + id: UUID, + label: String, + host: String, + enabled: Bool = true + ) throws -> MailLinkRegistration { + try XCTUnwrap(MailLinkRouter.registration( + account: MailLinkAccount(serviceID: id, label: label, providerLabel: label, spaceLabels: ["Work"]), + serviceURL: "https://\(host)/", + protocolName: "mailto", handlerTemplate: "https://\(host)/compose?url=%s", + declaringPageURL: "https://\(host)/inbox", isMainFrame: true, enabled: enabled + )) + } +} diff --git a/ChorusTests/MailRoutingTestSupport.swift b/ChorusTests/MailRoutingTestSupport.swift new file mode 100644 index 0000000..4ec9c76 --- /dev/null +++ b/ChorusTests/MailRoutingTestSupport.swift @@ -0,0 +1,20 @@ +import AppKit +import XCTest + +/// Helpers shared by the mail-routing test files. +@MainActor +extension XCTestCase { + func button(named title: String, in view: NSView?) -> NSButton? { + if let button = view as? NSButton, button.title == title { return button } + return view?.subviews.lazy.compactMap { self.button(named: title, in: $0) }.first + } + + func eventually(_ condition: @MainActor () async -> Bool) async -> Bool { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while ContinuousClock.now < deadline { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(25)) + } + return false + } +} diff --git a/ChorusTests/MailSchemaMigrationTests.swift b/ChorusTests/MailSchemaMigrationTests.swift new file mode 100644 index 0000000..d996bd8 --- /dev/null +++ b/ChorusTests/MailSchemaMigrationTests.swift @@ -0,0 +1,52 @@ +import SwiftData +import XCTest +@testable import Chorus + +@MainActor +final class MailSchemaMigrationTests: XCTestCase { + func testShippedPreMailStoreKeepsAccountAndStartsWithoutApprovedHandler() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "chorus-mail-migration-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "default.store") + let serviceID = UUID(), storeID = UUID(), spaceID = UUID(), linkID = UUID() + try autoreleasepool { + let schema = Schema(versionedSchema: ChorusSchemaV1_5_19.self) + let container = try ModelContainer(for: schema, configurations: ModelConfiguration(schema: schema, url: url)) + let service = ChorusSchemaV1_5_19.ServiceInstance(id: serviceID, label: "Work mail", url: "https://mail.example.test/inbox") + service.dataStoreIdentifier = storeID + service.stayActiveInBackground = false + service.hibernationPolicyRaw = "after" + service.hibernateAfterMinutes = 17 + service.userAgent = "FixtureBrowser/1.0" + let space = ChorusSchemaV1_5_19.Space(id: spaceID, name: "Work", emoji: "", sortOrder: 2) + container.mainContext.insert(service) + container.mainContext.insert(space) + try container.mainContext.save() + let link = ChorusSchemaV1_5_19.SpaceServiceLink(id: linkID, sortOrder: 3, space: space, service: service) + if link.modelContext == nil { container.mainContext.insert(link) } + try container.mainContext.save() + } + let schema = Schema(versionedSchema: ChorusSchemaVCurrent.self) + let container = try ModelContainer(for: schema, migrationPlan: ChorusMigrationPlan.self, + configurations: ModelConfiguration(schema: schema, url: url)) + let services = try container.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(services.count, 1) + let service = try XCTUnwrap(services.first) + XCTAssertEqual(service.id, serviceID) + XCTAssertEqual(service.dataStoreIdentifier, storeID) + XCTAssertEqual(service.label, "Work mail") + XCTAssertEqual(service.url, "https://mail.example.test/inbox") + XCTAssertEqual(service.userAgent, "FixtureBrowser/1.0") + XCTAssertEqual(service.hibernateAfterMinutes, 17) + XCTAssertEqual(service.stayActiveInBackground, false) + XCTAssertNil(service.mailtoHandler) + XCTAssertNil(service.mailtoHandlerEnabled) + let links = try container.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(links.count, 1) + let link = try XCTUnwrap(links.first) + XCTAssertEqual(link.id, linkID) + XCTAssertEqual(link.service?.id, serviceID) + XCTAssertEqual(link.space?.id, spaceID) + } +} diff --git a/ChorusTests/ManifestDiscoveryTests.swift b/ChorusTests/ManifestDiscoveryTests.swift new file mode 100644 index 0000000..dfbb2c5 --- /dev/null +++ b/ChorusTests/ManifestDiscoveryTests.swift @@ -0,0 +1,308 @@ +import AppKit +import Darwin +import Security +import SwiftData +import WebKit +import XCTest +@testable import Chorus + +@MainActor +final class ManifestDiscoveryTests: XCTestCase { + func testRelativeManifestHandlerWithMixedEntriesRegistersOnlyItsAccountsAndRoutesToChooser() async throws { + let fixture = try await ManifestFixture(manifests: ["/manifest": .init(body: """ + {"protocol_handlers":[ + null, + {"protocol":"web+fixture","url":"/other?value=%s"}, + {"protocol":"mailto","url":"/missing-placeholder"}, + {"protocol":"mailto","url":"https://unrelated.example.test/compose?mail=%s"}, + {"protocol":"mailto","url":"compose?mail=%s"} + ]} + """)]) + defer { fixture.close() } + let personal = fixture.addAccount("Personal") + let work = fixture.addAccount("Work") + let unrelated = fixture.addAccount("Unrelated") + try fixture.container.mainContext.save() + try await fixture.discover(personal, manifest: "/manifest") + try await fixture.discover(work, manifest: "/manifest") + for service in [personal, work] { + XCTAssertEqual(service.mailtoHandlerTemplate, fixture.origin + "/compose?mail=%s") + XCTAssertEqual(service.mailtoHandlerOrigin, fixture.origin) + XCTAssertEqual(service.mailtoHandlerEnabled, true) + } + XCTAssertNil(unrelated.mailtoHandlerTemplate) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:fixture@example.test?subject=A%26B"))) + let chooser = try XCTUnwrap(fixture.app.pendingMailLink) + XCTAssertEqual(Set(chooser.candidates.map(\.serviceID)), Set([personal.id, work.id])) + XCTAssertEqual(Set(chooser.candidates.map(\.label)), Set(["Personal", "Work"])) + XCTAssertNil(fixture.app.mailLinkError) + fixture.app.cancelMailLink(chooser.id) + XCTAssertNil(fixture.app.pendingMailLink) + } + + func testFixtureCleanupClosesAnyDirectComposerCreatedDuringDiscoveryAssertions() async throws { + let fixture = try await ManifestFixture(manifests: ["/manifest": .init(body: """ + {"protocol_handlers":[{"protocol":"mailto","url":"/compose?mail=%s"}]} + """)]) + defer { fixture.close() } + let service = fixture.addAccount("Cleanup fixture") + try fixture.container.mainContext.save() + try await fixture.discover(service, manifest: "/manifest") + let originalWindows = Set(NSApp.windows.map(ObjectIdentifier.init)) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:cleanup@example.test"))) + let composer = try XCTUnwrap(NSApp.windows.first { + !originalWindows.contains(ObjectIdentifier($0)) && $0.contentView is WKWebView + }) + XCTAssertTrue(composer.isVisible) + XCTAssertNil(fixture.app.mailLinkError) + fixture.close() + XCTAssertFalse(composer.isVisible) + XCTAssertNil(composer.contentView) + } + + func testRedirectedManifestResolvesHandlerAgainstActualFinalResponseURL() async throws { + let fixture = try await ManifestFixture(manifests: [ + "/manifest": .init(body: "", status: "302 Found", headers: ["Location": "/config/deep/app.webmanifest"]), + "/config/deep/app.webmanifest": .init(body: """ + {"protocol_handlers":[{"protocol":"mailto","url":"../compose?mail=%s"}]} + """) + ]) + defer { fixture.close() } + let service = fixture.addAccount("Redirect fixture") + try fixture.container.mainContext.save() + try await fixture.discover(service, manifest: "/manifest") + XCTAssertTrue(fixture.server.requestedTargets.contains("/manifest")) + XCTAssertTrue(fixture.server.requestedTargets.contains("/config/deep/app.webmanifest")) + XCTAssertEqual(service.mailtoHandlerTemplate, fixture.origin + "/config/compose?mail=%s") + XCTAssertEqual(service.mailtoHandlerOrigin, fixture.origin) + } + + func testFetchedInvalidManifestsSettleWithoutRegistrationOrPageErrors() async throws { + let cases: [(String, ComposeFixtureServer.Response)] = [ + ("malformed JSON", .init(body: "{this is not JSON")), + ("unsuccessful response", .init(body: """ + {"protocol_handlers":[{"protocol":"mailto","url":"/compose?mail=%s"}]} + """, status: "503 Service Unavailable")), + ("missing placeholder", .init(body: """ + {"protocol_handlers":[{"protocol":"mailto","url":"/compose"}]} + """)), + ("cross-origin handler", .init(body: """ + {"protocol_handlers":[{"protocol":"mailto","url":"https://unrelated.example.test/compose?mail=%s"}]} + """)) + ] + for (label, response) in cases { + let fixture = try await ManifestFixture(manifests: ["/manifest": response]) + defer { fixture.close() } + let service = fixture.addAccount(label) + try fixture.container.mainContext.save() + try await fixture.discover(service, manifest: "/manifest") + XCTAssertTrue(fixture.server.requestedTargets.contains("/manifest"), label) + XCTAssertNil(service.mailtoHandlerTemplate, label) + XCTAssertNil(service.mailtoHandlerOrigin, label) + XCTAssertNil(service.mailtoHandlerEnabled, label) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:rejected@example.test"))) + XCTAssertNotNil(fixture.app.mailLinkError, label) + XCTAssertNil(fixture.app.pendingMailLink, label) + fixture.app.dismissMailLinkError(try XCTUnwrap(fixture.app.mailLinkError).id) + } + } + + func testRejectedAndRepeatedDiscoveryPreserveExistingRegistrationOptOutAndOtherAccount() async throws { + let fixture = try await ManifestFixture(manifests: [ + "/valid": .init(body: """ + {"protocol_handlers":[{"protocol":"mailto","url":"/compose?mail=%s"}]} + """), + "/invalid": .init(body: """ + {"protocol_handlers":[ + {"protocol":"mailto","url":"/no-placeholder"}, + {"protocol":"mailto","url":"https://unrelated.example.test/compose?mail=%s"} + ]} + """) + ]) + defer { fixture.close() } + let personal = fixture.addAccount("Personal") + let work = fixture.addAccount("Work") + try fixture.container.mainContext.save() + try await fixture.discover(personal, manifest: "/valid") + personal.mailtoHandlerEnabled = false + try fixture.container.mainContext.save() + try await fixture.discover(personal, manifest: "/invalid") + XCTAssertEqual(personal.mailtoHandlerTemplate, fixture.origin + "/compose?mail=%s") + XCTAssertEqual(personal.mailtoHandlerOrigin, fixture.origin) + XCTAssertEqual(personal.mailtoHandlerEnabled, false) + XCTAssertNil(work.mailtoHandlerTemplate) + XCTAssertNil(work.mailtoHandlerOrigin) + XCTAssertNil(work.mailtoHandlerEnabled) + try await fixture.discover(personal, manifest: "/valid") + XCTAssertEqual(personal.mailtoHandlerTemplate, fixture.origin + "/compose?mail=%s") + XCTAssertEqual(personal.mailtoHandlerOrigin, fixture.origin) + XCTAssertEqual(personal.mailtoHandlerEnabled, false) + XCTAssertNil(work.mailtoHandlerTemplate) + XCTAssertNil(work.mailtoHandlerOrigin) + XCTAssertNil(work.mailtoHandlerEnabled) + fixture.app.enqueueMailLink(try XCTUnwrap(URL(string: "mailto:disabled@example.test"))) + XCTAssertNotNil(fixture.app.mailLinkError) + XCTAssertNil(fixture.app.pendingMailLink) + fixture.app.dismissMailLinkError(try XCTUnwrap(fixture.app.mailLinkError).id) + } +} + +@MainActor +private final class ManifestFixture { + let container: ModelContainer + let app: AppState + let server: ComposeFixtureServer + private let certificateData: Data + private var views: [(WKWebView, DiscoveryObservation)] = [] + private var accountIDs: [UUID] = [] + + var origin: String { "https://127.0.0.1:\(server.port)" } + + init(manifests: [String: ComposeFixtureServer.Response]) async throws { + // This publicly committed identity is only for loopback test transport. + // SecKeyCreateWithData/SecIdentityCreate keep it in memory, never Keychain. + let bundle = Bundle(for: ManifestDiscoveryTests.self) + certificateData = try Data(contentsOf: XCTUnwrap(bundle.url(forResource: "loopback-certificate", withExtension: "der"))) + let keyData = try Data(contentsOf: XCTUnwrap(bundle.url(forResource: "loopback-private-key", withExtension: "der"))) + let certificate = try XCTUnwrap(SecCertificateCreateWithData(nil, certificateData as CFData)) + let key = try XCTUnwrap(SecKeyCreateWithData(keyData as CFData, [ + kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPrivate + ] as CFDictionary, nil)) + let identity = try Self.makeIdentity(certificate: certificate, key: key) + var responses = manifests.mapValues { response in + var response = response + response.headers["Content-Type"] = "application/manifest+json" + return response + } + for path in manifests.keys { + responses["/pages" + path] = .init(body: "

Synthetic discovery fixture

") + } + server = try await ComposeFixtureServer(responses: responses, tlsIdentity: identity) + container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + app = AppState(modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() })) + } + + private static func makeIdentity(certificate: SecCertificate, key: SecKey) throws -> SecIdentity { + #if compiler(>=6.2) + return try XCTUnwrap(SecIdentityCreate(nil, certificate, key)) + #else + // The API exists from macOS 10.12, but Xcode 16 lacks its declaration. + // Resolve its C ABI without importing a fixture into any Keychain. + typealias CreateIdentity = @convention(c) (CFAllocator?, SecCertificate, SecKey) -> Unmanaged? + let symbol = try XCTUnwrap(dlsym(UnsafeMutableRawPointer(bitPattern: -2), "SecIdentityCreate")) + let create = unsafeBitCast(symbol, to: CreateIdentity.self) + return try XCTUnwrap(create(nil, certificate, key)).takeRetainedValue() + #endif + } + + func addAccount(_ label: String) -> ServiceInstance { + let service = ServiceInstance(label: label, url: origin + "/inbox") + container.mainContext.insert(service) + accountIDs.append(service.id) + return service + } + + func discover(_ service: ServiceInstance, manifest: String) async throws { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = app.dataStoreManager.dataStore(for: service) + let observation = DiscoveryObservation(port: Int(server.port), certificate: certificateData) + let controller = configuration.userContentController + controller.add(observation, name: "fixtureDiscovery") + controller.addUserScript(WKUserScript(source: DiscoveryObservation.script, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + app.userScriptManager.configureMailHandlerDiscovery(for: service.id, on: controller) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = observation + views.append((view, observation)) + view.load(URLRequest(url: server.url("/pages" + manifest))) + let deadline = Date().addingTimeInterval(5) + while !observation.settled && observation.navigationError == nil && Date() < deadline { + try await Task.sleep(for: .milliseconds(25)) + } + XCTAssertNil(observation.navigationError) + XCTAssertTrue(observation.settled, "The real fetch and JSON processing must settle before checking registration") + if let approval = app.pendingMailHandlerApproval { + XCTAssertEqual(approval.serviceID, service.id) + XCTAssertNotEqual(service.mailtoHandlerTemplate, approval.handler.template, + "A new declaration is not persisted before consent") + app.answerMailHandlerApproval(approval.id, allow: true) + } + let errors = try await view.evaluateJavaScriptValue("window.fixtureErrors.join('|')") as? String + XCTAssertEqual(errors, "", "Discovery failures must not escape into the provider page") + } + + func close() { + for (view, _) in views { + view.stopLoading() + view.navigationDelegate = nil + let controller = view.configuration.userContentController + app.userScriptManager.removeMailHandler(on: controller) + controller.removeScriptMessageHandler(forName: "fixtureDiscovery") + controller.removeAllUserScripts() + } + views.removeAll() + // Unexpected direct routing must not leak a composer when an assertion + // fails. Use the pool's account teardown rather than window-count state. + accountIDs.forEach { app.webViewPool.removeWebView(for: $0) } + accountIDs.removeAll() + server.stop() + } +} + +@MainActor +private final class DiscoveryObservation: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + let port: Int + let certificate: Data + var settled = false + var navigationError: Error? + + init(port: Int, certificate: Data) { + self.port = port + self.certificate = certificate + } + + func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping @MainActor (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + let space = challenge.protectionSpace + if space.authenticationMethod == NSURLAuthenticationMethodServerTrust, + space.host == "127.0.0.1", space.port == port, + let trust = space.serverTrust, + let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let leaf = chain.first, + SecCertificateCopyData(leaf) as Data == certificate { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + navigationError = error + } + + nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + MainActor.assumeIsolated { + guard message.frameInfo.isMainFrame, message.body as? String == "settled" else { return } + settled = true + } + } + + // Observe completion only: native fetch, Response.json and URL are not + // replaced with fixture answers. The next event-loop turn runs after the + // discovery promise chain, ordering its bridge messages before this marker. + static let script = """ + window.fixtureErrors = []; + window.addEventListener('error', event => window.fixtureErrors.push(String(event.message))); + window.addEventListener('unhandledrejection', event => window.fixtureErrors.push(String(event.reason))); + function fixtureSettled() { + setTimeout(() => window.webkit.messageHandlers.fixtureDiscovery.postMessage('settled'), 0); + } + var fixtureFetch = window.fetch; + window.fetch = function() { + return fixtureFetch.apply(this, arguments).then(response => { + if (!response.ok) { fixtureSettled(); return response; } + var fixtureJSON = response.json; + response.json = function() { return fixtureJSON.apply(this, arguments).finally(fixtureSettled); }; + return response; + }, error => { fixtureSettled(); throw error; }); + }; + """ +} From 1f5d978ee15ed1a30168fafa3803982fb44a3cb5 Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 14:42:07 -0500 Subject: [PATCH 10/10] fix: scope mail routing isolation seams to debug builds --- CHANGELOG.md | 2 +- Chorus/App/AppState.swift | 4 +- Chorus/Services/DataStoreManager.swift | 8 ++- Chorus/Views/WebView/WebViewPool.swift | 4 +- .../MailComposeWindowSessionTests.swift | 54 +++++++++++++------ 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cef0c2..c03ac02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to Chorus are documented here. Format loosely follows ### Added -- Chorus can open mail links from macOS in a floating window for a chosen account. Choose Save and detect mail links in that service’s settings, then approve its page for new drafts. Links clicked inside services still use the Mac’s default mail app. +- Chorus can open mail links from macOS in a floating window for the account you choose. Choose Save and detect mail links in that service’s settings, then approve its page for new drafts. Links clicked inside services still use the Mac’s default mail app. ### Changed diff --git a/Chorus/App/AppState.swift b/Chorus/App/AppState.swift index 80a8d78..4acd6a4 100644 --- a/Chorus/App/AppState.swift +++ b/Chorus/App/AppState.swift @@ -420,6 +420,7 @@ final class AppState { !isStoreInMemoryFallback && !storeWasDamagedAtLaunch && !storeWasRestoredAtLaunch } + #if DEBUG /// Isolated application wiring without launch-time persistence, observers, /// preloading, or recovery. Callers own the container and account stores. init( @@ -437,6 +438,7 @@ final class AppState { isStoreInMemoryFallback = true setupExternalLinkRouting() } + #endif init() { // The current shipping shape, pinned as an explicit `VersionedSchema`. @@ -1017,7 +1019,7 @@ final class AppState { return } declinedMailHandlers.removeValue(forKey: serviceID) - webViewPool.probeMailHandler(for: service, userRequested: true) + webViewPool.probeMailHandler(for: service) } private func currentMailRegistrations() -> [MailLinkRegistration] { diff --git a/Chorus/Services/DataStoreManager.swift b/Chorus/Services/DataStoreManager.swift index 1aa2aca..fc1c846 100644 --- a/Chorus/Services/DataStoreManager.swift +++ b/Chorus/Services/DataStoreManager.swift @@ -11,9 +11,15 @@ final class DataStoreManager { private var cache: [UUID: WKWebsiteDataStore] = [:] private let makeStore: (UUID) -> WKWebsiteDataStore - init(makeStore: @escaping (UUID) -> WKWebsiteDataStore = { WKWebsiteDataStore(forIdentifier: $0) }) { + init() { + makeStore = { WKWebsiteDataStore(forIdentifier: $0) } + } + + #if DEBUG + init(makeStore: @escaping (UUID) -> WKWebsiteDataStore) { self.makeStore = makeStore } + #endif func dataStore(for instance: ServiceInstance) -> WKWebsiteDataStore { dataStore(forIdentifier: instance.dataStoreIdentifier) diff --git a/Chorus/Views/WebView/WebViewPool.swift b/Chorus/Views/WebView/WebViewPool.swift index 40a71d2..ce30cfe 100644 --- a/Chorus/Views/WebView/WebViewPool.swift +++ b/Chorus/Views/WebView/WebViewPool.swift @@ -315,8 +315,8 @@ final class WebViewPool { /// Runs a serial, invisible compatibility load with the service's existing /// session. The page must still make a standards declaration; the probe does /// not infer providers or compose URLs. Duplicate requests are coalesced. - func probeMailHandler(for instance: ServiceInstance, userRequested: Bool = false) { - guard userRequested, instance.nativeAppBundleID == nil, instance.mailtoHandlerEnabledEffective, + func probeMailHandler(for instance: ServiceInstance) { + guard instance.nativeAppBundleID == nil, instance.mailtoHandlerEnabledEffective, let url = URL(string: instance.url), url.scheme?.lowercased() == "https", Origin(url) != nil else { return } let id = instance.id diff --git a/ChorusTests/MailComposeWindowSessionTests.swift b/ChorusTests/MailComposeWindowSessionTests.swift index 3219d99..d243b28 100644 --- a/ChorusTests/MailComposeWindowSessionTests.swift +++ b/ChorusTests/MailComposeWindowSessionTests.swift @@ -691,8 +691,8 @@ final class MailComposeWindowSessionTests: XCTestCase { let first = ServiceInstance(label: "First", url: "https://first.example.test") let second = ServiceInstance(label: "Second", url: "https://second.example.test") defer { pool.removeWebView(for: first.id); pool.removeWebView(for: second.id) } - pool.probeMailHandler(for: first, userRequested: true) - pool.probeMailHandler(for: second, userRequested: true) + pool.probeMailHandler(for: first) + pool.probeMailHandler(for: second) let view = try XCTUnwrap(loaded.first) for _ in 0..<10 { view.navigationDelegate?.webView?(view, didFinish: nil) @@ -720,8 +720,8 @@ final class MailComposeWindowSessionTests: XCTestCase { scripts.onMailHandlerDeclaration = { declarations.append($0.serviceID) } let first = ServiceInstance(label: "First", url: "https://first.example.test") let second = ServiceInstance(label: "Second", url: "https://second.example.test") - pool.probeMailHandler(for: first, userRequested: true) - pool.probeMailHandler(for: second, userRequested: true) + pool.probeMailHandler(for: first) + pool.probeMailHandler(for: second) XCTAssertEqual(loaded.count, 1) XCTAssertTrue(loaded[0].configuration.websiteDataStore === stores.dataStore(for: first)) XCTAssertEqual(loaded[0].customUserAgent, UserAgentProvider.safariDefault) @@ -761,7 +761,7 @@ final class MailComposeWindowSessionTests: XCTestCase { let draft = try XCTUnwrap(composed) XCTAssertNil(pool.liveWebView(for: service.id), "Composing must not create an inbox") XCTAssertTrue(pool.liveWebViews.contains { $0 === draft }, "Quit must give floating drafts the same bounded grace") - pool.probeMailHandler(for: service, userRequested: true) + pool.probeMailHandler(for: service) let probe = try XCTUnwrap(probed) XCTAssertFalse(pool.liveWebViews.contains { $0 === probe }, "Discovery owns no user draft") draft.window?.close() @@ -778,11 +778,11 @@ final class MailComposeWindowSessionTests: XCTestCase { ServiceInstance(label: $0, url: "https://\($0).example.test") } defer { accounts.forEach { pool.removeWebView(for: $0.id) } } - pool.probeMailHandler(for: accounts[0], userRequested: true) - pool.probeMailHandler(for: accounts[1], userRequested: true) + pool.probeMailHandler(for: accounts[0]) + pool.probeMailHandler(for: accounts[1]) pool.setMailComposersLocked(true) pool.setMailComposersLocked(false) - pool.probeMailHandler(for: accounts[2], userRequested: true) + pool.probeMailHandler(for: accounts[2]) XCTAssertEqual(loaded, [URL(string: accounts[0].url)!, URL(string: accounts[2].url)!], "Lock discards hidden loads without restarting the waiting account") } @@ -795,23 +795,43 @@ final class MailComposeWindowSessionTests: XCTestCase { loadMailProbe: { _, url in loaded.append(url) }) let service = ServiceInstance(label: "Account", url: "https://fixture.example.test") defer { pool.removeWebView(for: service.id) } - pool.probeMailHandler(for: service, userRequested: true) - pool.probeMailHandler(for: service, userRequested: true) + pool.probeMailHandler(for: service) + pool.probeMailHandler(for: service) XCTAssertEqual(loaded.count, 1, "Repeated clicks still coalesce an active probe") pool.completeMailHandlerProbe(for: service.id) - pool.probeMailHandler(for: service, userRequested: true) + pool.probeMailHandler(for: service) XCTAssertEqual(loaded.count, 2, "A deliberate retry starts a fresh bounded attempt") } - func testImplicitProbeRequestsNeverLoadAService() { + func testDetectionGuardsNeverLoadAService() throws { var loads = 0 - let pool = WebViewPool( + let container = try ModelContainer(for: Schema(versionedSchema: ChorusSchemaVCurrent.self), configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let app = AppState( + modelContainer: container, dataStoreManager: DataStoreManager(makeStore: { _ in .nonPersistent() }), - userScriptManager: UserScriptManager(), contentBlocker: ContentBlockerManager(), loadMailProbe: { _, _ in loads += 1 }) - let service = ServiceInstance(label: "Gmail", url: "https://mail.google.com/mail/u/0/") - for _ in 0..<3 { pool.probeMailHandler(for: service) } - XCTAssertEqual(loads, 0) + let service = ServiceInstance(label: "Account", url: "https://fixture.example.test") + container.mainContext.insert(service) + + app.detectMailHandler(for: UUID()) + XCTAssertEqual(loads, 0, "Missing services cannot start detection") + service.url = NativeApp.serviceURL(forBundleID: "com.example.native") + app.detectMailHandler(for: service.id) + XCTAssertEqual(loads, 0, "Native apps cannot start detection") + service.url = "https://fixture.example.test" + service.mailtoHandlerEnabled = false + app.detectMailHandler(for: service.id) + XCTAssertEqual(loads, 0, "Disabled mail links cannot start detection") + service.mailtoHandlerEnabled = true + service.url = "http://fixture.example.test" + app.detectMailHandler(for: service.id) + XCTAssertEqual(loads, 0, "Non-HTTPS services cannot start detection") + service.url = "https://fixture.example.test" + app.appLockEnabled = true + app.lock() + XCTAssertTrue(app.isLocked) + app.detectMailHandler(for: service.id) + XCTAssertEqual(loads, 0, "Locked apps cannot start detection") } func testStaleChooserActionsCannotExposeErrorsWhileLockedOrReplaceNewerRequests() throws {