diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 02e0bdb931..9fbda604d1 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -39,6 +39,36 @@ struct BrowserRemoteWorkspaceStatus: Equatable { let lastHeartbeatAt: Date? } +enum BrowserDeveloperToolsAttachmentPolicy { + private static let firstStableAttachedInspectorMajorVersion = 26 + +#if DEBUG + private nonisolated(unsafe) static var attachedInspectorAllowedOverrideForTesting: Bool? + + static func withAttachedInspectorAllowedForTesting( + _ allowed: Bool?, + _ body: () throws -> T + ) rethrows -> T { + let previous = attachedInspectorAllowedOverrideForTesting + attachedInspectorAllowedOverrideForTesting = allowed + defer { attachedInspectorAllowedOverrideForTesting = previous } + return try body() + } +#endif + + static func allowsAttachedInspector( + osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion + ) -> Bool { +#if DEBUG + if let attachedInspectorAllowedOverrideForTesting { + return attachedInspectorAllowedOverrideForTesting + } +#endif + // macOS 15 WebKit 20621 crashes inside WebInspectorUIProxy::platformAttach. + return osVersion.majorVersion >= firstStableAttachedInspectorMajorVersion + } +} + enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { WindowAppearanceSnapshot.clampedOpacity(opacity) @@ -6300,6 +6330,10 @@ extension BrowserPanel { } private func prepareDeveloperToolsForRevealIfNeeded(_ inspector: NSObject) { + guard BrowserDeveloperToolsAttachmentPolicy.allowsAttachedInspector() else { + setPreferredDeveloperToolsPresentation(.detached) + return + } if preferredDeveloperToolsPresentation != .unknown { guard preferredDeveloperToolsPresentation == .attached else { return } guard webView.superview != nil, webView.window != nil else { return } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 90eb071fc3..b76ac90500 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -21,17 +21,46 @@ final class PostHogAnalytics { private let workQueueSpecificKey = DispatchSpecificKey() private let utcHourFormatter: DateFormatter private let utcDayFormatter: DateFormatter + private let flushImplementation: () -> Void private var didStart = false private var activeCheckTimer: Timer? private init() { - workQueue = DispatchQueue(label: "com.cmux.posthog.analytics", qos: .utility) - utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH") - utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd") + self.workQueue = DispatchQueue(label: "com.cmux.posthog.analytics", qos: .utility) + self.utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH") + self.utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd") + self.flushImplementation = { PostHogSDK.shared.flush() } workQueue.setSpecific(key: workQueueSpecificKey, value: ()) } + private init( + workQueue: DispatchQueue, + didStart: Bool, + flushImplementation: @escaping () -> Void + ) { + self.workQueue = workQueue + self.utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH") + self.utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd") + self.flushImplementation = flushImplementation + self.didStart = didStart + workQueue.setSpecific(key: workQueueSpecificKey, value: ()) + } + +#if DEBUG + static func makeForTesting( + workQueue: DispatchQueue, + didStart: Bool, + flushImplementation: @escaping () -> Void + ) -> PostHogAnalytics { + PostHogAnalytics( + workQueue: workQueue, + didStart: didStart, + flushImplementation: flushImplementation + ) + } +#endif + private var isEnabled: Bool { guard TelemetrySettings.enabledForCurrentLaunch else { return false } #if DEBUG @@ -56,7 +85,7 @@ final class PostHogAnalytics { let didCaptureHourly = self.trackHourlyActiveOnWorkQueue(reason: reason, flush: false) if didCaptureDaily || didCaptureHourly { // On app focus we can capture both events; flush once to reduce extra work. - PostHogSDK.shared.flush() + self.flushImplementation() } } } @@ -74,9 +103,9 @@ final class PostHogAnalytics { } func flush() { - dispatchSyncOnWorkQueue { - guard didStart else { return } - PostHogSDK.shared.flush() + dispatchAsyncOnWorkQueue { [weak self] in + guard let self, self.didStart else { return } + self.flushImplementation() } } @@ -144,7 +173,7 @@ final class PostHogAnalytics { if flush && Self.shouldFlushAfterCapture(event: event) { // For active metrics we care more about delivery than batching. - PostHogSDK.shared.flush() + flushImplementation() } return true @@ -176,7 +205,7 @@ final class PostHogAnalytics { if flush && Self.shouldFlushAfterCapture(event: event) { // Keep hourly freshness and avoid losing a deduped hour on abrupt exits. - PostHogSDK.shared.flush() + flushImplementation() } return true @@ -190,14 +219,6 @@ final class PostHogAnalytics { workQueue.async(execute: block) } - private func dispatchSyncOnWorkQueue(_ block: () -> Void) { - if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil { - block() - return - } - workQueue.sync(execute: block) - } - private func utcHourString(_ date: Date) -> String { utcHourFormatter.string(from: date) } diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index de158d48b2..62c33443c5 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1833,6 +1833,23 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { } } + func testAttachedWebInspectorIsDisabledOnMacOS15() { + XCTAssertFalse( + BrowserDeveloperToolsAttachmentPolicy.allowsAttachedInspector( + osVersion: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 5) + ), + "The crash report is WebKit::WebInspectorUIProxy::platformAttach on macOS 15.7.5; cmux should not force side-attached Web Inspector there." + ) + } + + func testAttachedWebInspectorRemainsEnabledOnMacOS26() { + XCTAssertTrue( + BrowserDeveloperToolsAttachmentPolicy.allowsAttachedInspector( + osVersion: OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) + ) + ) + } + func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() { let panel = BrowserPanel(workspaceId: UUID()) let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) @@ -3623,6 +3640,23 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.attachCount, 2) } + func testShowDeveloperToolsSkipsAttachWhenAttachmentPolicyDisallowsIt() throws { + let (panel, inspector) = makePanelWithInspector() + defer { closeBrowserPanel(panel) } + + try BrowserDeveloperToolsAttachmentPolicy.withAttachedInspectorAllowedForTesting(false) { + XCTAssertTrue(panel.showDeveloperTools()) + } + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual( + inspector.attachCount, + 0, + "macOS versions with unstable WebKit side attach should open DevTools without calling the private attach selector." + ) + XCTAssertEqual(inspector.showCount, 1) + } + func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() { let (panel, inspector) = makePanelWithInspector() defer { closeBrowserPanel(panel) } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index af735db3ce..379e4544c2 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -3362,6 +3362,36 @@ final class UITestLaunchManifestTests: XCTestCase { } final class PostHogAnalyticsPropertiesTests: XCTestCase { + func testFlushReturnsWithoutWaitingForBusyAnalyticsQueue() { + let queue = DispatchQueue(label: "com.cmux.posthog.analytics.test") + let queuedWorkStarted = DispatchSemaphore(value: 0) + let releaseQueuedWork = DispatchSemaphore(value: 0) + queue.async { + queuedWorkStarted.signal() + _ = releaseQueuedWork.wait(timeout: .now() + 5) + } + XCTAssertEqual(queuedWorkStarted.wait(timeout: .now() + 1), .success) + + let analytics = PostHogAnalytics.makeForTesting( + workQueue: queue, + didStart: true, + flushImplementation: {} + ) + let flushReturned = expectation(description: "flush returned") + DispatchQueue.global(qos: .userInitiated).async { + analytics.flush() + flushReturned.fulfill() + } + + let result = XCTWaiter().wait(for: [flushReturned], timeout: 0.2) + releaseQueuedWork.signal() + XCTAssertEqual( + result, + .completed, + "PostHogAnalytics.flush must not synchronously wait for the analytics queue while the main thread is terminating." + ) + } + func testDailyActivePropertiesIncludeVersionAndBuild() { let properties = PostHogAnalytics.dailyActiveProperties( dayUTC: "2026-02-21",