diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf0f2d..ace17b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to Chorus are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [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. +- 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/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. 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..f5a3806 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,8 @@ 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. /// A web view made at zero size loads its page against a 0 by 0 window, and @@ -40,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) @@ -47,19 +58,72 @@ 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 + } - currentWebView?.removeFromSuperview() + 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 } + + // 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), - ]) + // 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 + 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..2273fac --- /dev/null +++ b/Chorus/Views/WebView/WebViewDeparture.swift @@ -0,0 +1,172 @@ +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) } + } + 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 + ) { _ in + MainActor.assumeIsolated { cancelPendingHide() } + } + } + cancelPendingHide() + let generation = hideGeneration + 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, isHidden() else { return } + hideTask = nil + for webView in webViews + where webView.window != nil && !webView.isHiddenOrHasHiddenAncestor + { + endHover(in: webView) + } + } + hideTask = task + return task + } + + /// 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..47691af 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -1,4 +1,5 @@ import XCTest +import CoreData import SwiftData import SQLite3 import JavaScriptCore @@ -83,6 +84,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") @@ -4951,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()) @@ -4994,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") @@ -5010,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 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..0df6d8d --- /dev/null +++ b/ChorusTests/WebViewDepartureTests.swift @@ -0,0 +1,552 @@ +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() + defer { window.close() } + InputSettle.shared.recordDeparture(in: webView) + // 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 + 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() + 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 testTransparentIncomingPageCannotRevealRetainedOutgoingPage() async throws { + let (window, outgoing, hosting, service) = try await selectedCardFixture() + defer { window.close() } + 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. + incoming.loadHTMLString("", baseURL: nil) + try await waitUntil(incoming, "window.transparentReady === true") + InputSettle.shared.recordDeparture(in: outgoing) + hosting.rootView = card(service: service, webView: incoming) + // 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(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) + 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) + } + + 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") + } + } +}