From ec03273325e54122e2289c5b5629a5fa1f0565ad Mon Sep 17 00:00:00 2001 From: Jack Adamson Date: Mon, 12 Oct 2026 11:48:39 -0500 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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) }