Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -358,13 +358,16 @@ final class WindowController: NSWindowController, NSWindowDelegate {
private var closeMotion: WindowCloseMotion?

/// Starts the person's close as a fade of the whole window; false when it closes now (Reduce
/// Motion, not on screen, or a second close while it fades).
/// Motion, not on screen, a second close while it fades, or the launch restore still runs).
private func fadeOutThenClose(_ window: NSWindow) -> Bool {
if let closeMotion {
closeMotion.finish()
return true
}
guard WindowCloseMotion.fades(window) else { return false }
// Before the restore finished the close lands at once: a fade could outlive the restore,
// which would then not know the window was closed and open it again.
let windows = services.windows
guard windows.restored, !windows.restoring, WindowCloseMotion.fades(window) else { return false }
let motion = WindowCloseMotion(window: window) { [weak window] in window?.close() }
closeMotion = motion
let next = NSApp.orderedWindows.first { $0 !== window && $0 is ShellWindow && $0.isVisible && !$0.isMiniaturized }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ extension WindowManager {
/// workspaces then move to the most recent other window, as before.
/// An incognito window closes for good: its workspaces close too.
func userClosed(_ id: String) {
if restoring || !restored { closedDuringRestore.insert(id) } // the restore must not open it again
let snapshots = services.sidebarSnapshots
Task { await snapshots.forget(window: id) }
let keepsGroup = services.daemon.supports(DaemonCapability.windowRecords.rawValue)
Expand All @@ -267,6 +268,7 @@ extension WindowManager {
var reopened: String?
transition { registry in
reopened = registry.reopen()
if let reopened { closedDuringRestore.remove(reopened) } // reopened on purpose: the restore keeps it open
return WindowRegistry.Changes()
}
guard let reopened, let controller = controller(for: reopened) else { return false }
Expand All @@ -276,7 +278,12 @@ extension WindowManager {

/// Closed window `id`'s group was deleted (`WindowRegistry.releaseClosed`).
func releaseClosedWindow(id: String) {
transition { $0.releaseClosed(id) }
transition { registry in
let changes = registry.releaseClosed(id)
// Released with no heir it opens again, on purpose: the restore keeps it open.
if registry.window(id)?.isOpen == true { closedDuringRestore.remove(id) }
return changes
}
if registry.value.window(id)?.isOpen == true, let controller = controller(for: id) { bringToFront(controller) }
}

Expand All @@ -287,6 +294,7 @@ extension WindowManager {
var reopened = false
transition { registry in
reopened = registry.reopen(id: id)
if reopened { closedDuringRestore.remove(id) } // reopened on purpose: the restore keeps it open
return WindowRegistry.Changes()
}
guard reopened, let controller = controller(for: id) else { return false }
Expand Down Expand Up @@ -375,10 +383,12 @@ extension WindowManager {
for id in live where !held.contains(id) { quietClaims.remove(id) }
let members = before.windows.flatMap(\.workspaceIDs)
let dead = Set(members.filter(isDead))
let fallback = launchWindowID ?? UUID().uuidString.lowercased()
let fallback = launchWindowID ?? closedLaunchWindowID ?? UUID().uuidString.lowercased()
transition(select: preferred) { registry in
registry.reconcile(live: live, dead: dead, placements: placements, held: held, behind: behindWindows,
fallbackWindow: fallback)
let changes = registry.reconcile(live: live, dead: dead, placements: placements, held: held, behind: behindWindows,
fallbackWindow: fallback)
keepClosedDuringRestore(&registry)
return changes
}
// A workspace that changed profile leaves no trace in membership.
if registry.value == before, preferred.isEmpty { repairSelections(previous: [:]) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ final class WindowManager {
/// Windows being closed by the registry (not by the user).
var programmaticCloses: Set<String> = []
private(set) var restored = false
/// Windows the person closed before `restore` finished (cx-ghagp): the restore puts back the
/// saved registry, where they are open, and must not open them again. Empty once it finished.
var closedDuringRestore: Set<String> = []
/// True while `restore` runs.
private(set) var restoring = false
/// The launch window the person closed before `restore` finished: the restore registers the
/// workspaces it would have shown under this id, which is in `closedDuringRestore`, so they
/// stay in a closed window. Nil once it finished.
var closedLaunchWindowID: String?
/// The windows the previous launch left open, captured at restore (Restore Previous App
/// Launch reopens those closed since); nil before restore and when nothing was saved.
var previousLaunch: PreviousLaunch?
Expand Down Expand Up @@ -191,6 +200,12 @@ final class WindowManager {
private func restore() async {
guard !restored else { return }
restored = true
restoring = true
defer {
restoring = false
closedDuringRestore = []
closedLaunchWindowID = nil
}
registry.provisional = [:]
DebugTimings.markLaunch("daemon_snapshot_loaded")
var document = WindowStateDocument()
Expand All @@ -214,17 +229,21 @@ final class WindowManager {
restoredAtMs: UInt64(max(0, Date().timeIntervalSince1970 * 1000)))
}
let adopted = adoptLaunchWindow(restoredRegistry, records: document.windows)
// The closed launch window stood for the saved window it would have become.
if closedLaunchWindowID != nil, let front = adoptableFront(restoredRegistry) { closedDuringRestore.insert(front) }
for record in document.windows where states[record.id] == nil { states[record.id] = WindowState(record: record) }
if !restoredRegistry.windows.isEmpty {
registry.apply { registry in
registry = restoredRegistry
keepClosedDuringRestore(&registry)
return WindowRegistry.Changes()
}
}
reconcileMembership()
// Every remaining window closed (the open ones lost their workspaces while the app was
// not running): the most recent one opens, so the app never starts with no window.
if !registry.value.windows.isEmpty, !registry.value.windows.contains(where: \.isOpen), let front = registry.value.recency.first {
if closedDuringRestore.isEmpty, !registry.value.windows.isEmpty, !registry.value.windows.contains(where: \.isOpen),
let front = registry.value.recency.first {
registry.apply { $0.setRestored(front, open: true); return WindowRegistry.Changes() }
}
// Restored windows but the front one come back at their saved place, in any content arrival order.
Expand All @@ -248,7 +267,9 @@ final class WindowManager {
registry.isLaunching = false
fullScreenRestoreReady = true
enterRestoredFullScreens()
FirstWorkspaceLaunch(manager: self).land(firstWorkspace) // its New Tab page, not Home
// Its New Tab page or Home; not after the person closed a window meanwhile: revealing it
// opens a window again (the Home workspace shows before the restore finished).
if closedDuringRestore.isEmpty { FirstWorkspaceLaunch(manager: self).land(firstWorkspace) }
// Classic cmux's session comes over once, after restore so the restored
// registry never overwrites where its workspaces land.
let onboarding = AppOnboardingServices(owner: services.onboarding)
Expand All @@ -259,13 +280,8 @@ final class WindowManager {
/// state, so relaunch shows it without opening a second window. With
/// nothing saved it stays the only window and receives every workspace.
private func adoptLaunchWindow(_ restored: WindowRegistry, records: [WindowRecord]) -> WindowController? {
// The frontmost saved window that can show a workspace now; one whose
// workspaces are gone or still connecting would leave the launch
// window on screen with nothing to show.
guard let launchID = launchWindowID, let launch = controller(for: launchID),
let front = restored.recency.first(where: { id in
restored.window(id).map { $0.isOpen && hasMirroredWorkspace($0) } == true
}),
let front = adoptableFront(restored),
let record = records.first(where: { $0.id == front }) else { return nil }
launch.state.adopt(record)
launchWindowID = front
Expand All @@ -278,6 +294,20 @@ final class WindowManager {
return launch
}

/// A window the person closed before the restore finished stays closed, even the last one: no
/// window open is the background mode, not a launch to recover. Applied in the registry step
/// itself, so no sync ever opens (and presents) it in between.
func keepClosedDuringRestore(_ registry: inout WindowRegistry) {
for id in closedDuringRestore where registry.window(id)?.isOpen == true { registry.setRestored(id, open: false) }
}

/// The frontmost saved window that can show a workspace now; one whose
/// workspaces are gone or still connecting would leave the launch
/// window on screen with nothing to show.
private func adoptableFront(_ restored: WindowRegistry) -> String? {
restored.recency.first { id in restored.window(id).map { $0.isOpen && hasMirroredWorkspace($0) } == true }
}

// MARK: Controllers

/// Creates the controller of registered window `id`.
Expand Down Expand Up @@ -381,7 +411,12 @@ final class WindowManager {
controller.teardown()
if programmaticCloses.remove(id) != nil || isTerminating { return }
if id == launchWindowID {
// The unregistered launch window: nothing to hand over.
// The unregistered launch window: nothing to hand over. It often wears the saved front
// window's id, so the restore must not open that one again either.
if restoring || !restored {
closedDuringRestore.insert(id)
closedLaunchWindowID = id
}
launchWindowID = nil
states[id] = nil
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,41 +26,88 @@ struct BackgroundAccessoryPolicyTests {
var policies: [NSApplication.ActivationPolicy] = []
}

/// A launch as `CmuxNextApp.main` and `AppDelegate` run it, on its own daemon and cmux.json.
@MainActor private struct Launch {
let services: AppServices
let settings: SettingsController
let configURL: URL
let root: URL
let presented = Presented()

init() async throws {
let binary = try #require(SameTreeDaemonBinary.url)
root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-bg-\(UUID().uuidString.prefix(8))")
let session = "cn-bg-\(UUID().uuidString.prefix(8).lowercased())"
let launcher = DaemonLauncher(
configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state")),
environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] })
// As `CmuxNextApp.main` and `AppDelegate` launch: regular, and these services are the process's.
NSApplication.shared.setActivationPolicy(.regular)
services = AppServices(environment: AppEnvironment.current([:]))
AppProcessRoot.shared.adopt(services)
// A cmux.json of its own (Settings needs one loaded), with the setting unset: on.
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
configURL = root.appendingPathComponent("cmux.json")
try Data("{}".utf8).write(to: configURL)
settings = SettingsController(registry: services.registry, design: DesignSettings(), fileURL: configURL,
managedReader: FixedManagedPreferenceReader(ManagedPreferences()), managedWatchFiles: [])
await settings.reload()
services.settings = settings
AppActions.bind(services)
services.palette.bindRegistryActions()
services.windows.ordersWindowsIn = false
// The policy each window is presented under (the hook runs after it is ordered in).
let presented = presented
services.windows.onPresent = { _ in presented.policies.append(NSApp.activationPolicy()) }
services.daemon.start {
DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: launcher.endpointProvider)
}
}

func shutDown() async {
if let connection = services.daemon.connection, let identity = services.daemon.identity {
_ = try? await connection.request(ShutdownDaemonRequest(pid: identity.pid, generation: identity.generation, endTerminals: true),
timeout: .seconds(10))
}
services.daemon.shutdownConnection()
}
}

/// The window closed while the restore still runs stays closed: the restore neither opens it
/// again nor lands the first workspace in a new one. The launch window is the one on screen then.
@Test func aWindowClosedBeforeTheRestoreFinishesStaysClosed() async throws {
let app = NSApplication.shared
let policyBefore = app.activationPolicy()
defer { app.setActivationPolicy(policyBefore) }
let launch = try await Launch()
defer { try? FileManager.default.removeItem(at: launch.root) }
let windows = launch.services.windows
windows.restoreWhenLoaded()
try #require(!windows.restored, "the restore finished before the launch window could close")
try #require(windows.controllers.count == 1, "no launch window")
_ = launch.services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user))
#expect(windows.controllers.isEmpty)
launch.presented.policies = []
await Self.wait(within: .seconds(90)) { windows.restored && !windows.registry.isLaunching }
try #require(windows.restored, "the restore never finished")
// A reopened window would land on the next main-actor turns; test-only wait.
try? await Task.sleep(for: .milliseconds(500))
#expect(launch.presented.policies.isEmpty, "the restore presented a closed window, even if only for a moment")
#expect(windows.controllers.isEmpty, "the restore opened \(windows.controllers.count) window(s) after the user closed the launch window")
await launch.shutDown()
}

@Test func noWindowIsAMenuBarAppAndAnOpeningWindowComesBackRegular() async throws {
let binary = try #require(SameTreeDaemonBinary.url)
let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-bg-\(UUID().uuidString.prefix(8))")
let session = "cn-bg-\(UUID().uuidString.prefix(8).lowercased())"
let launcher = DaemonLauncher(
configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state")),
environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] })
let app = NSApplication.shared
let policyBefore = app.activationPolicy()
// As `CmuxNextApp.main` and `AppDelegate` launch: regular, and these services are the process's.
app.setActivationPolicy(.regular)
let services = AppServices(environment: AppEnvironment.current([:]))
AppProcessRoot.shared.adopt(services)
// A cmux.json of its own (Settings needs one loaded), with the setting unset: on.
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let configURL = root.appendingPathComponent("cmux.json")
try Data("{}".utf8).write(to: configURL)
let settings = SettingsController(registry: services.registry, design: DesignSettings(), fileURL: configURL,
managedReader: FixedManagedPreferenceReader(ManagedPreferences()), managedWatchFiles: [])
await settings.reload()
services.settings = settings
AppActions.bind(services)
services.palette.bindRegistryActions()
services.windows.ordersWindowsIn = false
// The policy each window is presented under (the hook runs after it is ordered in).
let presented = Presented()
services.windows.onPresent = { _ in presented.policies.append(NSApp.activationPolicy()) }
services.daemon.start {
DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: launcher.endpointProvider)
}
defer { app.setActivationPolicy(policyBefore) }
let launch = try await Launch()
defer { try? FileManager.default.removeItem(at: launch.root) }
let services = launch.services
let settings = launch.settings
let configURL = launch.configURL
let presented = launch.presented
services.windows.restoreWhenLoaded()
defer {
app.setActivationPolicy(policyBefore)
try? FileManager.default.removeItem(at: root)
}
let windows = services.windows
let hasWindow = { windows.active.flatMap { windows.registry.members(of: $0.state.id).first } != nil }
await Self.wait(within: .seconds(90), until: hasWindow)
Expand Down Expand Up @@ -103,10 +150,6 @@ struct BackgroundAccessoryPolicyTests {
#expect(windows.controllers.isEmpty)
#expect(app.activationPolicy() == .regular, "the setting is off: the Dock icon stays with no window")

if let connection = services.daemon.connection, let identity = services.daemon.identity {
_ = try? await connection.request(ShutdownDaemonRequest(pid: identity.pid, generation: identity.generation, endTerminals: true),
timeout: .seconds(10))
}
services.daemon.shutdownConnection()
await launch.shutDown()
}
}
Loading