Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
943 changes: 943 additions & 0 deletions REPORT.md

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions Sources/App/ShortcutRoutingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ func shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(
/// For browser content, let the page try browser-local Find-family commands before cmux's menu fallback.
/// Cmd+F is excluded because cmux chooses terminal, browser, or right-sidebar
/// find from the current focus owner.
@MainActor
func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
_ event: NSEvent,
responder: NSResponder? = nil,
Expand All @@ -636,9 +637,7 @@ func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(

if shortcut.keepsCmuxBrowserFindBarOwnershipWhenVisible,
let owningWebView {
let browserFindBarIsVisible = MainActor.assumeIsolated {
AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true
}
let browserFindBarIsVisible = AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true
if browserFindBarIsVisible {
return false
}
Expand Down
43 changes: 31 additions & 12 deletions Sources/Auth/AuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,43 @@ import StackAuth
import Security
#endif

private final class AuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding {
private final class AuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding, @unchecked Sendable {
static let shared = AuthPresentationContext()

// ASWebAuthenticationSession invokes presentationAnchor(for:) synchronously
// on whichever thread called session.start(). When beginSignIn() fires from
// the socket command dispatch thread (cmux auth login), that callback lands
// off-main, but the anchor (NSApp.keyWindow/...) is @MainActor state. Rather
// than block the caller with DispatchQueue.main.sync, beginSignIn() (already
// @MainActor) pre-fetches the anchor on main via cacheCurrentAnchor() right
// before start(), and the off-main callback reads that cached window under a
// lock. The lock-guarded NSWindow is why this type is @unchecked Sendable.
private let lock = NSLock()
private var cachedAnchor: NSWindow?

func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
// ASWebAuthenticationSession invokes this on whichever thread called
// session.start(). When beginSignIn() fires from the socket command
// dispatch thread (cmux auth login), this callback lands off-main,
// and any NSApp access must hop to main before returning.
if Thread.isMainThread {
return Self.currentAnchor()
}
var result: ASPresentationAnchor = NSWindow()
DispatchQueue.main.sync {
result = Self.currentAnchor()
}
return result
lock.lock()
let cached = cachedAnchor
lock.unlock()
return cached ?? NSWindow()
}

/// Snapshot the current presentation anchor on the main actor so the
/// synchronous, possibly off-main protocol callback can read it without
/// blocking on main. Call immediately before `session.start()`.
@MainActor
func cacheCurrentAnchor() {
let anchor = Self.currentAnchor()
lock.lock()
cachedAnchor = anchor
lock.unlock()
}

@MainActor
private static func currentAnchor() -> ASPresentationAnchor {
private static func currentAnchor() -> NSWindow {
NSApp.keyWindow ?? NSApp.mainWindow ?? (NSApp.windows.first ?? NSWindow())
}
}
Expand Down Expand Up @@ -277,7 +294,9 @@ final class AuthManager: ObservableObject {
}
}
}
session.presentationContextProvider = AuthPresentationContext.shared
let presentationContext = AuthPresentationContext.shared
presentationContext.cacheCurrentAnchor()
session.presentationContextProvider = presentationContext
session.prefersEphemeralWebBrowserSession = false

if session.start() {
Expand Down
10 changes: 6 additions & 4 deletions Sources/BackgroundWorkspacePrimeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ final class BackgroundWorkspacePrimeCoordinator {
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
MainActor.assumeIsolated {
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
Expand All @@ -278,33 +278,35 @@ final class BackgroundWorkspacePrimeCoordinator {
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
MainActor.assumeIsolated {
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
waiter.addObserver(hostedViewObserver)

let pendingObserver = tabManager.$pendingBackgroundWorkspaceLoadIds
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self, weak waiter, weak tabManager] pendingIds in
guard !pendingIds.contains(workspaceId),
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
MainActor.assumeIsolated {
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
waiter.addCancellable(pendingObserver)

let tabsObserver = tabManager.$tabs
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self, weak waiter, weak tabManager] tabs in
guard !tabs.contains(where: { $0.id == workspaceId }),
let self,
let waiter,
let tabManager else { return }
Task { @MainActor in
MainActor.assumeIsolated {
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
}
}
Expand Down
54 changes: 24 additions & 30 deletions Sources/FileExplorerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Combine
import Foundation
import QuartzCore
import SwiftUI
import os

// MARK: - Explorer Visual Style

Expand Down Expand Up @@ -310,7 +311,11 @@ final class LocalFileExplorerProvider: FileExplorerProvider {

// MARK: - SSH Provider

// Captured by async SSH tasks; mutable availability/root state is guarded by stateLock.
// Captured by async SSH tasks; mutable availability/root state is owned by an
// OSAllocatedUnfairLock so the synchronous FileExplorerProvider getters
// (homePath/isAvailable) stay non-blocking. `@unchecked` remains because the
// stored `transport` is a non-Sendable AnyObject protocol whose thread safety
// this type guarantees; an actor cannot satisfy the synchronous getters.
final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
private struct State: Sendable {
var homePath: String
Expand All @@ -320,19 +325,14 @@ final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
let connection: SSHFileExplorerConnection
let displayTarget: String
private let transport: SSHFileExplorerTransport
private let stateLock = NSLock()
private var state: State
private let state: OSAllocatedUnfairLock<State>

var homePath: String {
stateLock.lock()
defer { stateLock.unlock() }
return state.homePath
state.withLock { $0.homePath }
}

var isAvailable: Bool {
stateLock.lock()
defer { stateLock.unlock() }
return state.isAvailable
state.withLock { $0.isAvailable }
}

var destination: String { connection.destination }
Expand Down Expand Up @@ -361,7 +361,7 @@ final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
return "\(destination):\(port)"
}()
self.transport = transport
self.state = State(homePath: homePath, isAvailable: isAvailable)
self.state = OSAllocatedUnfairLock(initialState: State(homePath: homePath, isAvailable: isAvailable))
}

init(
Expand All @@ -374,15 +374,15 @@ final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
self.connection = connection
self.displayTarget = displayTarget
self.transport = transport
self.state = State(homePath: homePath, isAvailable: isAvailable)
self.state = OSAllocatedUnfairLock(initialState: State(homePath: homePath, isAvailable: isAvailable))
}

func updateAvailability(_ available: Bool, homePath: String?) {
stateLock.lock()
defer { stateLock.unlock() }
state.isAvailable = available
if let homePath {
state.homePath = homePath
state.withLock { state in
state.isAvailable = available
if let homePath {
state.homePath = homePath
}
}
}

Expand Down Expand Up @@ -436,9 +436,11 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
private let process = Process()
private let outPipe = Pipe()
private let errPipe = Pipe()
private let lock = NSLock()
private let terminationGate = ProcessTerminationGate()
private var cancelled = false
// run() blocks on process.waitUntilExit() off the cooperative executor, so
// this stays a synchronous class rather than an actor; only the cancellation
// flag is shared, and it is owned by the lock instead of a bare NSLock + var.
private let cancelled = OSAllocatedUnfairLock(initialState: false)

init(connection: SSHFileExplorerConnection, command: String) {
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
Expand All @@ -448,9 +450,7 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
}

func run() throws -> SSHCommandResult {
lock.lock()
let wasCancelled = cancelled
lock.unlock()
let wasCancelled = cancelled.withLock { $0 }
if wasCancelled {
throw CancellationError()
}
Expand All @@ -462,9 +462,7 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
throw error
}

lock.lock()
let shouldTerminate = cancelled
lock.unlock()
let shouldTerminate = cancelled.withLock { $0 }
if terminationGate.markLaunched() || shouldTerminate {
guard process.isRunning else {
process.waitUntilExit()
Expand All @@ -478,9 +476,7 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
let stderrData = ProcessPipeReader.readDataToEndOfFileOrEmpty(from: errPipe.fileHandleForReading)
process.waitUntilExit()
terminationGate.markFinished()
lock.lock()
let cancelledAfterExit = cancelled
lock.unlock()
let cancelledAfterExit = cancelled.withLock { $0 }
if cancelledAfterExit {
throw CancellationError()
}
Expand All @@ -493,9 +489,7 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
}

func terminate() {
lock.lock()
cancelled = true
lock.unlock()
cancelled.withLock { $0 = true }

guard terminationGate.requestTermination() else {
return
Expand Down
70 changes: 38 additions & 32 deletions Sources/Panels/FilePreviewPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AVKit
import Bonsplit
import Combine
import Foundation
import os
import PDFKit
import Quartz
import SwiftUI
Expand Down Expand Up @@ -430,72 +431,77 @@ private final class FileExternalOpenMenuActionTarget: NSObject {
}
}

struct FilePreviewDragEntry {
struct FilePreviewDragEntry: Sendable {
let filePath: String
let displayTitle: String
}

final class FilePreviewDragRegistry {
final class FilePreviewDragRegistry: Sendable {
static let shared = FilePreviewDragRegistry()

private let lock = NSLock()
private var pending: [UUID: PendingEntry] = [:]
private static let entryTTL: TimeInterval = 60

private struct PendingEntry {
private struct PendingEntry: Sendable {
let entry: FilePreviewDragEntry
let registeredAt: Date
}

// Callers (NSPasteboardWriting requirements, AppKit drag teardown, and the
// main-actor drop handlers in Workspace / DragOverlayRoutingPolicy /
// BrowserPaneDropTargetView) all invoke these synchronously, so an actor
// would force every call site async and break the synchronous pasteboard
// contract. Serialize the pending map with a lock-protected state instead.
private let state = OSAllocatedUnfairLock<[UUID: PendingEntry]>(initialState: [:])

func register(_ entry: FilePreviewDragEntry, id: UUID = UUID(), now: Date = Date()) -> UUID {
lock.lock()
sweepExpiredLocked(now: now)
pending[id] = PendingEntry(entry: entry, registeredAt: now)
lock.unlock()
state.withLock { pending in
Self.sweepExpired(&pending, now: now)
pending[id] = PendingEntry(entry: entry, registeredAt: now)
}
return id
}

func consume(id: UUID, now: Date = Date()) -> FilePreviewDragEntry? {
lock.lock()
defer { lock.unlock() }
sweepExpiredLocked(now: now)
return pending.removeValue(forKey: id)?.entry
state.withLock { pending in
Self.sweepExpired(&pending, now: now)
return pending.removeValue(forKey: id)?.entry
}
}

func contains(id: UUID, now: Date = Date()) -> Bool {
lock.lock()
defer { lock.unlock() }
sweepExpiredLocked(now: now)
return pending[id] != nil
state.withLock { pending in
Self.sweepExpired(&pending, now: now)
return pending[id] != nil
}
}

func entry(id: UUID, now: Date = Date()) -> FilePreviewDragEntry? {
lock.lock()
defer { lock.unlock() }
sweepExpiredLocked(now: now)
return pending[id]?.entry
state.withLock { pending in
Self.sweepExpired(&pending, now: now)
return pending[id]?.entry
}
}

func discard(id: UUID) {
lock.lock()
pending.removeValue(forKey: id)
lock.unlock()
state.withLock { pending in
_ = pending.removeValue(forKey: id)
}
}

func discardExpired(now: Date = Date()) {
lock.lock()
sweepExpiredLocked(now: now)
lock.unlock()
state.withLock { pending in
Self.sweepExpired(&pending, now: now)
}
}

func discardAll() {
lock.lock()
pending.removeAll()
lock.unlock()
state.withLock { pending in
pending.removeAll()
}
}

private func sweepExpiredLocked(now: Date) {
let cutoff = now.addingTimeInterval(-Self.entryTTL)
private static func sweepExpired(_ pending: inout [UUID: PendingEntry], now: Date) {
let cutoff = now.addingTimeInterval(-entryTTL)
pending = pending.filter { _, value in
value.registeredAt >= cutoff
}
Expand Down
Loading
Loading