From f9587a50c3a78386eaf66a864600d8d387a8adc3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:16:07 -0700 Subject: [PATCH 01/15] Fix Computer Use companion review findings --- Sources/App/ExternalApplicationWindowTracker.swift | 4 +++- Sources/App/ExternalWindowCompanionPresenter.swift | 6 +++++- cmuxTests/ComputerUseOnboardingWindowTests.swift | 11 ++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Sources/App/ExternalApplicationWindowTracker.swift b/Sources/App/ExternalApplicationWindowTracker.swift index e40f838ec0e7..c0e93b38d03f 100644 --- a/Sources/App/ExternalApplicationWindowTracker.swift +++ b/Sources/App/ExternalApplicationWindowTracker.swift @@ -333,7 +333,9 @@ final class ExternalApplicationWindowTracker { width: quartzFrame.width, height: quartzFrame.height ), - isOnScreen: (entry[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? true + // Missing visibility metadata is ambiguous. Suppress companion + // presentation until WindowServer confirms that the window is on screen. + isOnScreen: (entry[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? false ) } } diff --git a/Sources/App/ExternalWindowCompanionPresenter.swift b/Sources/App/ExternalWindowCompanionPresenter.swift index cecf6939002e..9458e0e8accb 100644 --- a/Sources/App/ExternalWindowCompanionPresenter.swift +++ b/Sources/App/ExternalWindowCompanionPresenter.swift @@ -19,8 +19,12 @@ struct ExternalWindowCompanionPresenter { func present(_ companionWindow: NSWindow) { companionWindow.level = .floating - companionWindow.collectionBehavior = [.managed] + // Move to the Space containing System Settings for this first order. + // Clear the transient flag immediately so later app activation cannot + // rehome the companion. + companionWindow.collectionBehavior = [.managed, .moveToActiveSpace] companionWindow.hidesOnDeactivate = false orderWindow(companionWindow) + companionWindow.collectionBehavior = [.managed] } } diff --git a/cmuxTests/ComputerUseOnboardingWindowTests.swift b/cmuxTests/ComputerUseOnboardingWindowTests.swift index 716ce3c8d8bd..ef8973c240d5 100644 --- a/cmuxTests/ComputerUseOnboardingWindowTests.swift +++ b/cmuxTests/ComputerUseOnboardingWindowTests.swift @@ -152,11 +152,17 @@ struct ComputerUseOnboardingWindowTests { window.setFrame(placementFrame, display: true, animate: false) #expect(window.frame == placementFrame) } - for _ in 0..<12 { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while window.frame.size != expectedSize || contentView.frame.size != expectedSize { contentView.invalidateIntrinsicContentSize() contentView.needsLayout = true contentView.layoutSubtreeIfNeeded() window.displayIfNeeded() + if window.frame.size == expectedSize && contentView.frame.size == expectedSize { + break + } + guard clock.now < deadline else { break } await Task.yield() } @@ -381,8 +387,10 @@ struct ComputerUseOnboardingWindowTests { @Test @MainActor func externalWindowCompanionUsesFloatingNonactivatingPresentation() { var orderedWindow: NSWindow? + var behaviorDuringOrder: NSWindow.CollectionBehavior? let presenter = ExternalWindowCompanionPresenter { window in orderedWindow = window + behaviorDuringOrder = window.collectionBehavior } let companionWindow = NSPanel( contentRect: .zero, @@ -398,6 +406,7 @@ struct ComputerUseOnboardingWindowTests { presenter.present(companionWindow) #expect(orderedWindow === companionWindow) + #expect(behaviorDuringOrder?.contains(.moveToActiveSpace) == true) #expect(companionWindow.level == .floating) #expect(companionWindow.hidesOnDeactivate == false) #expect(!companionWindow.collectionBehavior.contains(.moveToActiveSpace)) From 68840ea1841338006e378ba6d70ba77fb67ed15e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:51:53 -0700 Subject: [PATCH 02/15] Fix current-main surface provider visibility --- Sources/Surfaces/CmuxTuiSurfaceProviders.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index d764eb173b72..b4e804f8e39f 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -97,11 +97,11 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// daemon identity, not by a local title or projection, because one remote /// tab can be shown in several windows. The canonical graph remains the /// only source of remote values. - private enum PendingRemoteRenameKey: Hashable { + enum PendingRemoteRenameKey: Hashable { case workspace(String) case tab(String) } - private struct PendingRemoteRename { + struct PendingRemoteRename { var name: String var receipt: CloudVMCursor } From 8a6d5daf9f31c4a9e9138330f12984f420b77029 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 13 Sep 2026 18:00:17 -0700 Subject: [PATCH 03/15] fix: restore cloud receipt helpers after recovery revert --- ...xTuiSurfaceProvider+PendingMutations.swift | 178 ++++++++++++++++++ .../Surfaces/CmuxTuiSurfaceProviders.swift | 10 +- cmux.xcodeproj/project.pbxproj | 4 + 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift new file mode 100644 index 000000000000..0d8228287be7 --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Maintains creation and rename receipts until an authoritative graph catches up. +@MainActor +extension CmuxTuiSurfaceProvider { + /// Merges pending mutation receipts into derived rows until an accepted + /// graph reaches each receipt. The canonical graph is never edited here. + /// A generation change, or a cursorless snapshot after a versioned receipt, + /// retires the overlay because the old placement cannot be proven to exist. + func resourcesWithPendingCreations( + _ resources: [SurfaceResource], + state: CloudVMState? + ) -> [SurfaceResource] { + var merged = resources + var completed: [SurfaceResourceID] = [] + for (resourceID, pending) in pendingRemoteCreations where resourceID.machine == machine { + if let state { + if let receipt = pending.receipt { + guard let cursor = state.cursor, + cursor.generation == receipt.generation else { + completed.append(resourceID) + continue + } + if cursor.revision >= receipt.revision { + // At or beyond the commit, the accepted graph is the + // source of truth, including an intentional close. + completed.append(resourceID) + continue + } + } else if pendingCreationIsVisible(pending, in: state) { + // Legacy mutation responses have no ordering fence. Stop + // overlaying as soon as the exact path is observed. + completed.append(resourceID) + continue + } + } + mergePendingCreation(pending, into: &merged) + } + for resourceID in completed { + pendingRemoteCreations.removeValue(forKey: resourceID) + } + return merged + } + + private func pendingCreationIsVisible( + _ pending: PendingRemoteCreation, + in state: CloudVMState + ) -> Bool { + guard state.lookupIndex.terminal(id: pending.resource.id.key) != nil else { return false } + guard let tabID = pending.tabID else { return true } + return state.lookupIndex.tab(id: tabID) != nil + } + + private func mergePendingCreation( + _ pending: PendingRemoteCreation, + into resources: inout [SurfaceResource] + ) { + guard let pendingView = pending.resource.remoteViews?.first else { + if !resources.contains(where: { $0.id == pending.resource.id }) { + resources.append(pending.resource) + } + return + } + guard let index = resources.firstIndex(where: { $0.id == pending.resource.id }) else { + resources.append(pending.resource) + return + } + var resource = resources[index] + var views = resource.remoteViews ?? [] + if !views.contains(where: { $0.tabID == pendingView.tabID }) { + views.append(pendingView) + resource.remoteViews = views + if resource.remoteWorkspace == nil { + resource.remoteWorkspace = pendingView.workspace + } + } + resources[index] = resource + } + + func remoteWorkspaces(for state: CloudVMState?) -> [SurfaceRemoteWorkspace]? { + var result = state.map(Self.remoteWorkspaces) ?? info.remoteWorkspaces ?? [] + var seen = Set(result.map(\.id)) + for pending in pendingRemoteCreations.values { + guard let workspace = pending.resource.remoteWorkspace, + seen.insert(workspace.id).inserted else { continue } + result.append(workspace) + } + return result.isEmpty ? nil : result + } + + func pendingMutationMetadata() -> [CloudVMPendingMutation] { + var writes = pendingRemoteCreations.map { resourceID, pending in + CloudVMPendingMutation( + kind: .terminalCreate, + resource: resourceID, + remoteWorkspaceID: pending.resource.remoteWorkspace?.id, + remoteTabID: pending.tabID, + name: pending.resource.remoteViews?.first?.name, + receipt: pending.receipt + ) + } + writes.append(contentsOf: pendingRemoteRenames.map { key, pending in + switch key { + case .workspace(let id): + return CloudVMPendingMutation( + kind: .workspaceRename, + resource: nil, + remoteWorkspaceID: id, + remoteTabID: nil, + name: pending.name, + receipt: pending.receipt + ) + case .tab(let id): + return CloudVMPendingMutation( + kind: .tabRename, + resource: nil, + remoteWorkspaceID: nil, + remoteTabID: id, + name: pending.name, + receipt: pending.receipt + ) + } + }) + return writes.sorted { left, right in + if left.kind.rawValue != right.kind.rawValue { + return left.kind.rawValue < right.kind.rawValue + } + let leftID = left.resource?.rawValue ?? left.remoteWorkspaceID ?? left.remoteTabID ?? "" + let rightID = right.resource?.rawValue ?? right.remoteWorkspaceID ?? right.remoteTabID ?? "" + return leftID < rightID + } + } + + func observationWithPendingWrites( + _ base: CloudVMStateObservation = .current + ) -> CloudVMStateObservation { + var observation = base + let pending = pendingMutationMetadata() + observation.pendingWrites = pending.isEmpty ? nil : pending + return observation + } + + func publishPendingMutationMetadata() { + catalog.updateCloudPendingWrites( + on: machine, + writes: pendingMutationMetadata(), + from: self + ) + } + + func pendingCreation(for resourceID: SurfaceResourceID) -> PendingRemoteCreation? { + pendingRemoteCreations[resourceID] + } + + func pendingCreation(forTabID tabID: String) -> PendingRemoteCreation? { + pendingRemoteCreations.values.first { $0.tabID == tabID } + } + + /// Advances a pending receipt after a follow-up rename commits before the + /// creation snapshot arrives. This keeps the optimistic row and its tab + /// label coherent without inventing a second canonical graph. + func recordPendingRename(tabID: String, name: String, revision: UInt64) { + for resourceID in Array(pendingRemoteCreations.keys) { + guard var pending = pendingRemoteCreations[resourceID], pending.tabID == tabID else { continue } + if let receipt = pending.receipt { + guard revision >= receipt.revision else { continue } + pending.receipt = CloudVMCursor(generation: receipt.generation, revision: revision) + } + if var views = pending.resource.remoteViews, + let viewIndex = views.firstIndex(where: { $0.tabID == tabID }) { + views[viewIndex].name = name + pending.resource.remoteViews = views + } + pendingRemoteCreations[resourceID] = pending + } + publishPendingMutationMetadata() + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index b4e804f8e39f..ed54cdbb376a 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -75,7 +75,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { let attachmentClock: any Clock /// Terminal → tab from the last snapshot, so an exited terminal (whose own selector /// no longer resolves in cmux-tui) can still be closed through its tab. - private var tabByTerminal: [String: String] = [:] + var tabByTerminal: [String: String] = [:] /// Coalesces concurrent first opens of a zero-view terminal. `terminal.project` is a /// mutation, so two local panes racing on the same pool row must share one remote view. // Internal so the manual-mirror extension can share the provider-owned task map. @@ -87,12 +87,12 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// the next snapshot can lag it. Keep the exact created row and placement /// until an accepted graph reaches that receipt. This is a transient view /// overlay, never a second remote-state store. - private struct PendingRemoteCreation { + struct PendingRemoteCreation { var resource: SurfaceResource var receipt: CloudVMCursor? let tabID: String? } - private var pendingRemoteCreations: [SurfaceResourceID: PendingRemoteCreation] = [:] + var pendingRemoteCreations: [SurfaceResourceID: PendingRemoteCreation] = [:] /// Rename receipts are transient read-your-write fences. They are keyed by /// daemon identity, not by a local title or projection, because one remote /// tab can be shown in several windows. The canonical graph remains the @@ -105,7 +105,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { var name: String var receipt: CloudVMCursor } - private var pendingRemoteRenames: [PendingRemoteRenameKey: PendingRemoteRename] = [:] + var pendingRemoteRenames: [PendingRemoteRenameKey: PendingRemoteRename] = [:] init( summary: VMSummary, links: CloudMachineLinkManager, @@ -681,7 +681,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { } } - private static func remoteWorkspaces(_ state: CloudVMState) -> [SurfaceRemoteWorkspace] { + static func remoteWorkspaces(_ state: CloudVMState) -> [SurfaceRemoteWorkspace] { state.workspaces.map { SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7a9a68cd312c..063356b5ce39 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1323,6 +1323,7 @@ 7F81637D3CD3867FAA502D1E /* CmuxTuiSurfaceProvider+FileDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */; }; C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */; }; C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */; }; + 58E8FB4BECA84B2283E0D906 /* CmuxTuiSurfaceProvider+PendingMutations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4091B3DC55D4121A082A1F0 /* CmuxTuiSurfaceProvider+PendingMutations.swift */; }; 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */; }; 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */; }; ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */; }; @@ -5042,6 +5043,7 @@ F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+FileDelivery.swift"; sourceTree = ""; }; 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+LayoutCreation.swift"; sourceTree = ""; }; C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirror.swift"; sourceTree = ""; }; + F4091B3DC55D4121A082A1F0 /* CmuxTuiSurfaceProvider+PendingMutations.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingMutations.swift"; sourceTree = ""; }; 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PlacementSync.swift"; sourceTree = ""; }; 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PortForward.swift"; sourceTree = ""; }; F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ProjectionLayout.swift"; sourceTree = ""; }; @@ -7878,6 +7880,7 @@ AB74882A29884E378E36D645 /* SurfaceCatalog+CloudWorkspaceProjection.swift */, 183FE43D3F71A474726D6AA6 /* CloudWorkspaceLayoutTranslator.swift */, 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */, + F4091B3DC55D4121A082A1F0 /* CmuxTuiSurfaceProvider+PendingMutations.swift */, D12439050000000000000002 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift */, A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */, B12438020000000000000002 /* CloudRenameCoordinator.swift */, @@ -12614,6 +12617,7 @@ 7F81637D3CD3867FAA502D1E /* CmuxTuiSurfaceProvider+FileDelivery.swift in Sources */, C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */, C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */, + 58E8FB4BECA84B2283E0D906 /* CmuxTuiSurfaceProvider+PendingMutations.swift in Sources */, 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */, 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */, ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */, From 5d29051f2b504651e9093da5e9ef7cdbf982ce2f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 13 Sep 2026 18:41:30 -0700 Subject: [PATCH 04/15] fix: restore Cloud provider ownership after recovery revert --- .../cloud-provider-release-guard.yml | 115 +++++++++++ ...CmuxTuiSurfaceProvider+CloseTerminal.swift | 5 +- ...faceProvider+PendingCreationRecovery.swift | 182 ++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 + 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/cloud-provider-release-guard.yml create mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+PendingCreationRecovery.swift diff --git a/.github/workflows/cloud-provider-release-guard.yml b/.github/workflows/cloud-provider-release-guard.yml new file mode 100644 index 000000000000..d3991da1f5c8 --- /dev/null +++ b/.github/workflows/cloud-provider-release-guard.yml @@ -0,0 +1,115 @@ +name: Cloud provider Release compile guard + +on: + pull_request: + paths: + - Sources/Surfaces/CmuxTuiSurfaceProvider*.swift + - Sources/Surfaces/CmuxTuiSurfaceProviders.swift + - Sources/Surfaces/Workspace+CloudPaneRouting.swift + - Sources/Cloud/CloudTreeCellView.swift + - cmux.xcodeproj/project.pbxproj + - .github/workflows/cloud-provider-release-guard.yml + workflow_dispatch: + inputs: + ref: + description: Branch or SHA to compile + required: false + default: "" + +permissions: + contents: read + +concurrency: + group: cloud-provider-release-${{ github.event.pull_request.number || inputs.ref || github.ref }} + cancel-in-progress: true + +jobs: + cloud-provider-release-compile: + name: Cloud provider Release compile + runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-12vcpu-macos-26' }} + timeout-minutes: 45 + env: + CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} + CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" + SOURCE_PACKAGES_DIR: ${{ github.workspace }}/.ci-source-packages + DERIVED_DATA_PATH: ${{ github.workspace }}/build-cloud-provider-release + steps: + - name: Clear stale git locks + shell: bash + run: | + set -euo pipefail + ws="${GITHUB_WORKSPACE:-$PWD}" + rm -f "$ws/.git/index.lock" 2>/dev/null || true + if [ -d "$ws/.git/modules" ]; then + find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true + fi + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || inputs.ref || github.sha }} + fetch-depth: 0 + submodules: recursive + + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + + - name: Install Rust for app support targets + run: ./scripts/install-rust-ci.sh + + - name: Download prebuilt GhosttyKit + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Cache Swift packages + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: .ci-source-packages + key: cloud-provider-spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: cloud-provider-spm- + + - name: Sanitize Swift package cache + run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py "$SOURCE_PACKAGES_DIR" + + - name: Set up Go for the Cloud tunnel extension + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.x' + cache: false + + - name: Resolve Swift packages + run: | + set -euo pipefail + mkdir -p "$SOURCE_PACKAGES_DIR" + for attempt in 1 2 3; do + if xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Release \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." >&2 + sleep $((attempt * 5)) + done + + - name: Build unsigned universal Release app + run: | + set -euo pipefail + rm -rf "$DERIVED_DATA_PATH" + CMUX_SKIP_ZIG_BUILD=1 xcodebuild \ + -scheme cmux \ + -configuration Release \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -destination 'generic/platform=macOS' \ + -showBuildTimingSummary \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + COMPILER_INDEX_STORE_ENABLE=NO \ + CODE_SIGNING_ALLOWED=NO \ + ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly \ + build diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloseTerminal.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloseTerminal.swift index aeaa45e48c01..6da0dc74bd60 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloseTerminal.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloseTerminal.swift @@ -7,13 +7,14 @@ extension CmuxTuiSurfaceProvider { /// Closes a Cloud terminal, falling back to its daemon tab when the process already exited. func closeTerminal(_ id: SurfaceResourceID, fallbackTabID: String?) async throws { - pendingRemoteCreations.removeValue(forKey: id) + let pendingTabID = pendingRemoteCreations[id]?.tabID do { _ = try await runCloseCommand { CloudTuiCommandLine.closeTerminalArguments(socketPath: $0, terminalID: id.key) } } catch { - guard let tabID = fallbackTabID ?? tabByTerminal[id.key], Self.isSelectorNotFound(error) else { throw error } + guard let tabID = fallbackTabID ?? pendingTabID ?? tabByTerminal[id.key], Self.isSelectorNotFound(error) else { throw error } _ = try await runCloseCommand { CloudTuiCommandLine.closeTabArguments(socketPath: $0, tabID: tabID) } } + pendingRemoteCreations.removeValue(forKey: id) closeLocalPanes(showing: [id]); catalog.remove(id, from: self); scheduleRefresh() } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingCreationRecovery.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingCreationRecovery.swift new file mode 100644 index 000000000000..0b1811e69c31 --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingCreationRecovery.swift @@ -0,0 +1,182 @@ +import Foundation + +/// Read-your-write overlays for Cloud terminal creation and rename receipts. +/// +/// This extension owns the transient pending metadata used while the accepted +/// daemon graph catches up with a mutation response. +@MainActor +extension CmuxTuiSurfaceProvider { + /// Merges pending mutation receipts into derived rows until an accepted + /// graph reaches each receipt. The canonical graph is never edited here. + /// A generation change, or a cursorless snapshot after a versioned receipt, + /// retires the overlay because the old placement cannot be proven to exist. + private func resourcesWithPendingCreations( + _ resources: [SurfaceResource], + state: CloudVMState? + ) -> [SurfaceResource] { + var merged = resources + var completed: [SurfaceResourceID] = [] + for (resourceID, pending) in pendingRemoteCreations where resourceID.machine == machine { + if let state { + if let receipt = pending.receipt { + guard let cursor = state.cursor, + cursor.generation == receipt.generation else { + completed.append(resourceID) + continue + } + if cursor.revision >= receipt.revision { + // At or beyond the commit, the accepted graph is the + // source of truth, including an intentional close. + completed.append(resourceID) + continue + } + } else if pendingCreationIsVisible(pending, in: state) { + // Legacy mutation responses have no ordering fence. Stop + // overlaying as soon as the exact path is observed. + completed.append(resourceID) + continue + } + } + mergePendingCreation(pending, into: &merged) + } + for resourceID in completed { + pendingRemoteCreations.removeValue(forKey: resourceID) + } + return merged + } + + private func pendingCreationIsVisible( + _ pending: PendingRemoteCreation, + in state: CloudVMState + ) -> Bool { + guard state.lookupIndex.terminal(id: pending.resource.id.key) != nil else { return false } + guard let tabID = pending.tabID else { return true } + return state.lookupIndex.tab(id: tabID) != nil + } + + private func mergePendingCreation( + _ pending: PendingRemoteCreation, + into resources: inout [SurfaceResource] + ) { + guard let pendingView = pending.resource.remoteViews?.first else { + if !resources.contains(where: { $0.id == pending.resource.id }) { + resources.append(pending.resource) + } + return + } + guard let index = resources.firstIndex(where: { $0.id == pending.resource.id }) else { + resources.append(pending.resource) + return + } + var resource = resources[index] + var views = resource.remoteViews ?? [] + if !views.contains(where: { $0.tabID == pendingView.tabID }) { + views.append(pendingView) + resource.remoteViews = views + if resource.remoteWorkspace == nil { + resource.remoteWorkspace = pendingView.workspace + } + } + resources[index] = resource + } + + private func remoteWorkspaces(for state: CloudVMState?) -> [SurfaceRemoteWorkspace]? { + var result = state.map(Self.remoteWorkspaces) ?? info.remoteWorkspaces ?? [] + var seen = Set(result.map(\.id)) + for pending in pendingRemoteCreations.values { + guard let workspace = pending.resource.remoteWorkspace, + seen.insert(workspace.id).inserted else { continue } + result.append(workspace) + } + return result.isEmpty ? nil : result + } + + private func pendingMutationMetadata() -> [CloudVMPendingMutation] { + var writes = pendingRemoteCreations.map { resourceID, pending in + CloudVMPendingMutation( + kind: .terminalCreate, + resource: resourceID, + remoteWorkspaceID: pending.resource.remoteWorkspace?.id, + remoteTabID: pending.tabID, + name: pending.resource.remoteViews?.first?.name, + receipt: pending.receipt + ) + } + writes.append(contentsOf: pendingRemoteRenames.map { key, pending in + switch key { + case .workspace(let id): + return CloudVMPendingMutation( + kind: .workspaceRename, + resource: nil, + remoteWorkspaceID: id, + remoteTabID: nil, + name: pending.name, + receipt: pending.receipt + ) + case .tab(let id): + return CloudVMPendingMutation( + kind: .tabRename, + resource: nil, + remoteWorkspaceID: nil, + remoteTabID: id, + name: pending.name, + receipt: pending.receipt + ) + } + }) + return writes.sorted { left, right in + if left.kind.rawValue != right.kind.rawValue { + return left.kind.rawValue < right.kind.rawValue + } + let leftID = left.resource?.rawValue ?? left.remoteWorkspaceID ?? left.remoteTabID ?? "" + let rightID = right.resource?.rawValue ?? right.remoteWorkspaceID ?? right.remoteTabID ?? "" + return leftID < rightID + } + } + + private func observationWithPendingWrites( + _ base: CloudVMStateObservation = .current + ) -> CloudVMStateObservation { + var observation = base + let pending = pendingMutationMetadata() + observation.pendingWrites = pending.isEmpty ? nil : pending + return observation + } + + private func publishPendingMutationMetadata() { + catalog.updateCloudPendingWrites( + on: machine, + writes: pendingMutationMetadata(), + from: self + ) + } + + private func pendingCreation(for resourceID: SurfaceResourceID) -> PendingRemoteCreation? { + pendingRemoteCreations[resourceID] + } + + func pendingCreation(forTabID tabID: String) -> PendingRemoteCreation? { + pendingRemoteCreations.values.first { $0.tabID == tabID } + } + + /// Advances a pending receipt after a follow-up rename commits before the + /// creation snapshot arrives. This keeps the optimistic row and its tab + /// label coherent without inventing a second canonical graph. + func recordPendingRename(tabID: String, name: String, revision: UInt64) { + for resourceID in Array(pendingRemoteCreations.keys) { + guard var pending = pendingRemoteCreations[resourceID], pending.tabID == tabID else { continue } + if let receipt = pending.receipt { + guard revision >= receipt.revision else { continue } + pending.receipt = CloudVMCursor(generation: receipt.generation, revision: revision) + } + if var views = pending.resource.remoteViews, + let viewIndex = views.firstIndex(where: { $0.tabID == tabID }) { + views[viewIndex].name = name + pending.resource.remoteViews = views + } + pendingRemoteCreations[resourceID] = pending + } + publishPendingMutationMetadata() + } + +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 063356b5ce39..d95a52e90c9c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1318,6 +1318,7 @@ 3312CA2585324CF9B4CA9D9A /* CmuxTuiSurfaceProvider+AgentNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3528E32A29B49E99C636F0B /* CmuxTuiSurfaceProvider+AgentNaming.swift */; }; 81A1621623F83B61D4C5FFA9 /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8BE7D3234A1F44E2A1C52B /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift */; }; 5633045E23E44075B22C08CA /* CmuxTuiSurfaceProvider+CloseTerminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */; }; + C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; AA92C99C529DB9A24064AF48 /* CmuxTuiSurfaceProvider+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE30231700141C6F370FA63 /* CmuxTuiSurfaceProvider+Environment.swift */; }; A12093000000000000000002 /* CmuxTuiSurfaceProvider+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12093000000000000000001 /* CmuxTuiSurfaceProvider+Error.swift */; }; 7F81637D3CD3867FAA502D1E /* CmuxTuiSurfaceProvider+FileDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */; }; @@ -5038,6 +5039,7 @@ E3528E32A29B49E99C636F0B /* CmuxTuiSurfaceProvider+AgentNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+AgentNaming.swift"; sourceTree = ""; }; 4D8BE7D3234A1F44E2A1C52B /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+AttachmentRecovery.swift"; sourceTree = ""; }; E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloseTerminal.swift"; sourceTree = ""; }; + C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; 4BE30231700141C6F370FA63 /* CmuxTuiSurfaceProvider+Environment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Environment.swift"; sourceTree = ""; }; A12093000000000000000001 /* CmuxTuiSurfaceProvider+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Error.swift"; sourceTree = ""; }; F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+FileDelivery.swift"; sourceTree = ""; }; @@ -7851,6 +7853,7 @@ 3FB8309C25B29D6CEE17BD64 /* SurfaceSocketCommands+VMFileDelivery.swift */, F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */, E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */, + C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */, DA32812C9009969F4AEA2380 /* CloudFileDelivery.swift */, 4ECA6203F1AB489BDF3F7A05 /* CmuxTuiSurfaceProvider+TerminalIO.swift */, 122E7DF3DF3044E68322B11C /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift */, @@ -12612,6 +12615,7 @@ 3312CA2585324CF9B4CA9D9A /* CmuxTuiSurfaceProvider+AgentNaming.swift in Sources */, 81A1621623F83B61D4C5FFA9 /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift in Sources */, 5633045E23E44075B22C08CA /* CmuxTuiSurfaceProvider+CloseTerminal.swift in Sources */, + C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, AA92C99C529DB9A24064AF48 /* CmuxTuiSurfaceProvider+Environment.swift in Sources */, A12093000000000000000002 /* CmuxTuiSurfaceProvider+Error.swift in Sources */, 7F81637D3CD3867FAA502D1E /* CmuxTuiSurfaceProvider+FileDelivery.swift in Sources */, From b06d37cd3ddf0fe85335c4d86dc2898c5928d118 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:19:01 -0700 Subject: [PATCH 05/15] Remove duplicate pending mutation helpers --- ...xTuiSurfaceProvider+PendingMutations.swift | 178 ------------------ cmux.xcodeproj/project.pbxproj | 3 - 2 files changed, 181 deletions(-) delete mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift deleted file mode 100644 index 0d8228287be7..000000000000 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+PendingMutations.swift +++ /dev/null @@ -1,178 +0,0 @@ -import Foundation - -/// Maintains creation and rename receipts until an authoritative graph catches up. -@MainActor -extension CmuxTuiSurfaceProvider { - /// Merges pending mutation receipts into derived rows until an accepted - /// graph reaches each receipt. The canonical graph is never edited here. - /// A generation change, or a cursorless snapshot after a versioned receipt, - /// retires the overlay because the old placement cannot be proven to exist. - func resourcesWithPendingCreations( - _ resources: [SurfaceResource], - state: CloudVMState? - ) -> [SurfaceResource] { - var merged = resources - var completed: [SurfaceResourceID] = [] - for (resourceID, pending) in pendingRemoteCreations where resourceID.machine == machine { - if let state { - if let receipt = pending.receipt { - guard let cursor = state.cursor, - cursor.generation == receipt.generation else { - completed.append(resourceID) - continue - } - if cursor.revision >= receipt.revision { - // At or beyond the commit, the accepted graph is the - // source of truth, including an intentional close. - completed.append(resourceID) - continue - } - } else if pendingCreationIsVisible(pending, in: state) { - // Legacy mutation responses have no ordering fence. Stop - // overlaying as soon as the exact path is observed. - completed.append(resourceID) - continue - } - } - mergePendingCreation(pending, into: &merged) - } - for resourceID in completed { - pendingRemoteCreations.removeValue(forKey: resourceID) - } - return merged - } - - private func pendingCreationIsVisible( - _ pending: PendingRemoteCreation, - in state: CloudVMState - ) -> Bool { - guard state.lookupIndex.terminal(id: pending.resource.id.key) != nil else { return false } - guard let tabID = pending.tabID else { return true } - return state.lookupIndex.tab(id: tabID) != nil - } - - private func mergePendingCreation( - _ pending: PendingRemoteCreation, - into resources: inout [SurfaceResource] - ) { - guard let pendingView = pending.resource.remoteViews?.first else { - if !resources.contains(where: { $0.id == pending.resource.id }) { - resources.append(pending.resource) - } - return - } - guard let index = resources.firstIndex(where: { $0.id == pending.resource.id }) else { - resources.append(pending.resource) - return - } - var resource = resources[index] - var views = resource.remoteViews ?? [] - if !views.contains(where: { $0.tabID == pendingView.tabID }) { - views.append(pendingView) - resource.remoteViews = views - if resource.remoteWorkspace == nil { - resource.remoteWorkspace = pendingView.workspace - } - } - resources[index] = resource - } - - func remoteWorkspaces(for state: CloudVMState?) -> [SurfaceRemoteWorkspace]? { - var result = state.map(Self.remoteWorkspaces) ?? info.remoteWorkspaces ?? [] - var seen = Set(result.map(\.id)) - for pending in pendingRemoteCreations.values { - guard let workspace = pending.resource.remoteWorkspace, - seen.insert(workspace.id).inserted else { continue } - result.append(workspace) - } - return result.isEmpty ? nil : result - } - - func pendingMutationMetadata() -> [CloudVMPendingMutation] { - var writes = pendingRemoteCreations.map { resourceID, pending in - CloudVMPendingMutation( - kind: .terminalCreate, - resource: resourceID, - remoteWorkspaceID: pending.resource.remoteWorkspace?.id, - remoteTabID: pending.tabID, - name: pending.resource.remoteViews?.first?.name, - receipt: pending.receipt - ) - } - writes.append(contentsOf: pendingRemoteRenames.map { key, pending in - switch key { - case .workspace(let id): - return CloudVMPendingMutation( - kind: .workspaceRename, - resource: nil, - remoteWorkspaceID: id, - remoteTabID: nil, - name: pending.name, - receipt: pending.receipt - ) - case .tab(let id): - return CloudVMPendingMutation( - kind: .tabRename, - resource: nil, - remoteWorkspaceID: nil, - remoteTabID: id, - name: pending.name, - receipt: pending.receipt - ) - } - }) - return writes.sorted { left, right in - if left.kind.rawValue != right.kind.rawValue { - return left.kind.rawValue < right.kind.rawValue - } - let leftID = left.resource?.rawValue ?? left.remoteWorkspaceID ?? left.remoteTabID ?? "" - let rightID = right.resource?.rawValue ?? right.remoteWorkspaceID ?? right.remoteTabID ?? "" - return leftID < rightID - } - } - - func observationWithPendingWrites( - _ base: CloudVMStateObservation = .current - ) -> CloudVMStateObservation { - var observation = base - let pending = pendingMutationMetadata() - observation.pendingWrites = pending.isEmpty ? nil : pending - return observation - } - - func publishPendingMutationMetadata() { - catalog.updateCloudPendingWrites( - on: machine, - writes: pendingMutationMetadata(), - from: self - ) - } - - func pendingCreation(for resourceID: SurfaceResourceID) -> PendingRemoteCreation? { - pendingRemoteCreations[resourceID] - } - - func pendingCreation(forTabID tabID: String) -> PendingRemoteCreation? { - pendingRemoteCreations.values.first { $0.tabID == tabID } - } - - /// Advances a pending receipt after a follow-up rename commits before the - /// creation snapshot arrives. This keeps the optimistic row and its tab - /// label coherent without inventing a second canonical graph. - func recordPendingRename(tabID: String, name: String, revision: UInt64) { - for resourceID in Array(pendingRemoteCreations.keys) { - guard var pending = pendingRemoteCreations[resourceID], pending.tabID == tabID else { continue } - if let receipt = pending.receipt { - guard revision >= receipt.revision else { continue } - pending.receipt = CloudVMCursor(generation: receipt.generation, revision: revision) - } - if var views = pending.resource.remoteViews, - let viewIndex = views.firstIndex(where: { $0.tabID == tabID }) { - views[viewIndex].name = name - pending.resource.remoteViews = views - } - pendingRemoteCreations[resourceID] = pending - } - publishPendingMutationMetadata() - } -} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b8b866079b1d..baa669d60cdb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1327,7 +1327,6 @@ C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */; }; C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; - 58E8FB4BECA84B2283E0D906 /* CmuxTuiSurfaceProvider+PendingMutations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4091B3DC55D4121A082A1F0 /* CmuxTuiSurfaceProvider+PendingMutations.swift */; }; 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */; }; 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */; }; ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */; }; @@ -5051,7 +5050,6 @@ C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirror.swift"; sourceTree = ""; }; C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; - F4091B3DC55D4121A082A1F0 /* CmuxTuiSurfaceProvider+PendingMutations.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingMutations.swift"; sourceTree = ""; }; 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PlacementSync.swift"; sourceTree = ""; }; 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PortForward.swift"; sourceTree = ""; }; F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ProjectionLayout.swift"; sourceTree = ""; }; @@ -12632,7 +12630,6 @@ C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */, C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, - 58E8FB4BECA84B2283E0D906 /* CmuxTuiSurfaceProvider+PendingMutations.swift in Sources */, 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */, 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */, ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */, From 7ce291483cb2b2fe5b6f7ca9de4f57ad0d8efc9f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 13 Sep 2026 19:19:27 -0700 Subject: [PATCH 06/15] fix: stabilize Cloud row view actor isolation --- Sources/Cloud/CloudTreeRowContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 97c63cdd27b5..7c6a23cc752b 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -25,6 +25,7 @@ enum CloudTreeIconPalette { static let machine = Color.accentColor } +@MainActor struct CloudTreeRowContentView: View { let kind: CloudTreeNode.Kind var style: CloudTreeStyle = CloudTreeStyleStore.current From c6a685c0c496fcdc507565b21d13db01ab1c6b29 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:49:45 -0700 Subject: [PATCH 07/15] Fix Cloud drag test window override --- cmuxTests/CloudTreeNativeDragOwnershipTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift index 5ff6711c7237..4b28d30007dc 100644 --- a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift +++ b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift @@ -12,10 +12,10 @@ import Testing @Suite("Cloud tree native drag ownership", .serialized) struct CloudTreeNativeDragOwnershipTests { private final class HoverWindow: NSWindow { - var keyWindow = false + var simulatedKeyWindow = false var pointerOnScreen = NSPoint.zero - override var isKeyWindow: Bool { keyWindow } + override var isKeyWindow: Bool { simulatedKeyWindow } override var mouseLocationOutsideOfEventStream: NSPoint { pointerOnScreen } } @@ -317,7 +317,7 @@ struct CloudTreeNativeDragOwnershipTests { NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) #expect(buttons.alphaValue == 0) - window.keyWindow = true + window.simulatedKeyWindow = true NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: window) #expect(buttons.alphaValue == 1) _ = window From dde61c9c9d9b702b70b427e605d4905957efc2ed Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:15:32 -0700 Subject: [PATCH 08/15] Keep Cloud row formatters nonisolated --- Sources/Cloud/CloudTreeRowContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 7c6a23cc752b..747bb85d7acc 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -143,7 +143,7 @@ struct CloudTreeRowContentView: View { /// Formats the transport and screen label shown beneath a VNC display row. /// A key such as `display:1` becomes `noVNC · :1`; unknown key shapes retain /// the transport-only detail. - static func text(for resource: SurfaceResource) -> String { + nonisolated static func text(for resource: SurfaceResource) -> String { let transport = String(localized: "cloudTree.node.desktop.detail", defaultValue: "noVNC") guard let screen = screenLabel(displayKey: resource.id.key) else { return transport } return String( @@ -155,7 +155,7 @@ struct CloudTreeRowContentView: View { /// Converts a display resource key such as `display:1` to its X display /// label (`:1`), returning nil for keys that are not numbered displays. - static func screenLabel(displayKey key: String) -> String? { + nonisolated static func screenLabel(displayKey key: String) -> String? { let prefix = "display:" guard key.hasPrefix(prefix) else { return nil } let number = key.dropFirst(prefix.count) From a54a0fdce698eca69cece944d5badf81500cfe60 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:23:28 -0700 Subject: [PATCH 09/15] Fix Cloud hover pointer coordinate conversion --- cmuxTests/CloudTreeNativeDragOwnershipTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift index 4b28d30007dc..df566584eede 100644 --- a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift +++ b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift @@ -313,7 +313,8 @@ struct CloudTreeNativeDragOwnershipTests { let cell = try #require(outline.view(atColumn: 0, row: 0, makeIfNecessary: true) as? CloudTreeCellView) let buttons = try #require(cell.subviews.last) let rowPoint = NSPoint(x: outline.rect(ofRow: 0).midX, y: outline.rect(ofRow: 0).midY) - window.pointerOnScreen = window.convertToScreen(outline.convert(rowPoint, to: nil)) + let rowRect = NSRect(origin: rowPoint, size: .zero) + window.pointerOnScreen = window.convertToScreen(outline.convert(rowRect, to: nil)).origin NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) #expect(buttons.alphaValue == 0) From 9bd572f2048987421680dd7abf73cc040af902be Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:31:53 -0700 Subject: [PATCH 10/15] test: cover background defaults notification deadlock --- .../MobileHostIdentityConcurrencyTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cmuxTests/MobileHostIdentityConcurrencyTests.swift b/cmuxTests/MobileHostIdentityConcurrencyTests.swift index 82c494583a5f..4e1cc7cea827 100644 --- a/cmuxTests/MobileHostIdentityConcurrencyTests.swift +++ b/cmuxTests/MobileHostIdentityConcurrencyTests.swift @@ -9,6 +9,33 @@ import Testing @Suite(.serialized) @MainActor struct MobileHostIdentityConcurrencyTests { + @Test(.timeLimit(.minutes(1))) + func backgroundDefaultsNotificationDoesNotWaitForTheMainThread() async { + let center = NotificationCenter() + let environment = AppearanceSettingsUserDefaultsObserver.Environment.live(notificationCenter: center) + let (deliveries, continuation) = AsyncStream.makeStream() + let observer = environment.addDefaultsObserver { + MainActor.preconditionIsolated() + continuation.yield() + } + defer { + environment.removeObserver(observer) + continuation.finish() + } + let postReturned = DispatchSemaphore(value: 0) + Thread.detachNewThread { + center.post(name: UserDefaults.didChangeNotification, object: nil) + postReturned.signal() + } + + // Deliberately hold main while a background preference writer posts. + // A finite wait reproduces the cache-initialization deadlock without + // leaving the test host blocked when the assertion fails. + #expect(postReturned.wait(timeout: .now() + 1) == .success) + var iterator = deliveries.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + @Test func dismissalWarmupGateDoesNotResolveIdentityOnTheSynchronousPath() throws { let prewarm = PhonePushIdentityPrewarm( identityProvider: NeverReadyPhonePushIdentityProvider() From add1e7c54f83fc6f74d28c5de2a123b57c850b5e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:38:16 -0700 Subject: [PATCH 11/15] fix: prevent preference observers from blocking background writers --- .../NotificationCenter+UserDefaults.swift | 29 +++++++++ ...serDefaultsNotificationDeliveryTests.swift | 61 +++++++++++++++++++ Sources/AppDelegate.swift | 12 +--- Sources/AppearanceSettings.swift | 9 +-- ...ityInvalidationSubscriberCoordinator.swift | 7 +-- Sources/Cloud/PresenceHeartbeatClient.swift | 7 +-- Sources/ContentView.swift | 6 +- Sources/KeyboardShortcutSettings.swift | 7 +-- .../BrowserHiddenWebViewDiscardManager.swift | 7 +-- Sources/Panels/MarkdownPanel.swift | 6 +- Sources/TabManager.swift | 6 +- Sources/TerminalNotificationStore.swift | 6 +- Sources/Update/UpdateTitlebarAccessory.swift | 18 +----- Sources/WindowDecorationsController.swift | 3 +- Sources/WindowToolbarController.swift | 6 +- cmuxTests/AppearanceSettingsTests.swift | 2 +- .../MobileHostIdentityConcurrencyTests.swift | 6 +- 17 files changed, 119 insertions(+), 79 deletions(-) create mode 100644 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/NotificationCenter+UserDefaults.swift create mode 100644 Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/UserDefaultsNotificationDeliveryTests.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/NotificationCenter+UserDefaults.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/NotificationCenter+UserDefaults.swift new file mode 100644 index 000000000000..65e994c84c7d --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/NotificationCenter+UserDefaults.swift @@ -0,0 +1,29 @@ +public import Foundation + +extension NotificationCenter { + /// Observes preference changes on the main actor without blocking background writers. + /// + /// Main-thread posts retain synchronous delivery. Background posts schedule + /// the callback and return before it runs. Foundation's `queue: .main` + /// observer blocks the posting thread, which can deadlock a lazy initializer + /// when the main thread is waiting for its value. + /// + /// - Parameters: + /// - object: The preferences object to observe, or `nil` for all objects. + /// - handler: The main-actor action that re-reads the current preferences. + /// - Returns: An observer token to remove with `removeObserver(_:)` at teardown. + public func addUserDefaultsObserver( + object: AnyObject? = nil, + using handler: @escaping @MainActor @Sendable () -> Void + ) -> any NSObjectProtocol { + // This callback is the Foundation notification boundary. Never make a + // preference writer wait for an operation on the main queue. + addObserver(forName: UserDefaults.didChangeNotification, object: object, queue: nil) { _ in + if Thread.isMainThread { + MainActor.assumeIsolated { handler() } + } else { + Task { @MainActor in handler() } + } + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/UserDefaultsNotificationDeliveryTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/UserDefaultsNotificationDeliveryTests.swift new file mode 100644 index 000000000000..03a7909758ca --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/UserDefaultsNotificationDeliveryTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite("Preference notification delivery") +struct UserDefaultsNotificationDeliveryTests { + @Test @MainActor func mainThreadPostsDeliverSynchronously() { + let center = NotificationCenter() + var deliveries = 0 + let observer = center.addUserDefaultsObserver { deliveries += 1 } + defer { center.removeObserver(observer) } + + center.post(name: UserDefaults.didChangeNotification, object: nil) + + #expect(deliveries == 1) + } + + @Test(.timeLimit(.minutes(1))) @MainActor + func backgroundWriterReturnsWhileMainThreadIsOccupied() async { + let center = NotificationCenter() + let (deliveries, continuation) = AsyncStream.makeStream() + let observer = center.addUserDefaultsObserver { + MainActor.preconditionIsolated() + continuation.yield() + } + defer { + center.removeObserver(observer) + continuation.finish() + } + let posted = DispatchSemaphore(value: 0) + Thread.detachNewThread { + center.post(name: UserDefaults.didChangeNotification, object: nil) + posted.signal() + } + // Hold main until the writer returns, with a finite failure deadline. + // A synchronous main-queue observer cannot complete this sequence. + #expect(Self.waitForPostWhileMainThreadIsOccupied(posted)) + var iterator = deliveries.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + + @MainActor private static func waitForPostWhileMainThreadIsOccupied(_ posted: DispatchSemaphore) -> Bool { + posted.wait(timeout: .now() + 1) == .success + } + + @Test @MainActor func objectFilteringAndRemovalRemainEffective() { + let center = NotificationCenter() + let expected = NSObject() + let other = NSObject() + var deliveries = 0 + let observer = center.addUserDefaultsObserver(object: expected) { deliveries += 1 } + center.post(name: UserDefaults.didChangeNotification, object: other) + #expect(deliveries == 0) + center.post(name: UserDefaults.didChangeNotification, object: expected) + #expect(deliveries == 1) + + center.removeObserver(observer) + center.post(name: UserDefaults.didChangeNotification, object: expected) + #expect(deliveries == 1) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 33d55653355b..6b82c2c35fbe 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10739,11 +10739,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installMenuBarVisibilityObserver() { guard menuBarVisibilityObserver == nil else { return } - menuBarVisibilityObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + menuBarVisibilityObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.syncApplicationPresentationPreferences() } @@ -10766,11 +10762,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installMobileHostSettingsObserver() { guard mobileHostSettingsObserver == nil else { return } - mobileHostSettingsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + mobileHostSettingsObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.syncMobileHostService() } diff --git a/Sources/AppearanceSettings.swift b/Sources/AppearanceSettings.swift index 9e74a8ce37de..7b67587cc644 100644 --- a/Sources/AppearanceSettings.swift +++ b/Sources/AppearanceSettings.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import AppKit import SwiftUI import CmuxTerminalCore @@ -272,7 +273,7 @@ enum AppearanceSettings { final class AppearanceSettingsUserDefaultsObserver { struct Environment { - let addDefaultsObserver: (@escaping () -> Void) -> NSObjectProtocol + let addDefaultsObserver: (@escaping @MainActor @Sendable () -> Void) -> NSObjectProtocol let removeObserver: (NSObjectProtocol) -> Void let currentRawValue: () -> String? let applyStoredMode: (String?, String) -> AppearanceMode @@ -283,11 +284,7 @@ final class AppearanceSettingsUserDefaultsObserver { ) -> Environment { Environment( addDefaultsObserver: { handler in - notificationCenter.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { _ in + notificationCenter.addUserDefaultsObserver(object: nil) { handler() } }, diff --git a/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift b/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift index 7c8c75c42683..cd7794d05ad5 100644 --- a/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift +++ b/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import CmuxAuthRuntime import CmuxIrohTransport import Foundation @@ -25,11 +26,7 @@ final class ConnectivityInvalidationSubscriberCoordinator { func configure(auth: AuthCoordinator) { self.auth = auth if defaultsObserver == nil { - defaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: UserDefaults.standard, - queue: .main - ) { [weak self] _ in + defaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: UserDefaults.standard) { [weak self] in MainActor.assumeIsolated { self?.evaluate() } diff --git a/Sources/Cloud/PresenceHeartbeatClient.swift b/Sources/Cloud/PresenceHeartbeatClient.swift index e739dcb9d6c1..11908b8b36d2 100644 --- a/Sources/Cloud/PresenceHeartbeatClient.swift +++ b/Sources/Cloud/PresenceHeartbeatClient.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import CMUXMobileCore import CmuxAuthRuntime import Foundation @@ -50,11 +51,7 @@ final class PresenceHeartbeatClient { // Re-evaluate when the flag or URL flips, so enabling presence in a // running app starts the loop without a relaunch (and disabling // stops it and says goodbye). - defaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: UserDefaults.standard, - queue: .main - ) { _ in + defaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: UserDefaults.standard) { MainActor.assumeIsolated { PresenceHeartbeatClient.shared.evaluate() } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 65c6deb1291d..021c25ec71da 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11072,11 +11072,7 @@ private final class SidebarTabItemSettingsStore: ObservableObject { defaults: defaults, sidebarFontSize: sidebarFontSize ) - defaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + defaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.refreshSnapshot() } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 88ff04a267c9..97a14f9fa587 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import AppKit import Bonsplit import Carbon @@ -1218,11 +1219,7 @@ final class SystemWideHotkeyController { installHotKeyHandlerIfNeeded() - defaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + defaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in self?.refreshRegistration() } shortcutObserver = NotificationCenter.default.addObserver( diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index 7bbc9bb3f288..fd4bb29bb3cf 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import AppKit import Foundation @@ -306,11 +307,7 @@ final class BrowserHiddenWebViewDiscardManager { func installPolicyObserver() { policyState = BrowserHiddenWebViewDiscardPolicy.resolved(defaults: policyDefaults) guard policyObserver == nil else { return } - policyObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + policyObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.handlePolicyDefaultsChanged() } diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index dbda38de8916..0be03d940ea3 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -267,11 +267,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// or a `cmux.json` reload), but only while this viewer still matches the /// default it was tracking — i.e. the user has not customized it. private func observeTypographyDefaults() { - typographyDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: UserDefaults.standard, - queue: .main - ) { [weak self] _ in + typographyDefaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: UserDefaults.standard) { [weak self] in Task { @MainActor in self?.adoptTypographyDefaultsIfFollowing() } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index db99932ab9a6..1bf2623bf6d3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -726,11 +726,7 @@ class TabManager: ObservableObject { }) startAgentPIDSweepTimer() - observers.append(NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + observers.append(NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in MainActor.assumeIsolated { [weak self] in self?.sidebarMetadataSettingsDidChange() self?.focusHistoryScopeSettingsDidChange() diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index d50cd2dc9ad3..4a8927ebd4e8 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -471,11 +471,7 @@ final class TerminalNotificationStore: ObservableObject { ) } indexes = Self.buildIndexes(for: notifications) - userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + userDefaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.refreshDockBadge() } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index d508425a92a3..a3eb2a616592 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -180,11 +180,7 @@ final class TitlebarControlsLayoutModel { ) observers.append( - notificationCenter.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + notificationCenter.addUserDefaultsObserver(object: nil) { [weak self] in MainActor.assumeIsolated { self?.refreshStyleIfNeeded() } @@ -2040,11 +2036,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.layer?.masksToBounds = false containerView.addSubview(hostingView) - userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + userDefaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in guard let self else { return } let shouldShow = self.showsWorkspaceTitlebar let debugSnapshot = MinimalModeTitlebarDebugSettings.snapshot() @@ -2865,11 +2857,7 @@ final class UpdateTitlebarAccessoryController { // Re-evaluate all windows when the presentation mode changes so that // accessories are removed in minimal mode and re-attached in standard mode. - observers.append(center.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + observers.append(center.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.reattachIfPresentationModeChanged() } diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift index 761a36255869..2f690d17c598 100644 --- a/Sources/WindowDecorationsController.swift +++ b/Sources/WindowDecorationsController.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import AppKit import CmuxTestSupport @@ -59,7 +60,7 @@ final class WindowDecorationsController { for name in TitlebarWindowGeometryNotifications.names { observers.append(center.addObserver(forName: name, object: nil, queue: .main, using: handler)) } - observers.append(center.addObserver(forName: UserDefaults.didChangeNotification, object: nil, queue: .main) { [weak self] _ in + observers.append(center.addUserDefaultsObserver(object: nil) { [weak self] in self?.applyDefaultsDrivenDecorationChangeIfNeeded() }) } diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 7f403211e6c0..724f7a696a53 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -111,11 +111,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { } }) - observers.append(center.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in + observers.append(center.addUserDefaultsObserver(object: nil) { [weak self] in Task { @MainActor [weak self] in self?.updateToolbarVisibilityIfNeeded() } diff --git a/cmuxTests/AppearanceSettingsTests.swift b/cmuxTests/AppearanceSettingsTests.swift index eefcf3bde054..fd565d6f9331 100644 --- a/cmuxTests/AppearanceSettingsTests.swift +++ b/cmuxTests/AppearanceSettingsTests.swift @@ -510,7 +510,7 @@ final class AppearanceSettingsTests: XCTestCase { object: nil, queue: nil ) { _ in - handler() + MainActor.assumeIsolated { handler() } } }, removeObserver: { observer in diff --git a/cmuxTests/MobileHostIdentityConcurrencyTests.swift b/cmuxTests/MobileHostIdentityConcurrencyTests.swift index 4e1cc7cea827..c16e1f6d2e97 100644 --- a/cmuxTests/MobileHostIdentityConcurrencyTests.swift +++ b/cmuxTests/MobileHostIdentityConcurrencyTests.swift @@ -31,11 +31,15 @@ struct MobileHostIdentityConcurrencyTests { // Deliberately hold main while a background preference writer posts. // A finite wait reproduces the cache-initialization deadlock without // leaving the test host blocked when the assertion fails. - #expect(postReturned.wait(timeout: .now() + 1) == .success) + #expect(Self.waitForPostWhileMainThreadIsOccupied(postReturned)) var iterator = deliveries.makeAsyncIterator() #expect(await iterator.next() != nil) } + private static func waitForPostWhileMainThreadIsOccupied(_ posted: DispatchSemaphore) -> Bool { + posted.wait(timeout: .now() + 1) == .success + } + @Test func dismissalWarmupGateDoesNotResolveIdentityOnTheSynchronousPath() throws { let prewarm = PhonePushIdentityPrewarm( identityProvider: NeverReadyPhonePushIdentityProvider() From 467ea5195893df7c49eb2fb1d9c716e82adee5da Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:38:16 -0700 Subject: [PATCH 12/15] test: cover missing window visibility and repeated onboarding layout --- .../ExternalApplicationWindowTracker.swift | 2 +- cmux.xcodeproj/project.pbxproj | 4 --- .../ComputerUseOnboardingWindowTests.swift | 15 +++++------ ...xternalApplicationWindowTrackerTests.swift | 26 +++++++++++++++++++ 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/Sources/App/ExternalApplicationWindowTracker.swift b/Sources/App/ExternalApplicationWindowTracker.swift index c0e93b38d03f..7dabddeed3cf 100644 --- a/Sources/App/ExternalApplicationWindowTracker.swift +++ b/Sources/App/ExternalApplicationWindowTracker.swift @@ -304,7 +304,7 @@ final class ExternalApplicationWindowTracker { }.first } - private nonisolated static func snapshot( + nonisolated static func snapshot( from entry: [String: Any], expectedWindowID: CGWindowID?, processIdentifier: pid_t, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 398190b2c042..90cbaa834162 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1345,7 +1345,6 @@ C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */; }; C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */; }; C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; - C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */; }; 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */; }; ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */; }; @@ -5100,7 +5099,6 @@ 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+LayoutCreation.swift"; sourceTree = ""; }; C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirror.swift"; sourceTree = ""; }; C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; - C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PlacementSync.swift"; sourceTree = ""; }; 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PortForward.swift"; sourceTree = ""; }; F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ProjectionLayout.swift"; sourceTree = ""; }; @@ -7958,7 +7956,6 @@ AB74882A29884E378E36D645 /* SurfaceCatalog+CloudWorkspaceProjection.swift */, 183FE43D3F71A474726D6AA6 /* CloudWorkspaceLayoutTranslator.swift */, 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */, - F4091B3DC55D4121A082A1F0 /* CmuxTuiSurfaceProvider+PendingMutations.swift */, D12439050000000000000002 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift */, A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */, B12438020000000000000002 /* CloudRenameCoordinator.swift */, @@ -12737,7 +12734,6 @@ C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */, C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */, C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, - C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */, 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */, ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */, diff --git a/cmuxTests/ComputerUseOnboardingWindowTests.swift b/cmuxTests/ComputerUseOnboardingWindowTests.swift index ef8973c240d5..160bf4d12f86 100644 --- a/cmuxTests/ComputerUseOnboardingWindowTests.swift +++ b/cmuxTests/ComputerUseOnboardingWindowTests.swift @@ -118,7 +118,7 @@ struct ComputerUseOnboardingWindowTests { #expect(!first.hasShadow) } - @Test @MainActor func onboardingContentCannotOutgrowItsAppKitWindow() async { + @Test @MainActor func onboardingContentCannotOutgrowItsAppKitWindow() { let expandedSize = CGSize(width: 600, height: 440) let companionSize = ComputerUsePermissionCompanionLayout.size let oversizedContent = Color.clear.frame(width: 680, height: 883) @@ -152,18 +152,15 @@ struct ComputerUseOnboardingWindowTests { window.setFrame(placementFrame, display: true, animate: false) #expect(window.frame == placementFrame) } - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(1)) - while window.frame.size != expectedSize || contentView.frame.size != expectedSize { + // Exercise repeated layout invalidations. Each pass is synchronous + // and must preserve the frame, even after an earlier pass matched. + for _ in 0..<12 { contentView.invalidateIntrinsicContentSize() contentView.needsLayout = true contentView.layoutSubtreeIfNeeded() window.displayIfNeeded() - if window.frame.size == expectedSize && contentView.frame.size == expectedSize { - break - } - guard clock.now < deadline else { break } - await Task.yield() + #expect(window.frame.size == expectedSize) + #expect(contentView.frame.size == expectedSize) } #expect(window.frame.size == expectedSize) diff --git a/cmuxTests/ExternalApplicationWindowTrackerTests.swift b/cmuxTests/ExternalApplicationWindowTrackerTests.swift index b75a71b4cb55..1c75f88615af 100644 --- a/cmuxTests/ExternalApplicationWindowTrackerTests.swift +++ b/cmuxTests/ExternalApplicationWindowTrackerTests.swift @@ -9,6 +9,32 @@ import Testing @Suite("External application window lifecycle") struct ExternalApplicationWindowTrackerTests { + @Test @MainActor func missingVisibilityMetadataSuppressesCompanionPresentation() throws { + let entry: [String: Any] = [ + kCGWindowOwnerPID as String: NSNumber(value: 42), + kCGWindowLayer as String: NSNumber(value: 0), + kCGWindowNumber as String: NSNumber(value: 17), + kCGWindowBounds as String: CGRect(x: 80, y: 100, width: 600, height: 440).dictionaryRepresentation, + ] + let snapshot = try #require(ExternalApplicationWindowTracker.snapshot( + from: entry, expectedWindowID: 17, processIdentifier: 42, primaryScreenMaxY: 1_200 + )) + #expect(!snapshot.isOnScreen) + let tracker = ExternalApplicationWindowTracker( + bundleIdentifier: "com.example.Target", + dependencies: .init(frontWindow: { _, _ in snapshot }, window: { _, _, _ in snapshot }), + automaticUpdatesEnabled: false + ) + var events: [ExternalApplicationWindowEvent] = [] + tracker.start { events.append($0) } + defer { tracker.stop() } + + tracker.handleApplicationActivation(bundleIdentifier: "com.example.Target", processIdentifier: 42) + + #expect(events.last == .offscreen) + #expect(!events.contains(.visible(snapshot))) + } + @MainActor private final class DeliveryState { var refreshCallIsActive = false From d036a228e3aea55dfc3a6ca4195c7015e44168f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:43:06 -0700 Subject: [PATCH 13/15] =?UTF-8?q?Use=20main=E2=80=99s=20scoped=20Cloud=20r?= =?UTF-8?q?ow=20actor=20isolation=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Cloud/CloudTreeRowContentView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index f95cc3e15f29..347f446f9b2f 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -25,7 +25,6 @@ enum CloudTreeIconPalette { static let machine = Color.accentColor } -@MainActor struct CloudTreeRowContentView: View { let kind: CloudTreeNode.Kind var style: CloudTreeStyle = CloudTreeStyleStore.current @@ -136,7 +135,7 @@ struct CloudTreeRowContentView: View { /// Formats the transport and screen label shown beneath a VNC display row. /// A key such as `display:1` becomes `noVNC · :1`; unknown key shapes retain /// the transport-only detail. - nonisolated static func text(for resource: SurfaceResource) -> String { + static func text(for resource: SurfaceResource) -> String { let transport = String(localized: "cloudTree.node.desktop.detail", defaultValue: "noVNC") guard let screen = screenLabel(displayKey: resource.id.key) else { return transport } return String( @@ -148,7 +147,7 @@ struct CloudTreeRowContentView: View { /// Converts a display resource key such as `display:1` to its X display /// label (`:1`), returning nil for keys that are not numbered displays. - nonisolated static func screenLabel(displayKey key: String) -> String? { + static func screenLabel(displayKey key: String) -> String? { let prefix = "display:" guard key.hasPrefix(prefix) else { return nil } let number = key.dropFirst(prefix.count) From b650ddb638c2dd9090cb69f034cbe2644349012e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:13:05 -0700 Subject: [PATCH 14/15] fix: preserve offscreen window metadata with raw window IDs --- .../App/ExternalApplicationWindowTracker.swift | 15 ++++++++------- cmuxTests/ComputerUseOnboardingWindowTests.swift | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/App/ExternalApplicationWindowTracker.swift b/Sources/App/ExternalApplicationWindowTracker.swift index 7dabddeed3cf..97ac1c6174c4 100644 --- a/Sources/App/ExternalApplicationWindowTracker.swift +++ b/Sources/App/ExternalApplicationWindowTracker.swift @@ -285,13 +285,14 @@ final class ExternalApplicationWindowTracker { processIdentifier: pid_t, primaryScreenMaxY: CGFloat ) -> Snapshot? { - // CGWindowListCreateDescriptionFromArray returns an empty result for a - // valid external window on macOS 26. The including-window query uses - // the same public metadata and returns the requested record reliably. - guard let windowInfo = CGWindowListCopyWindowInfo( - [.optionIncludingWindow, .excludeDesktopElements], - windowID - ) as? [[String: Any]] else { + // This API expects pointer-sized window IDs, not boxed CFNumbers. + // Query the tracked ID directly so ordered-out windows retain their + // identity without scanning every window on each sample. + guard windowID != kCGNullWindowID else { return nil } + var rawWindowID = UnsafeRawPointer(bitPattern: UInt(windowID)) + guard let windowIDs = CFArrayCreate(kCFAllocatorDefault, &rawWindowID, 1, nil), + let windowInfo = CGWindowListCreateDescriptionFromArray(windowIDs) as? [[String: Any]] + else { return nil } return windowInfo.compactMap { diff --git a/cmuxTests/ComputerUseOnboardingWindowTests.swift b/cmuxTests/ComputerUseOnboardingWindowTests.swift index 160bf4d12f86..302c9dad0f4b 100644 --- a/cmuxTests/ComputerUseOnboardingWindowTests.swift +++ b/cmuxTests/ComputerUseOnboardingWindowTests.swift @@ -64,6 +64,7 @@ struct ComputerUseOnboardingWindowTests { ) #expect(snapshot?.windowID == windowID) + #expect(snapshot?.isOnScreen == false) } @Test @MainActor func unavailableTargetDismissesOnlyItsCompanion() throws { From 5f157e0a1cde25f887711a5e2189dd26e59adaa6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:13:27 -0700 Subject: [PATCH 15/15] test: isolate invalid relay ownership selectors --- cmuxTests/AgentRelayTTYOwnershipTests.swift | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/cmuxTests/AgentRelayTTYOwnershipTests.swift b/cmuxTests/AgentRelayTTYOwnershipTests.swift index 27a5af16e336..fd919b28c26f 100644 --- a/cmuxTests/AgentRelayTTYOwnershipTests.swift +++ b/cmuxTests/AgentRelayTTYOwnershipTests.swift @@ -318,11 +318,33 @@ extension AgentNotificationRegressionTests { ) let coordinator = ControlCommandCoordinator(context: TerminalController.shared) + // Isolate each invalid selector. Dictionary iteration does not define + // which error wins when both the workspace and surface are foreign. assertTTYReportRejected(coordinator.handle(ControlRequest( id: .string("spoofed-owner"), method: "surface.report_tty", params: [ "workspace_id": .string(fixture.destination.id.uuidString), + "surface_id": .string(fixture.panelId.uuidString), + "tty_name": .string("pts/30"), + "_cmux_remote_workspace_id": .string(fixture.source.id.uuidString), + "_cmux_remote_connection_id": .string(connectionID.uuidString), + "terminal_lifecycle_id": .string( + sourceTerminal.surface.terminalLifecycleId.uuidString + ), + "attempt_id": .string(sourceAttemptID.uuidString), + ] + )), expectedCode: "remote_relay_workspace_denied") + #expect( + !fixture.source.surfaceRegistry.runtimeReportedTTYSurfaceIDs + .contains(fixture.panelId) + ) + + assertTTYReportRejected(coordinator.handle(ControlRequest( + id: .string("spoofed-surface"), + method: "surface.report_tty", + params: [ + "workspace_id": .string(fixture.source.id.uuidString), "surface_id": .string(destinationPanelID.uuidString), "tty_name": .string("pts/30"), "_cmux_remote_workspace_id": .string(fixture.source.id.uuidString), @@ -332,7 +354,7 @@ extension AgentNotificationRegressionTests { ), "attempt_id": .string(destinationAttemptID.uuidString), ] - )), expectedCode: "remote_relay_workspace_denied") + )), expectedCode: "remote_relay_surface_denied") #expect( !fixture.destination.surfaceRegistry.runtimeReportedTTYSurfaceIDs .contains(destinationPanelID)