From cf97f698166b5038c55d21abe61d953f1828cfb1 Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:00:00 +0300 Subject: [PATCH 1/7] Add persistent scratchpads and window sessions Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- DESIGN.md | 7 +- Docs/ARCHITECTURE.md | 16 +- Docs/RELEASE_NOTES.md | 13 +- README.md | 2 + Resources/Info.plist | 2 + Sources/MarginApp/AppDelegate.swift | 353 ++++++++++++++++-- Sources/MarginApp/AppMenu.swift | 9 + Sources/MarginApp/EditorViewController.swift | 52 ++- Sources/MarginApp/ScratchpadStore.swift | 89 +++++ Sources/MarginApp/WorkspaceSessionStore.swift | 158 +++++++- .../MarginApp/WorkspaceWindowController.swift | 236 +++++++++++- .../WorkspaceBehaviorTests.swift | 263 ++++++++++++- 12 files changed, 1122 insertions(+), 78 deletions(-) create mode 100644 Sources/MarginApp/ScratchpadStore.swift diff --git a/DESIGN.md b/DESIGN.md index fefc20b..eeaea61 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -18,11 +18,12 @@ All panes use standard AppKit split-view behavior, system materials, native focu - A new or comment-free document begins with the source alone. The comment inspector opens automatically only when a document already contains review threads or the user starts a comment. - Standalone files hide the navigator; directory workspaces reveal it. Closing either sidebar gives its space to the document without changing the outer window frame. -- The initial window uses a generous 1180 × 780 working size when the screen permits. Later launches restore only a usable, on-screen frame, and the document pane remains the elastic region during live resize and full screen. -- Opening several files—through the Open panel, Finder, or the CLI—places them in native tabs. An empty tab is reused, duplicate paths focus their existing tab, and separate windows remain available with `⌘N`. +- The initial window uses a generous 1180 × 780 working size when the screen permits. Later launches restore each window to its prior physical display when available, with a usable on-screen fallback when a display is disconnected; the document pane remains the elastic region during live resize and full screen. +- Opening several files—through the Open panel, Finder, or the CLI—places them in native tabs. An empty start tab is reused, duplicate paths focus their existing tab, and separate windows remain available. +- `⌘T` creates an editable, automatically saved untitled Markdown scratchpad in the current tab group. `⌘N` creates one in a separate window, and `⌘⇧S` promotes it to an ordinary named Markdown file without changing its tab position. - Tabs follow browser conventions: `⌘T`, `⌘W`, `⌃Tab`, `⌃⇧Tab`, and `⌘1`…`⌘9`. Pane focus uses `⌃1`…`⌃3` so it never competes with tab selection. - Reader presentation and file-provider change watching begin only after they are requested, away from the main interaction path. Stale reader work is discarded when the user switches files or modes. -- A normal relaunch restores tabs and lightweight view state, but an explicit CLI target always wins. Persistence is deferred to a utility queue and never joins the first-window path. +- A normal relaunch restores independent windows, their physical displays, native tab groups and order, the selected window and tab, usable window frames, scratchpad contents, and lightweight editor state. An explicit CLI target always wins. Session persistence is deferred to a utility queue, and scratchpads remain ordinary private Markdown files rather than database records. - New comments or replies arriving from another process never steal focus or open the inspector. They add a restrained numeric tab/toolbar signal and a temporary **New** filter until the affected thread is deliberately visited. ## Typography and color diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index bd93d93..187ae15 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -102,10 +102,18 @@ directory indexing, filesystem watching, comparison loading, collaboration overviews, and stage presentation are created only when requested so they do not join the initial window path. -The application stores only lightweight local presentation state such as open -tabs, pane visibility, selection, scroll position, and the active thread. An -explicit file or directory supplied by the user takes precedence over session -restoration. +The application stores only lightweight local presentation state such as the +independent-window and native-tab hierarchy, pane visibility, selection, scroll +position, display affinity, and the active thread. Stable native restoration +identifiers let AppKit preserve system-managed window state; custom placement +metadata provides physical-display continuity and an on-screen fallback when a +display is unavailable. Untitled scratchpads are ordinary Markdown +files below the user's private Application Support directory; the session holds +only their stable identity and presentation state. Scratch contents use the +same atomic autosave path as named documents, are flushed synchronously during +a normal quit, and move to the selected destination only after Save As +succeeds. An explicit file or directory supplied by the user takes precedence +over session restoration. ## CLI contract diff --git a/Docs/RELEASE_NOTES.md b/Docs/RELEASE_NOTES.md index 3c7ba1c..7266c26 100644 --- a/Docs/RELEASE_NOTES.md +++ b/Docs/RELEASE_NOTES.md @@ -2,7 +2,18 @@ ## Unreleased -No changes yet. +- Added durable untitled Markdown scratchpads. `⌘T` creates one in the current + tab group, `⌘N` creates one in a separate window, and `⌘⇧S` promotes it to a + named file. Scratch contents are private file-backed Markdown, autosave while + editing, flush synchronously on quit, and require an explicit Save As or + discard decision before a nonempty scratchpad tab closes. +- Expanded session restoration from one flattened tab list to the complete + independent-window and native-tab hierarchy, including tab order, selected + tabs, usable window frames, cursor and scroll continuity, and open + scratchpads. Existing version-1 sessions migrate to a single restored window. +- Integrated the durable session with native AppKit window restoration. + Physical-display identity and relative placement are retained, with an + on-screen fallback when a monitor is disconnected or its layout changes. ## 0.5.1 — native reading and review fixes diff --git a/README.md b/README.md index f5ce029..b84f42b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Open a file for quick editing or a directory for a native, lazily loaded navigat In the app you can: +- create an automatically saved untitled Markdown scratchpad with `⌘T`, or a scratchpad in a separate window with `⌘N`, then promote it to an ordinary file with `⌘⇧S`; +- return after a normal quit to the same independent windows and displays, tab groups, selected tabs, cursor positions, and scroll positions; - edit literal Markdown with restrained syntax cues, delimiter pairing, list continuation, native undo, find, spellcheck, and accessibility support; - switch to a bounded, typography-first reader view with `⌘⇧R`; - select a passage and comment from the inline affordance, context menu, or `⌘⌥M`; diff --git a/Resources/Info.plist b/Resources/Info.plist index c601b9b..818530e 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -32,6 +32,8 @@ Copyright © 2026 OpenProse, Inc. NSPrincipalClass NSApplication + NSQuitAlwaysKeepsWindows + CFBundleDocumentTypes diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift index 09ec11b..28404f5 100644 --- a/Sources/MarginApp/AppDelegate.swift +++ b/Sources/MarginApp/AppDelegate.swift @@ -1,6 +1,51 @@ import AppKit +import ColorSync import MarginCore +enum WorkspaceRestorationIdentifier { + private static let prefix = "ink.margin.workspace." + + static func make(_ value: String) -> NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier(prefix + value) + } + + static func parse(_ identifier: NSUserInterfaceItemIdentifier) -> String? { + guard identifier.rawValue.hasPrefix(prefix) else { return nil } + let value = String(identifier.rawValue.dropFirst(prefix.count)) + return value.isEmpty ? nil : value + } +} + +enum WorkspaceScreenIdentity { + static func identifier(for screen: NSScreen?) -> String? { + guard let number = screen?.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? NSNumber else { return nil } + let displayID = CGDirectDisplayID(number.uint32Value) + guard let displayUUID = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue(), + let identifier = CFUUIDCreateString(nil, displayUUID) + else { + return "display-\(displayID)" + } + return identifier as String + } +} + +@MainActor +final class WorkspaceWindowRestorationHandler: NSObject, NSWindowRestoration { + static func restoreWindow( + withIdentifier identifier: NSUserInterfaceItemIdentifier, + state: NSCoder, + completionHandler: @escaping (NSWindow?, Error?) -> Void + ) { + guard let delegate = NSApplication.shared.delegate as? AppDelegate else { + completionHandler(nil, nil) + return + } + completionHandler(delegate.restoreWorkspaceWindow(withIdentifier: identifier), nil) + } +} + protocol WorkspacePathRenameParticipating: AnyObject { var documentURLForPathRename: URL? { get } func prepareForPathRename(from sourceURL: URL) -> Bool @@ -138,15 +183,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private var comparisonPickerController: NavigationPaletteController? private var pendingURLs: [URL] = [] private var didFinishLaunching = false - private let sessionStore = WorkspaceSessionStore() + private let sessionStore: WorkspaceSessionStore + private let scratchpadStore: ScratchpadStore private let sessionPersistenceQueue = DispatchQueue( label: "ink.margin.session-persistence", qos: .utility ) private var isRestoringSession = false private var isSessionPersistScheduled = false + private var terminationSession: WorkspaceSession? + private var nativeRestorationSession: WorkspaceSession? + private var nativeRestoredControllers: [String: WorkspaceWindowController] = [:] + + override init() { + sessionStore = WorkspaceSessionStore() + scratchpadStore = ScratchpadStore() + super.init() + } + + init(sessionStore: WorkspaceSessionStore, scratchpadStore: ScratchpadStore) { + self.sessionStore = sessionStore + self.scratchpadStore = scratchpadStore + super.init() + } func applicationWillFinishLaunching(_ notification: Notification) { + // Margin owns durable document continuity and always reopens its + // workspace. Register the matching AppKit preference as well so a + // normal Quit preserves native window state even when the system-wide + // “close windows when quitting” setting is enabled. + UserDefaults.standard.register(defaults: ["NSQuitAlwaysKeepsWindows": true]) WorkspacePaneFactory.makeEditor = { EditorViewController() } WorkspacePaneFactory.makeComments = { CommentsViewController() } AppMenu.install(for: NSApplication.shared, delegate: self) @@ -157,8 +223,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { let startupURLs = pendingURLs.isEmpty ? commandLineURLs() : pendingURLs pendingURLs.removeAll() + let didRestoreNatively = !nativeRestoredControllers.isEmpty - if startupURLs.isEmpty, restoreLastSession() { + if startupURLs.isEmpty, didRestoreNatively { + // AppKit has recreated and reconfigured every registered window, + // including its native tab-group placement. + } else if startupURLs.isEmpty, restoreLastSession() { // The first restored tab is visible immediately. Remaining tabs // attach on the next run-loop turn so session continuity never // delays the first usable window. @@ -167,6 +237,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } else { open(startupURLs) } + if didRestoreNatively { + isRestoringSession = false + nativeRestoredControllers.removeAll() + nativeRestorationSession = nil + } NSApplication.shared.activate(ignoringOtherApps: true) } @@ -197,8 +272,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { persistSession() } - func applicationWillTerminate(_ notification: Notification) { + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard workspaceWindows.allSatisfy({ $0.prepareForApplicationTermination() }) else { + terminationSession = nil + return .terminateCancel + } + terminationSession = captureWorkspaceSession() persistSession(synchronously: true) + workspaceWindows.forEach { + $0.invalidateRestorableState() + $0.window?.invalidateRestorableState() + } + sender.invalidateRestorableState() + return .terminateNow + } + + func applicationWillTerminate(_ notification: Notification) { + if let terminationSession { + sessionPersistenceQueue.sync { [sessionStore] in + sessionStore.save(terminationSession) + } + } else { + persistSession(synchronously: true) + } } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -221,11 +317,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc func newWindow(_ sender: Any?) { - makeWorkspaceWindow(for: nil) + makeScratchpadWindow(tabbedTo: nil) } @objc func newWindowForTab(_ sender: Any?) { - makeWorkspaceWindow(for: nil, tabbedTo: activeTabAnchorWindow) + makeScratchpadWindow(tabbedTo: activeTabAnchorWindow) + } + + @objc func saveDocumentAs(_ sender: Any?) { + activeWorkspaceWindow?.saveDocumentAs(sender) } @objc func selectTab(_ sender: NSMenuItem) { @@ -434,6 +534,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { case #selector(openDocument(_:)), #selector(newWindow(_:)), #selector(newWindowForTab(_:)), #selector(compareFiles(_:)): return true + case #selector(saveDocumentAs(_:)): + return activeWorkspaceWindow?.canSaveDocumentAs == true case #selector(compareActiveTab(_:)): return OpenTabComparisonPickerModel.isAvailable( active: activeWorkspaceWindow, @@ -598,22 +700,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { NSApplication.shared.activate(ignoringOtherApps: true) } + private func makeScratchpadWindow(tabbedTo parentWindow: NSWindow?) { + do { + let scratchpad = try scratchpadStore.create() + makeWorkspaceWindow( + for: nil, + tabbedTo: parentWindow, + scratchpad: scratchpad + ) + } catch { + let alert = NSAlert(error: error) + alert.messageText = "Could Not Create Scratchpad" + alert.runModal() + } + } + @discardableResult private func makeWorkspaceWindow( for url: URL?, tabbedTo parentWindow: NSWindow? = nil, - restorationState: WorkspaceTabSession? = nil + restorationState: WorkspaceTabSession? = nil, + scratchpad: ScratchpadDescriptor? = nil, + restorationIdentifier: String? = nil, + ordersFront: Bool = true ) -> WorkspaceWindowController { let controller = WorkspaceWindowController( workspaceURL: url, - restorationState: restorationState + scratchpad: scratchpad, + restorationState: restorationState, + scratchpadStore: scratchpadStore, + restorationIdentifier: restorationIdentifier ) controller.onClose = { [weak self, weak controller] in guard let self, let controller else { return } self.workspaceWindows.removeAll { $0 === controller } - self.persistSession() + if self.terminationSession == nil { + self.schedulePersistSession() + } + } + controller.onSessionStateChange = { [weak self, weak controller] in + controller?.invalidateRestorableState() + controller?.window?.invalidateRestorableState() + self?.schedulePersistSession() } - controller.onSessionStateChange = { [weak self] in self?.schedulePersistSession() } controller.onRequestPathRename = { [weak self] sourceURL, proposedName in guard let self else { return .failure(NavigatorRenameError.coordinationUnavailable) @@ -652,9 +781,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } controller.refreshTabPresentation() } - controller.showWindow(nil) - focus(controller) - schedulePersistSession() + if ordersFront { + controller.showWindow(nil) + focus(controller) + schedulePersistSession() + } return controller } @@ -887,38 +1018,118 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } + func restoreWorkspaceWindow( + withIdentifier identifier: NSUserInterfaceItemIdentifier + ) -> NSWindow? { + guard commandLineURLs().isEmpty, + let restorationIdentifier = WorkspaceRestorationIdentifier.parse(identifier) + else { return nil } + if let existing = nativeRestoredControllers[restorationIdentifier] { + return existing.window + } + + let session: WorkspaceSession + if let nativeRestorationSession { + session = nativeRestorationSession + } else { + guard let loaded = sessionStore.load() else { return nil } + nativeRestorationSession = loaded + session = loaded + } + guard let state = session.windows + .flatMap(\.tabs) + .first(where: { $0.restorationIdentifier == restorationIdentifier }), + isRestorable(state) else { return nil } + + isRestoringSession = true + let controller = makeWorkspaceWindow( + for: nil, + restorationState: state, + restorationIdentifier: restorationIdentifier, + ordersFront: false + ) + nativeRestoredControllers[restorationIdentifier] = controller + return controller.window + } + private func restoreLastSession() -> Bool { guard let session = sessionStore.load() else { return false } - let requestedSelectedState = session.tabs.indices.contains(session.selectedIndex) - ? session.tabs[session.selectedIndex] - : session.tabs.first - let tabs = session.tabs.filter { state in - FileManager.default.fileExists(atPath: state.workspacePath) + let indexedWindows = session.windows.enumerated().compactMap { + originalIndex, window -> (Int, WorkspaceWindowSession)? in + let tabs = window.tabs.filter(isRestorable) + guard !tabs.isEmpty else { return nil } + let requestedTab = window.tabs.indices.contains(window.selectedIndex) + ? window.tabs[window.selectedIndex] + : nil + let selectedIndex = requestedTab.flatMap { tabs.firstIndex(of: $0) } + ?? min(max(window.selectedIndex, 0), tabs.count - 1) + return (originalIndex, WorkspaceWindowSession( + tabs: tabs, + selectedIndex: selectedIndex, + frame: window.frame + )) + } + let windows = indexedWindows.map(\.1) + guard let firstWindow = windows.first, let firstTab = firstWindow.tabs.first else { + return false } - guard let first = tabs.first else { return false } + let restoredSelectedWindowIndex = indexedWindows.firstIndex { + $0.0 == session.selectedWindowIndex + } ?? min(max(session.selectedWindowIndex, 0), windows.count - 1) isRestoringSession = true let firstController = makeWorkspaceWindow( - for: URL(fileURLWithPath: first.workspacePath), - restorationState: first + for: nil, + restorationState: firstTab ) + firstWindow.frame.map(firstController.applyRestoredWindowFrame) DispatchQueue.main.async { [weak self, weak firstController] in guard let self, let firstController else { return } - var controllers = [firstController] - var tabAnchor = firstController.window - for state in tabs.dropFirst() { - let controller = self.makeWorkspaceWindow( - for: URL(fileURLWithPath: state.workspacePath), - tabbedTo: tabAnchor, - restorationState: state + var restoredWindows: [[WorkspaceWindowController]] = [] + + for (windowIndex, windowState) in windows.enumerated() { + var controllers: [WorkspaceWindowController] = [] + var tabAnchor: NSWindow? + + for (tabIndex, state) in windowState.tabs.enumerated() { + let controller: WorkspaceWindowController + if windowIndex == 0, tabIndex == 0 { + controller = firstController + } else { + controller = self.makeWorkspaceWindow( + for: nil, + tabbedTo: tabAnchor, + restorationState: state + ) + } + controllers.append(controller) + tabAnchor = controller.window + } + + if let frame = windowState.frame { + controllers.first?.applyRestoredWindowFrame(frame) + } + let selectedIndex = min( + max(windowState.selectedIndex, 0), + controllers.count - 1 ) - controllers.append(controller) - tabAnchor = controller.window + if let selectedWindow = controllers[selectedIndex].window { + selectedWindow.tabGroup?.selectedWindow = selectedWindow + } + restoredWindows.append(controllers) } - let selected = requestedSelectedState.flatMap { tabs.firstIndex(of: $0) } - ?? min(max(session.selectedIndex, 0), controllers.count - 1) - self.focus(controllers[selected]) + + let selectedWindowIndex = min( + max(restoredSelectedWindowIndex, 0), + restoredWindows.count - 1 + ) + let selectedWindowState = windows[selectedWindowIndex] + let selectedTabIndex = min( + max(selectedWindowState.selectedIndex, 0), + restoredWindows[selectedWindowIndex].count - 1 + ) + self.focus(restoredWindows[selectedWindowIndex][selectedTabIndex]) self.isRestoringSession = false self.persistSession() } @@ -927,14 +1138,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private func persistSession(synchronously: Bool = false) { guard didFinishLaunching, !isRestoringSession else { return } - let tabs = workspaceWindows.compactMap(\.sessionState) - let selectedController = activeWorkspaceWindow - let selectedIndex = selectedController.flatMap { selected in - workspaceWindows.firstIndex { $0 === selected } - } ?? 0 - let session = tabs.isEmpty - ? nil - : WorkspaceSession(tabs: tabs, selectedIndex: selectedIndex) + let session = captureWorkspaceSession() let save = { [sessionStore] in sessionStore.save(session) } if synchronously { sessionPersistenceQueue.sync(execute: save) @@ -943,8 +1147,77 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } + private func isRestorable(_ state: WorkspaceTabSession) -> Bool { + if let id = state.scratchpadID { + return scratchpadStore.exists(ScratchpadDescriptor( + id: id, + title: state.scratchpadTitle ?? "Untitled" + )) + } + return FileManager.default.fileExists(atPath: state.workspacePath) + } + + private func captureWorkspaceSession() -> WorkspaceSession? { + var visitedWindows = Set() + var windowSessions: [WorkspaceWindowSession] = [] + var controllerWindowIndices: [ObjectIdentifier: Int] = [:] + + for controller in workspaceWindows { + guard let window = controller.window, + !visitedWindows.contains(ObjectIdentifier(window)) else { continue } + + let tabWindows = window.tabGroup?.windows ?? [window] + let controllers = tabWindows.compactMap { + $0.windowController as? WorkspaceWindowController + } + controllers.compactMap(\.window).forEach { + visitedWindows.insert(ObjectIdentifier($0)) + } + let tabs = controllers.compactMap(\.sessionState) + guard !tabs.isEmpty else { continue } + + let selectedWindow = window.tabGroup?.selectedWindow ?? window + let selectedIndex = controllers.firstIndex { + $0.window === selectedWindow + } ?? 0 + let windowIndex = windowSessions.count + controllers.forEach { controller in + controllerWindowIndices[ObjectIdentifier(controller)] = windowIndex + } + windowSessions.append(WorkspaceWindowSession( + tabs: tabs, + selectedIndex: selectedIndex, + frame: WorkspaceWindowFrame( + selectedWindow.frame, + displayIdentifier: WorkspaceScreenIdentity.identifier( + for: selectedWindow.screen + ), + displayFrame: selectedWindow.screen?.visibleFrame + ) + )) + } + + guard !windowSessions.isEmpty else { return nil } + let selectedWindowIndex = activeWorkspaceWindow.flatMap { + controllerWindowIndices[ObjectIdentifier($0)] + } ?? 0 + return WorkspaceSession( + windows: windowSessions, + selectedWindowIndex: selectedWindowIndex + ) + } + + var workspaceSessionForTesting: WorkspaceSession? { + captureWorkspaceSession() + } + + var isTerminatingApplication: Bool { + terminationSession != nil + } + private func schedulePersistSession() { guard didFinishLaunching, !isRestoringSession, !isSessionPersistScheduled else { return } + NSApplication.shared.invalidateRestorableState() isSessionPersistScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } diff --git a/Sources/MarginApp/AppMenu.swift b/Sources/MarginApp/AppMenu.swift index b4039a7..7193710 100644 --- a/Sources/MarginApp/AppMenu.swift +++ b/Sources/MarginApp/AppMenu.swift @@ -149,6 +149,15 @@ enum AppMenu { fileMenu.addItem( item("Save", action: #selector(WorkspaceDocumentSaving.saveDocument(_:)), key: "s") ) + fileMenu.addItem( + item( + "Save Scratchpad As…", + action: #selector(AppDelegate.saveDocumentAs(_:)), + key: "s", + modifiers: [.command, .shift], + target: delegate + ) + ) fileMenu.addItem(.separator()) fileMenu.addItem(item("Close Tab", action: #selector(NSWindow.performClose(_:)), key: "w")) mainMenu.addItem(menuItem(title: "File", submenu: fileMenu)) diff --git a/Sources/MarginApp/EditorViewController.swift b/Sources/MarginApp/EditorViewController.swift index 4c18427..c434d6b 100644 --- a/Sources/MarginApp/EditorViewController.swift +++ b/Sources/MarginApp/EditorViewController.swift @@ -5,6 +5,7 @@ final class EditorViewController: NSViewController, WorkspaceDocumentPresenting, WorkspaceReaderModeToggling, WorkspaceDocumentSaving, + WorkspaceDocumentSavingAs, WorkspaceDocumentPathRelocating, WorkspaceComparisonSourceProviding, WorkspaceComparisonApplying, @@ -63,6 +64,7 @@ final class EditorViewController: NSViewController, private var highlighter: MarkdownHighlighter? private weak var commentsViewController: CommentsViewController? private var documentURL: URL? + private var documentDisplayName: String? private var lastSavedBodyData = Data() private var isApplyingDocument = false private var isDirty = false @@ -271,6 +273,14 @@ final class EditorViewController: NSViewController, } func presentDocument(at url: URL) { + presentDocument(at: url, displayName: nil) + } + + func presentScratchpad(at url: URL, title: String) { + presentDocument(at: url, displayName: title) + } + + private func presentDocument(at url: URL, displayName: String?) { _ = view guard prepareToClose() else { return } saveWorkItem?.cancel() @@ -278,6 +288,7 @@ final class EditorViewController: NSViewController, fileWatcher = nil documentURL = url.standardizedFileURL + documentDisplayName = displayName isDocumentLoaded = false pendingContinuityState = nil selectedThreadID = nil @@ -340,6 +351,7 @@ final class EditorViewController: NSViewController, fileWatcher?.stop() fileWatcher = nil documentURL = nil + documentDisplayName = nil isDocumentLoaded = false pendingContinuityState = nil selectedThreadID = nil @@ -493,6 +505,43 @@ final class EditorViewController: NSViewController, } } + func saveDocument(to destinationURL: URL) -> Bool { + let destinationURL = destinationURL.standardizedFileURL + guard let sourceURL = documentURL else { return false } + guard prepareToClose() else { return false } + if sourceURL == destinationURL { return true } + + do { + let data = try Data(contentsOf: sourceURL) + try data.write(to: destinationURL, options: .atomic) + fileWatcher?.stop() + fileWatcher = nil + documentURL = destinationURL + documentDisplayName = nil + watchDocument(destinationURL) + textView.isEditable = FileManager.default.isWritableFile(atPath: destinationURL.path) + textView.setAccessibilityHelp( + "Editing \(destinationURL.lastPathComponent) as literal Markdown. Formatting marks remain visible." + ) + if isReaderMode, let reader = readerViewController { + let selection = reader.selectedSourceRange + reader.renderAsync( + markdown: textView.string, + baseURL: destinationURL.deletingLastPathComponent(), + preferredSourceSelection: selection + ) { [weak self] applied in + guard let self, applied, self.isReaderMode else { return } + self.updateReaderHighlights() + } + } + updateStatus(savedMessage: true) + return true + } catch { + showBanner("Could not save as \(destinationURL.lastPathComponent): \(error.localizedDescription)") + return false + } + } + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { if menuItem.action == #selector(saveDocument(_:)) { return documentURL != nil @@ -530,6 +579,7 @@ final class EditorViewController: NSViewController, fileWatcher?.stop() fileWatcher = nil self.documentURL = relocatedURL + documentDisplayName = nil watchDocument(relocatedURL) textView.setAccessibilityHelp( "Editing \(relocatedURL.lastPathComponent) as literal Markdown. Formatting marks remain visible." @@ -834,7 +884,7 @@ final class EditorViewController: NSViewController, textView.setSelectedRange(NSRange(location: 0, length: 0)) isApplyingDocument = false isDocumentLoaded = true - let name = documentURL?.lastPathComponent ?? "Markdown document" + let name = documentDisplayName ?? documentURL?.lastPathComponent ?? "Markdown document" textView.setAccessibilityHelp("Editing \(name) as literal Markdown. Formatting marks remain visible.") lastSavedBodyData = bodyData setDirty(false) diff --git a/Sources/MarginApp/ScratchpadStore.swift b/Sources/MarginApp/ScratchpadStore.swift new file mode 100644 index 0000000..5cf1578 --- /dev/null +++ b/Sources/MarginApp/ScratchpadStore.swift @@ -0,0 +1,89 @@ +import Foundation + +struct ScratchpadDescriptor: Codable, Equatable { + let id: UUID + let title: String +} + +enum ScratchpadStoreError: LocalizedError { + case couldNotCreateStorage + + var errorDescription: String? { + switch self { + case .couldNotCreateStorage: + return "Margin could not create its private scratchpad storage." + } + } +} + +/// Gives untitled documents durable, file-backed storage without exposing an +/// implementation path as the document's identity in the UI. Scratchpads use +/// ordinary Markdown bytes so recovery never depends on a database or daemon. +final class ScratchpadStore { + private let fileManager: FileManager + private let rootURL: URL + private let defaults: UserDefaults + private let counterKey: String + + init( + fileManager: FileManager = .default, + rootURL: URL? = nil, + defaults: UserDefaults = .standard, + counterKey: String = "MarginScratchpadCounter.v1" + ) { + self.fileManager = fileManager + self.defaults = defaults + self.counterKey = counterKey + if let rootURL { + self.rootURL = rootURL.standardizedFileURL + } else { + let applicationSupport = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? fileManager.homeDirectoryForCurrentUser + self.rootURL = applicationSupport + .appendingPathComponent("Margin", isDirectory: true) + .appendingPathComponent("Scratchpads", isDirectory: true) + } + } + + func create() throws -> ScratchpadDescriptor { + try fileManager.createDirectory( + at: rootURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let descriptor = ScratchpadDescriptor(id: UUID(), title: nextTitle()) + let destination = url(for: descriptor.id) + guard fileManager.createFile( + atPath: destination.path, + contents: Data(), + attributes: [.posixPermissions: 0o600] + ) else { + throw ScratchpadStoreError.couldNotCreateStorage + } + return descriptor + } + + func url(for id: UUID) -> URL { + rootURL.appendingPathComponent(id.uuidString.lowercased()) + .appendingPathExtension("md") + } + + func exists(_ descriptor: ScratchpadDescriptor) -> Bool { + fileManager.fileExists(atPath: url(for: descriptor.id).path) + } + + func remove(_ descriptor: ScratchpadDescriptor) throws { + let target = url(for: descriptor.id) + guard fileManager.fileExists(atPath: target.path) else { return } + try fileManager.removeItem(at: target) + } + + private func nextTitle() -> String { + let previous = defaults.integer(forKey: counterKey) + let next = previous == Int.max ? 1 : previous + 1 + defaults.set(next, forKey: counterKey) + return "Untitled \(next)" + } +} diff --git a/Sources/MarginApp/WorkspaceSessionStore.swift b/Sources/MarginApp/WorkspaceSessionStore.swift index 7a92b7e..fb84615 100644 --- a/Sources/MarginApp/WorkspaceSessionStore.swift +++ b/Sources/MarginApp/WorkspaceSessionStore.swift @@ -26,12 +26,142 @@ struct WorkspaceTabSession: Codable, Equatable { var navigatorVisible: Bool var commentsVisible: Bool var editor: EditorContinuityState + var scratchpadID: UUID? = nil + var scratchpadTitle: String? = nil + var restorationIdentifier: String? = nil +} + +struct WorkspaceWindowFrame: Codable, Equatable { + var x: Double + var y: Double + var width: Double + var height: Double + var displayIdentifier: String? = nil + var displayX: Double? = nil + var displayY: Double? = nil + var displayWidth: Double? = nil + var displayHeight: Double? = nil + + init( + _ frame: CGRect, + displayIdentifier: String? = nil, + displayFrame: CGRect? = nil + ) { + x = frame.origin.x + y = frame.origin.y + width = frame.size.width + height = frame.size.height + self.displayIdentifier = displayIdentifier + displayX = displayFrame.map { Double($0.origin.x) } + displayY = displayFrame.map { Double($0.origin.y) } + displayWidth = displayFrame.map { Double($0.size.width) } + displayHeight = displayFrame.map { Double($0.size.height) } + } + + var rect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } + + var savedDisplayFrame: CGRect? { + guard let displayX, + let displayY, + let displayWidth, + let displayHeight, + displayWidth > 0, + displayHeight > 0 else { return nil } + return CGRect( + x: displayX, + y: displayY, + width: displayWidth, + height: displayHeight + ) + } +} + +struct WorkspaceDisplayGeometry: Equatable { + var identifier: String? + var visibleFrame: CGRect +} + +enum WorkspaceWindowPlacementResolver { + static func resolve( + _ state: WorkspaceWindowFrame, + displays: [WorkspaceDisplayGeometry] + ) -> CGRect? { + guard state.width >= 720, + state.height >= 480, + !displays.isEmpty else { return nil } + + let savedFrame = state.rect + let targetDisplay = state.displayIdentifier.flatMap { identifier in + displays.first { $0.identifier == identifier } + } ?? bestDisplay(for: state, displays: displays) + guard let targetDisplay else { return nil } + + var restoredFrame = savedFrame + if let savedDisplayFrame = state.savedDisplayFrame { + restoredFrame.origin.x = targetDisplay.visibleFrame.origin.x + + savedFrame.origin.x - savedDisplayFrame.origin.x + restoredFrame.origin.y = targetDisplay.visibleFrame.origin.y + + savedFrame.origin.y - savedDisplayFrame.origin.y + } + return constrain(restoredFrame, to: targetDisplay.visibleFrame) + } + + private static func bestDisplay( + for state: WorkspaceWindowFrame, + displays: [WorkspaceDisplayGeometry] + ) -> WorkspaceDisplayGeometry? { + let referenceFrame = state.savedDisplayFrame ?? state.rect + let intersecting = displays.max { left, right in + intersectionArea(referenceFrame, left.visibleFrame) + < intersectionArea(referenceFrame, right.visibleFrame) + } + if let intersecting, + intersectionArea(referenceFrame, intersecting.visibleFrame) > 0 { + return intersecting + } + return displays.first + } + + private static func intersectionArea(_ left: CGRect, _ right: CGRect) -> Double { + let intersection = left.intersection(right) + guard !intersection.isNull else { return 0 } + return intersection.width * intersection.height + } + + private static func constrain(_ frame: CGRect, to visibleFrame: CGRect) -> CGRect { + var result = frame + result.size.width = min(result.width, visibleFrame.width) + result.size.height = min(result.height, visibleFrame.height) + result.origin.x = min( + max(result.origin.x, visibleFrame.minX), + visibleFrame.maxX - result.width + ) + result.origin.y = min( + max(result.origin.y, visibleFrame.minY), + visibleFrame.maxY - result.height + ) + return result + } +} + +struct WorkspaceWindowSession: Codable, Equatable { + var tabs: [WorkspaceTabSession] + var selectedIndex: Int + var frame: WorkspaceWindowFrame? } struct WorkspaceSession: Codable, Equatable { - static let currentVersion = 1 + static let currentVersion = 2 var version: Int = currentVersion + var windows: [WorkspaceWindowSession] + var selectedWindowIndex: Int +} + +private struct LegacyWorkspaceSession: Codable { + var version: Int var tabs: [WorkspaceTabSession] var selectedIndex: Int } @@ -49,15 +179,29 @@ final class WorkspaceSessionStore { } func load() -> WorkspaceSession? { - guard let data = defaults.data(forKey: key), - let session = try? JSONDecoder().decode(WorkspaceSession.self, from: data), - session.version == WorkspaceSession.currentVersion, - !session.tabs.isEmpty else { return nil } - return session + guard let data = defaults.data(forKey: key) else { return nil } + if let session = try? JSONDecoder().decode(WorkspaceSession.self, from: data), + session.version == WorkspaceSession.currentVersion, + !session.windows.isEmpty, + session.windows.contains(where: { !$0.tabs.isEmpty }) { + return session + } + guard let legacy = try? JSONDecoder().decode(LegacyWorkspaceSession.self, from: data), + legacy.version == 1, + !legacy.tabs.isEmpty else { return nil } + return WorkspaceSession( + windows: [WorkspaceWindowSession( + tabs: legacy.tabs, + selectedIndex: legacy.selectedIndex, + frame: nil + )], + selectedWindowIndex: 0 + ) } func save(_ session: WorkspaceSession?) { - guard let session, !session.tabs.isEmpty, + guard let session, !session.windows.isEmpty, + session.windows.contains(where: { !$0.tabs.isEmpty }), let data = try? JSONEncoder().encode(session) else { defaults.removeObject(forKey: key) return diff --git a/Sources/MarginApp/WorkspaceWindowController.swift b/Sources/MarginApp/WorkspaceWindowController.swift index e486ceb..04acb9b 100644 --- a/Sources/MarginApp/WorkspaceWindowController.swift +++ b/Sources/MarginApp/WorkspaceWindowController.swift @@ -3,10 +3,15 @@ import MarginCore protocol WorkspaceDocumentPresenting: AnyObject { func presentDocument(at url: URL) + func presentScratchpad(at url: URL, title: String) func clearDocument() } extension WorkspaceDocumentPresenting { + func presentScratchpad(at url: URL, title: String) { + presentDocument(at: url) + } + func clearDocument() {} } @@ -23,6 +28,10 @@ protocol WorkspaceReaderModeToggling: AnyObject { func saveDocument(_ sender: Any?) } +protocol WorkspaceDocumentSavingAs: AnyObject { + func saveDocument(to url: URL) -> Bool +} + protocol WorkspaceDocumentPathRelocating: AnyObject { func prepareForPathRename() -> Bool func applyDocumentPathRename(from sourceURL: URL, to destinationURL: URL) @@ -54,6 +63,7 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST case empty case file(URL) case directory(URL) + case scratchpad(ScratchpadDescriptor) } var onClose: (() -> Void)? @@ -72,6 +82,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST private let editorItem: NSSplitViewItem private let commentsItem: NSSplitViewItem private let recentWorkspaceStore: RecentWorkspaceStore + private let scratchpadStore: ScratchpadStore + let restorationIdentifier: String private var navigationPaletteController: NavigationPaletteController? private var recentWorkspaceStartViewController: RecentWorkspaceStartViewController? private var recentWorkspaceLoadGeneration = UUID() @@ -90,6 +102,11 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST return false } + var isScratchpad: Bool { + if case .scratchpad = workspaceKind { return true } + return false + } + var canShowNavigator: Bool { if case .directory = workspaceKind { return true } return false @@ -117,6 +134,10 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST documentURL != nil && editorViewController is WorkspaceDocumentSaving } + var canSaveDocumentAs: Bool { + isScratchpad && editorViewController is WorkspaceDocumentSavingAs + } + var canQuickOpen: Bool { quickOpenDirectoryURL != nil } var canNavigateFiles: Bool { @@ -167,13 +188,22 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST guard let workspaceURL else { return nil } let editorState = (editorViewController as? WorkspaceContinuityProviding)? .captureContinuityState() ?? .beginning + let scratchpad: ScratchpadDescriptor? + if case .scratchpad(let descriptor) = workspaceKind { + scratchpad = descriptor + } else { + scratchpad = nil + } return WorkspaceTabSession( workspacePath: workspaceURL.path, documentPath: documentURL?.path, readerMode: isReaderModeActive, navigatorVisible: isNavigatorVisible, commentsVisible: isCommentsVisible, - editor: editorState + editor: editorState, + scratchpadID: scratchpad?.id, + scratchpadTitle: scratchpad?.title, + restorationIdentifier: restorationIdentifier ) } @@ -181,16 +211,23 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST switch workspaceKind { case .directory(let url): return url case .file(let url): return url.deletingLastPathComponent() - case .empty: return nil + case .empty, .scratchpad: return nil } } init( workspaceURL: URL?, + scratchpad: ScratchpadDescriptor? = nil, restorationState: WorkspaceTabSession? = nil, - recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore() + recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore(), + scratchpadStore: ScratchpadStore = ScratchpadStore(), + restorationIdentifier: String? = nil ) { self.recentWorkspaceStore = recentWorkspaceStore + self.scratchpadStore = scratchpadStore + self.restorationIdentifier = restorationIdentifier + ?? restorationState?.restorationIdentifier + ?? UUID().uuidString editorViewController = WorkspacePaneFactory.makeEditor() commentsViewController = WorkspacePaneFactory.makeComments() @@ -216,6 +253,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST if let restorationState { restore(restorationState) + } else if let scratchpad { + openScratchpad(scratchpad) } else if let workspaceURL { open(workspaceURL) } else { @@ -250,14 +289,40 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST scheduleRecentWorkspaceStartScreenIfNeeded() } + override func encodeRestorableState(with coder: NSCoder) { + super.encodeRestorableState(with: coder) + coder.encode(restorationIdentifier, forKey: "MarginRestorationIdentifier") + } + func prepareForTabAttachment() { isExplicitlyTabbed = true pendingInitialWindowFrame = nil } + func applyRestoredWindowFrame(_ state: WorkspaceWindowFrame) { + guard let window else { return } + let displays = NSScreen.screens.map { + WorkspaceDisplayGeometry( + identifier: WorkspaceScreenIdentity.identifier(for: $0), + visibleFrame: $0.visibleFrame + ) + } + guard let frame = WorkspaceWindowPlacementResolver.resolve( + state, + displays: displays + ) else { return } + pendingInitialWindowFrame = nil + window.setFrame(frame, display: true, animate: false) + } + func refreshTabPresentation() { guard isExplicitlyTabbed, let window else { return } - window.tab.toolTip = (window.representedURL?.path).flatMap { $0.isEmpty ? nil : $0 } ?? "Margin" + if case .scratchpad(let descriptor) = workspaceKind { + window.tab.toolTip = "\(descriptor.title) · Scratchpad" + } else { + window.tab.toolTip = (window.representedURL?.path) + .flatMap { $0.isEmpty ? nil : $0 } ?? "Margin" + } } func open(_ url: URL) { @@ -282,7 +347,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST recentWorkspaceStore.recordAfterLaunch(recentDirectoryURL) } - var documentURLForPathRename: URL? { documentURL } + var documentURLForPathRename: URL? { + isScratchpad ? nil : documentURL + } func prepareForPathRename(from sourceURL: URL) -> Bool { guard let documentURL, @@ -317,6 +384,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST workspaceKind = .directory( WorkspacePathRelocation.relocatedURL(url, from: sourceURL, to: destinationURL) ?? url ) + case .scratchpad: + break } indexedFileURLs = indexedFileURLs.map { @@ -383,6 +452,51 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST _ = NSApp.sendAction(NSSelectorFromString("beginComment:"), to: editorViewController, from: sender) } + @objc func saveDocumentAs(_ sender: Any?) { + guard canSaveDocumentAs, let window else { return } + let panel = scratchpadSavePanel() + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let destination = panel.url else { return } + _ = self?.saveScratchpad(to: destination) + } + } + + @discardableResult + func saveScratchpad(to destinationURL: URL) -> Bool { + let destinationURL = destinationURL.standardizedFileURL + guard case .scratchpad(let descriptor) = workspaceKind, + destinationURL != scratchpadStore.url(for: descriptor.id).standardizedFileURL, + let editor = editorViewController as? WorkspaceDocumentSavingAs, + editor.saveDocument(to: destinationURL) else { return false } + + try? scratchpadStore.remove(descriptor) + workspaceKind = .file(destinationURL) + workspaceURL = destinationURL + documentURL = destinationURL + fileTreeViewController.trackActiveDocument(nil) + (commentsViewController as? WorkspaceCommentsPresenting)? + .presentComments(for: destinationURL) + updateWindowTitle(documentURL: destinationURL, workspaceURL: destinationURL) + window?.toolbar?.validateVisibleItems() + recentWorkspaceStore.recordAfterLaunch(destinationURL.deletingLastPathComponent()) + onSessionStateChange?() + return true + } + + private var suggestedScratchpadFilename: String { + guard case .scratchpad(let descriptor) = workspaceKind else { return "Untitled.md" } + return descriptor.title + ".md" + } + + private func scratchpadSavePanel() -> NSSavePanel { + let panel = NSSavePanel() + panel.title = "Save Scratchpad" + panel.prompt = "Save" + panel.nameFieldStringValue = suggestedScratchpadFilename + panel.canCreateDirectories = true + return panel + } + @objc func quickOpen(_ sender: Any?) { guard let directoryURL = quickOpenDirectoryURL, let window else { return } navigationPaletteController?.close() @@ -458,6 +572,16 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST ) { [weak self] in self?.toggleReaderMode(nil) } ) } + if canSaveDocumentAs { + items.append( + NavigationPaletteItem( + title: "Save Scratchpad As", + subtitle: "⌘⇧S", + symbolName: "square.and.arrow.down", + searchText: "save export name scratchpad markdown file" + ) { [weak self] in self?.saveDocumentAs(nil) } + ) + } if canShowComments { var commentItems = [ NavigationPaletteItem( @@ -845,10 +969,53 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST } func windowShouldClose(_ sender: NSWindow) -> Bool { + guard let editor = editorViewController as? EditorViewController else { return true } + guard editor.prepareToClose() else { return false } + guard case .scratchpad(let descriptor) = workspaceKind, + (NSApp.delegate as? AppDelegate)?.isTerminatingApplication != true else { + return true + } + return confirmScratchpadClose(descriptor) + } + + func prepareForApplicationTermination() -> Bool { guard let editor = editorViewController as? EditorViewController else { return true } return editor.prepareToClose() } + private func confirmScratchpadClose(_ descriptor: ScratchpadDescriptor) -> Bool { + let scratchURL = scratchpadStore.url(for: descriptor.id) + if (try? Data(contentsOf: scratchURL).isEmpty) == true { + try? scratchpadStore.remove(descriptor) + return true + } + + let alert = NSAlert() + alert.messageText = "Save \(descriptor.title) as a Markdown file?" + alert.informativeText = "The scratchpad is safely stored inside Margin. Save it as a normal file, or explicitly discard it before closing this tab." + alert.alertStyle = .warning + alert.addButton(withTitle: "Save As…") + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Discard Scratchpad") + + switch alert.runModal() { + case .alertFirstButtonReturn: + let panel = scratchpadSavePanel() + guard panel.runModal() == .OK, let destination = panel.url else { return false } + return saveScratchpad(to: destination) + case .alertThirdButtonReturn: + do { + try scratchpadStore.remove(descriptor) + return true + } catch { + NSAlert(error: error).runModal() + return false + } + default: + return false + } + } + func windowWillClose(_ notification: Notification) { onClose?() } @@ -857,6 +1024,14 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST onSessionStateChange?() } + func windowDidMove(_ notification: Notification) { + onSessionStateChange?() + } + + func windowDidEndLiveResize(_ notification: Notification) { + onSessionStateChange?() + } + func windowWillUseStandardFrame(_ window: NSWindow, defaultFrame newFrame: NSRect) -> NSRect { window.screen?.visibleFrame ?? newFrame } @@ -870,7 +1045,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST window.titlebarSeparatorStyle = .none window.toolbarStyle = .unified window.backgroundColor = MarginTheme.documentBackground - window.isRestorable = false + window.identifier = WorkspaceRestorationIdentifier.make(restorationIdentifier) + window.restorationClass = WorkspaceWindowRestorationHandler.self + window.isRestorable = true // Let AppKit own live resize and full-screen sizing. Explicit frame // maxima are ignored by Auto Layout and can interact poorly with // split-view fitting sizes; a content minimum is the native contract. @@ -891,7 +1068,7 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST } private func configureInitialWindowFrame(_ window: NSWindow) { - let frameName = "MarginWorkspaceWindow.v3" + let frameName = "MarginWorkspaceWindow.v4.\(restorationIdentifier)" let restored = window.setFrameUsingName(frameName) let restoredFrame = window.frame let restoredIsUsable = restored @@ -1099,6 +1276,20 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST } private func restore(_ state: WorkspaceTabSession) { + if let id = state.scratchpadID { + let descriptor = ScratchpadDescriptor( + id: id, + title: state.scratchpadTitle ?? "Untitled" + ) + guard scratchpadStore.exists(descriptor) else { + showEmptyState() + return + } + openScratchpad(descriptor) + restorePresentationState(state) + return + } + let rootURL = URL(fileURLWithPath: state.workspacePath).standardizedFileURL var isDirectory = ObjCBool(false) let exists = FileManager.default.fileExists(atPath: rootURL.path, isDirectory: &isDirectory) @@ -1119,6 +1310,10 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST openStandaloneFile(rootURL) } + restorePresentationState(state) + } + + private func restorePresentationState(_ state: WorkspaceTabSession) { if canShowNavigator { navigatorItem.isCollapsed = !state.navigatorVisible } @@ -1221,6 +1416,17 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST presentDocument(fileURL) } + private func openScratchpad(_ descriptor: ScratchpadDescriptor) { + dismissRecentWorkspaceStartScreen() + let fileURL = scratchpadStore.url(for: descriptor.id) + workspaceKind = .scratchpad(descriptor) + workspaceURL = fileURL + documentURL = fileURL + resetUnreadComments() + navigatorItem.isCollapsed = true + presentDocument(fileURL, scratchpadTitle: descriptor.title) + } + private func openDirectory( _ directoryURL: URL, preferredDocumentURL: URL? = nil @@ -1684,11 +1890,16 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST } } - private func presentDocument(_ fileURL: URL) { + private func presentDocument(_ fileURL: URL, scratchpadTitle: String? = nil) { commentsVisibilityChoice = .automatic resetUnreadComments() commentsItem.isCollapsed = true - (editorViewController as? WorkspaceDocumentPresenting)?.presentDocument(at: fileURL) + if let scratchpadTitle { + (editorViewController as? WorkspaceDocumentPresenting)? + .presentScratchpad(at: fileURL, title: scratchpadTitle) + } else { + (editorViewController as? WorkspaceDocumentPresenting)?.presentDocument(at: fileURL) + } if case .directory = workspaceKind { fileTreeViewController.trackActiveDocument(fileURL) } @@ -1728,6 +1939,13 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST private func updateWindowTitle(documentURL: URL?, workspaceURL: URL?) { guard let window else { return } + if case .scratchpad(let descriptor) = workspaceKind { + window.representedURL = nil + window.title = descriptor.title + window.subtitle = "Scratchpad · Saved automatically" + refreshTabPresentation() + return + } let representedURL = documentURL ?? workspaceURL window.representedURL = representedURL refreshTabPresentation() diff --git a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift index 449b33f..558598e 100644 --- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift +++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift @@ -177,6 +177,10 @@ final class WorkspaceBehaviorTests: XCTestCase { XCTAssertEqual(newTab?.keyEquivalent, "t") XCTAssertEqual(newTab?.keyEquivalentModifierMask, [.command]) + let saveScratchpadAs = menuItem(named: "Save Scratchpad As…") + XCTAssertEqual(saveScratchpadAs?.keyEquivalent, "s") + XCTAssertEqual(saveScratchpadAs?.keyEquivalentModifierMask, [.command, .shift]) + let focusEditor = menuItem(named: "Focus Editor") XCTAssertEqual(focusEditor?.keyEquivalent, "2") XCTAssertEqual(focusEditor?.keyEquivalentModifierMask, [.control]) @@ -253,22 +257,33 @@ final class WorkspaceBehaviorTests: XCTestCase { defer { defaults.removePersistentDomain(forName: suite) } let store = WorkspaceSessionStore(defaults: defaults, key: "session") let value = WorkspaceSession( - tabs: [ - WorkspaceTabSession( - workspacePath: "/tmp/workspace", - documentPath: "/tmp/workspace/note.md", - readerMode: true, - navigatorVisible: true, - commentsVisible: false, - editor: EditorContinuityState( - selectionLocation: 42, - selectionLength: 7, - scrollFraction: 0.5, - selectedThreadID: "urn:uuid:test" + windows: [ + WorkspaceWindowSession( + tabs: [ + WorkspaceTabSession( + workspacePath: "/tmp/workspace", + documentPath: "/tmp/workspace/note.md", + readerMode: true, + navigatorVisible: true, + commentsVisible: false, + editor: EditorContinuityState( + selectionLocation: 42, + selectionLength: 7, + scrollFraction: 0.5, + selectedThreadID: "urn:uuid:test" + ), + restorationIdentifier: "window-restoration-test" + ) + ], + selectedIndex: 0, + frame: WorkspaceWindowFrame( + CGRect(x: 40, y: 80, width: 1180, height: 780), + displayIdentifier: "display-test", + displayFrame: CGRect(x: 0, y: 0, width: 1440, height: 900) ) ) ], - selectedIndex: 0 + selectedWindowIndex: 0 ) store.save(value) @@ -277,6 +292,222 @@ final class WorkspaceBehaviorTests: XCTestCase { XCTAssertNil(store.load()) } + func testVersionTwoSessionWithoutPlacementMetadataStillLoads() throws { + let suite = "margin-session-compatibility-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("Expected isolated defaults") + } + defer { defaults.removePersistentDomain(forName: suite) } + let data = Data(""" + { + "version": 2, + "windows": [{ + "selectedIndex": 0, + "frame": {"x": 40, "y": 80, "width": 1180, "height": 780}, + "tabs": [{ + "workspacePath": "/tmp/legacy-v2.md", + "documentPath": "/tmp/legacy-v2.md", + "readerMode": false, + "navigatorVisible": false, + "commentsVisible": false, + "editor": { + "selectionLocation": 0, + "selectionLength": 0, + "scrollFraction": 0 + } + }] + }], + "selectedWindowIndex": 0 + } + """.utf8) + defaults.set(data, forKey: "session") + + let session = try XCTUnwrap( + WorkspaceSessionStore(defaults: defaults, key: "session").load() + ) + XCTAssertNil(session.windows[0].tabs[0].restorationIdentifier) + XCTAssertNil(session.windows[0].frame?.displayIdentifier) + } + + func testWindowPlacementFollowsTheSameDisplayWhenItsLayoutChanges() throws { + let state = WorkspaceWindowFrame( + CGRect(x: 1560, y: 80, width: 900, height: 650), + displayIdentifier: "external", + displayFrame: CGRect(x: 1440, y: 0, width: 1920, height: 1080) + ) + let restored = try XCTUnwrap(WorkspaceWindowPlacementResolver.resolve( + state, + displays: [ + WorkspaceDisplayGeometry( + identifier: "built-in", + visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900) + ), + WorkspaceDisplayGeometry( + identifier: "external", + visibleFrame: CGRect(x: -1920, y: 0, width: 1920, height: 1080) + ), + ] + )) + + XCTAssertEqual(restored.origin.x, -1800, accuracy: 0.5) + XCTAssertEqual(restored.origin.y, 80, accuracy: 0.5) + XCTAssertEqual(restored.size, state.rect.size) + } + + func testWindowPlacementFallsBackOnscreenWhenDisplayIsDisconnected() throws { + let state = WorkspaceWindowFrame( + CGRect(x: 1600, y: 100, width: 900, height: 650), + displayIdentifier: "disconnected", + displayFrame: CGRect(x: 1440, y: 0, width: 1920, height: 1080) + ) + let restored = try XCTUnwrap(WorkspaceWindowPlacementResolver.resolve( + state, + displays: [WorkspaceDisplayGeometry( + identifier: "built-in", + visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900) + )] + )) + + XCTAssertEqual(restored.origin.x, 160, accuracy: 0.5) + XCTAssertEqual(restored.origin.y, 100, accuracy: 0.5) + XCTAssertTrue(CGRect(x: 0, y: 0, width: 1440, height: 900).contains(restored)) + } + + func testScratchpadIsPrivateEditableAndCanBecomeANamedMarkdownFile() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("margin-scratch-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: directory) } + let suite = "margin-scratch-defaults-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("Expected isolated defaults") + } + defer { defaults.removePersistentDomain(forName: suite) } + + let scratchStore = ScratchpadStore( + rootURL: directory.appendingPathComponent("private", isDirectory: true), + defaults: defaults, + counterKey: "counter" + ) + let descriptor = try scratchStore.create() + let controller = WorkspaceWindowController( + workspaceURL: nil, + scratchpad: descriptor, + scratchpadStore: scratchStore + ) + defer { controller.close() } + controller.showWindow(nil) + + XCTAssertTrue(controller.isScratchpad) + XCTAssertEqual(controller.window?.title, "Untitled 1") + XCTAssertNil(controller.window?.representedURL) + XCTAssertEqual(controller.sessionState?.scratchpadID, descriptor.id) + XCTAssertEqual( + controller.sessionState?.restorationIdentifier, + controller.restorationIdentifier + ) + XCTAssertEqual( + controller.window?.identifier, + WorkspaceRestorationIdentifier.make(controller.restorationIdentifier) + ) + XCTAssertTrue(controller.window?.isRestorable == true) + XCTAssertTrue( + controller.window?.restorationClass === WorkspaceWindowRestorationHandler.self + ) + + let textView = try XCTUnwrap(descendantTextView(in: controller.window?.contentView)) + waitUntil { textView.isEditable } + textView.insertText("# Durable scratch\n", replacementRange: NSRange(location: 0, length: 0)) + let scratchURL = scratchStore.url(for: descriptor.id) + XCTAssertTrue(controller.prepareForApplicationTermination()) + XCTAssertEqual( + try String(contentsOf: scratchURL, encoding: .utf8), + "# Durable scratch\n" + ) + + let destination = directory.appendingPathComponent("named.md") + XCTAssertTrue(controller.saveScratchpad(to: destination)) + XCTAssertFalse(controller.isScratchpad) + XCTAssertFalse(scratchStore.exists(descriptor)) + XCTAssertEqual(try String(contentsOf: destination, encoding: .utf8), "# Durable scratch\n") + XCTAssertEqual(controller.window?.representedURL, destination.standardizedFileURL) + } + + func testLegacyFlatSessionMigratesToOneWindow() throws { + let suite = "margin-legacy-session-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("Expected isolated defaults") + } + defer { defaults.removePersistentDomain(forName: suite) } + let tab = WorkspaceTabSession( + workspacePath: "/tmp/legacy.md", + documentPath: "/tmp/legacy.md", + readerMode: false, + navigatorVisible: false, + commentsVisible: false, + editor: .beginning + ) + let tabJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(tab)) + let data = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "tabs": [tabJSON], + "selectedIndex": 0, + ]) + defaults.set(data, forKey: "session") + + let migrated = try XCTUnwrap( + WorkspaceSessionStore(defaults: defaults, key: "session").load() + ) + XCTAssertEqual(migrated.windows.count, 1) + XCTAssertEqual(migrated.windows[0].tabs, [tab]) + XCTAssertEqual(migrated.windows[0].selectedIndex, 0) + } + + func testSessionCapturePreservesIndependentWindowsAndTheirTabGroups() throws { + let originalWindows = Set(NSApplication.shared.windows.map(ObjectIdentifier.init)) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("margin-window-session-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + defer { + NSApplication.shared.windows + .filter { !originalWindows.contains(ObjectIdentifier($0)) } + .forEach { $0.close() } + try? FileManager.default.removeItem(at: directory) + } + let suite = "margin-window-session-defaults-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("Expected isolated defaults") + } + defer { defaults.removePersistentDomain(forName: suite) } + let scratchStore = ScratchpadStore( + rootURL: directory.appendingPathComponent("scratch", isDirectory: true), + defaults: defaults, + counterKey: "counter" + ) + let delegate = AppDelegate( + sessionStore: WorkspaceSessionStore(defaults: defaults, key: "session"), + scratchpadStore: scratchStore + ) + + delegate.newWindow(nil) + delegate.newWindowForTab(nil) + var captured = try XCTUnwrap(delegate.workspaceSessionForTesting) + XCTAssertEqual(captured.windows.count, 1) + XCTAssertEqual(captured.windows[0].tabs.count, 2) + + delegate.newWindow(nil) + captured = try XCTUnwrap(delegate.workspaceSessionForTesting) + XCTAssertEqual(captured.windows.count, 2) + XCTAssertEqual(captured.windows.map { $0.tabs.count }.sorted(), [1, 2]) + XCTAssertEqual(captured.windows.flatMap(\.tabs).compactMap(\.scratchpadID).count, 3) + let restorationIdentifiers = captured.windows + .flatMap(\.tabs) + .compactMap(\.restorationIdentifier) + XCTAssertEqual(restorationIdentifiers.count, 3) + XCTAssertEqual(Set(restorationIdentifiers).count, 3) + XCTAssertNotNil(captured.windows[0].frame?.displayIdentifier) + } + func testUnreadCommentBadgeIsLazyAndPersistsUntilActivityIsRead() throws { let fixture = try makeDocument("# Review\n\nA passage.\n") defer { try? FileManager.default.removeItem(at: fixture.directory) } @@ -395,4 +626,10 @@ final class WorkspaceBehaviorTests: XCTestCase { let own = (view as? NSTextField).map { [$0.stringValue] } ?? [] return own + view.subviews.flatMap { descendantText(in: $0) } } + + private func descendantTextView(in view: NSView?) -> NSTextView? { + guard let view else { return nil } + if let textView = view as? NSTextView { return textView } + return view.subviews.lazy.compactMap { descendantTextView(in: $0) }.first + } } From 2068df2d065c3ab7074ba0cf68f02fa8cf265d57 Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:40:39 +0300 Subject: [PATCH 2/7] Restore windows to their macOS Spaces Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- NOTICE | 24 ++ Package.swift | 12 +- Sources/MarginApp/AppDelegate.swift | 166 ++++++++++-- .../MarginApp/NativeSpaceRestoration.swift | 249 ++++++++++++++++++ Sources/MarginApp/WorkspaceSessionStore.swift | 9 + .../MarginPrivateSupport.c | 212 +++++++++++++++ .../include/MarginPrivateSupport.h | 31 +++ .../WorkspaceBehaviorTests.swift | 100 +++++++ 8 files changed, 784 insertions(+), 19 deletions(-) create mode 100644 Sources/MarginApp/NativeSpaceRestoration.swift create mode 100644 Sources/MarginPrivateSupport/MarginPrivateSupport.c create mode 100644 Sources/MarginPrivateSupport/include/MarginPrivateSupport.h diff --git a/NOTICE b/NOTICE index 8733567..7c1eb9e 100644 --- a/NOTICE +++ b/NOTICE @@ -6,3 +6,27 @@ This product includes software developed by OpenProse, Inc. The Apache License, Version 2.0 does not grant permission to use the Margin or OpenProse trade names, trademarks, service marks, or product names except as required for reasonable and customary use in describing the origin of the work. + +This product includes software adapted from yabai: + +The MIT License (MIT) + +Copyright (c) 2019 Åsmund Vikane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift index 8f7976c..11a46fe 100644 --- a/Package.swift +++ b/Package.swift @@ -31,10 +31,20 @@ var targets: [Target] = [ #if os(macOS) products.append(.executable(name: "MarginAppBinary", targets: ["MarginApp"])) +targets.append( + .target( + name: "MarginPrivateSupport", + path: "Sources/MarginPrivateSupport", + publicHeadersPath: "include", + linkerSettings: [ + .linkedFramework("CoreFoundation"), + ] + ) +) targets.append( .executableTarget( name: "MarginApp", - dependencies: ["MarginCore"], + dependencies: ["MarginCore", "MarginPrivateSupport"], path: "Sources/MarginApp" ) ) diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift index 28404f5..feb6dfa 100644 --- a/Sources/MarginApp/AppDelegate.swift +++ b/Sources/MarginApp/AppDelegate.swift @@ -194,6 +194,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private var terminationSession: WorkspaceSession? private var nativeRestorationSession: WorkspaceSession? private var nativeRestoredControllers: [String: WorkspaceWindowController] = [:] + private lazy var spaceRestorationBackend = WorkspaceSpaceRestorationBackendFactory.make() override init() { sessionStore = WorkspaceSessionStore() @@ -216,6 +217,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { WorkspacePaneFactory.makeEditor = { EditorViewController() } WorkspacePaneFactory.makeComments = { CommentsViewController() } AppMenu.install(for: NSApplication.shared, delegate: self) + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(activeSpaceDidChange(_:)), + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(windowScreenDidChange(_:)), + name: NSWindow.didChangeScreenNotification, + object: nil + ) } func applicationDidFinishLaunching(_ notification: Notification) { @@ -228,17 +241,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { if startupURLs.isEmpty, didRestoreNatively { // AppKit has recreated and reconfigured every registered window, // including its native tab-group placement. + finishNativeWindowRestoration() } else if startupURLs.isEmpty, restoreLastSession() { - // The first restored tab is visible immediately. Remaining tabs - // attach on the next run-loop turn so session continuity never - // delays the first usable window. + // Rebuild the complete native tab graph before revealing it so + // each independent window can return to its saved Space cleanly. } else if startupURLs.isEmpty { makeWorkspaceWindow(for: nil) } else { open(startupURLs) } - if didRestoreNatively { + if didRestoreNatively, !startupURLs.isEmpty { isRestoringSession = false + nativeRestoredControllers.values.compactMap(\.window).forEach { + $0.alphaValue = 1 + } nativeRestoredControllers.removeAll() nativeRestorationSession = nil } @@ -272,6 +288,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { persistSession() } + func applicationDidBecomeActive(_ notification: Notification) { + schedulePersistSession() + } + + @objc private func activeSpaceDidChange(_ notification: Notification) { + schedulePersistSession() + } + + @objc private func windowScreenDidChange(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window.windowController is WorkspaceWindowController else { return } + schedulePersistSession() + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard workspaceWindows.allSatisfy({ $0.prepareForApplicationTermination() }) else { terminationSession = nil @@ -1036,9 +1066,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { nativeRestorationSession = loaded session = loaded } - guard let state = session.windows - .flatMap(\.tabs) - .first(where: { $0.restorationIdentifier == restorationIdentifier }), + guard let windowState = session.windows.first(where: { window in + window.tabs.contains(where: { + $0.restorationIdentifier == restorationIdentifier + }) + }), + let state = windowState.tabs.first(where: { + $0.restorationIdentifier == restorationIdentifier + }), isRestorable(state) else { return nil } isRestoringSession = true @@ -1048,10 +1083,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { restorationIdentifier: restorationIdentifier, ordersFront: false ) + if spaceRestorationBackend.isAvailable, windowState.space != nil { + controller.window?.alphaValue = 0 + } nativeRestoredControllers[restorationIdentifier] = controller return controller.window } + private func finishNativeWindowRestoration() { + guard let session = nativeRestorationSession else { + finishSessionRestoration(groups: [], selectedWindowIndex: 0) + return + } + let restored = session.windows.enumerated().compactMap { + index, state -> (Int, WorkspaceWindowSession, [WorkspaceWindowController])? in + let controllers = state.tabs.compactMap { tab in + tab.restorationIdentifier.flatMap { nativeRestoredControllers[$0] } + } + guard !controllers.isEmpty else { return nil } + return (index, state, controllers) + } + let groups = restored.map { ($0.1, $0.2) } + let selectedWindowIndex = restored.firstIndex { + $0.0 == session.selectedWindowIndex + } ?? min(max(session.selectedWindowIndex, 0), max(groups.count - 1, 0)) + restoreSpaceAssignments(for: groups) { [weak self] in + self?.finishSessionRestoration( + groups: groups, + selectedWindowIndex: selectedWindowIndex + ) + } + } + private func restoreLastSession() -> Bool { guard let session = sessionStore.load() else { return false } let indexedWindows = session.windows.enumerated().compactMap { @@ -1066,7 +1129,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { return (originalIndex, WorkspaceWindowSession( tabs: tabs, selectedIndex: selectedIndex, - frame: window.frame + frame: window.frame, + space: window.space )) } let windows = indexedWindows.map(\.1) @@ -1080,7 +1144,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { isRestoringSession = true let firstController = makeWorkspaceWindow( for: nil, - restorationState: firstTab + restorationState: firstTab, + ordersFront: false ) firstWindow.frame.map(firstController.applyRestoredWindowFrame) @@ -1100,7 +1165,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { controller = self.makeWorkspaceWindow( for: nil, tabbedTo: tabAnchor, - restorationState: state + restorationState: state, + ordersFront: false ) } controllers.append(controller) @@ -1124,18 +1190,76 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { max(restoredSelectedWindowIndex, 0), restoredWindows.count - 1 ) - let selectedWindowState = windows[selectedWindowIndex] - let selectedTabIndex = min( - max(selectedWindowState.selectedIndex, 0), - restoredWindows[selectedWindowIndex].count - 1 - ) - self.focus(restoredWindows[selectedWindowIndex][selectedTabIndex]) - self.isRestoringSession = false - self.persistSession() + let groups = Array(zip(windows, restoredWindows)) + self.restoreSpaceAssignments(for: groups) { [weak self] in + self?.finishSessionRestoration( + groups: groups, + selectedWindowIndex: selectedWindowIndex + ) + } } return true } + private func restoreSpaceAssignments( + for groups: [(WorkspaceWindowSession, [WorkspaceWindowController])], + completion: @escaping () -> Void + ) { + guard spaceRestorationBackend.isAvailable else { + completion() + return + } + let requests = groups.compactMap { + state, controllers -> ([CGWindowID], WorkspaceSpaceAssignment)? in + guard let assignment = state.space else { return nil } + let windowIDs = controllers.compactMap(\.window).compactMap { window in + window.windowNumber > 0 ? CGWindowID(window.windowNumber) : nil + } + return windowIDs.isEmpty ? nil : (windowIDs, assignment) + } + guard !requests.isEmpty else { + completion() + return + } + + var remaining = requests.count + for (windowIDs, assignment) in requests { + spaceRestorationBackend.restore( + windowIDs: windowIDs, + assignment: assignment + ) { _ in + remaining -= 1 + if remaining == 0 { completion() } + } + } + } + + private func finishSessionRestoration( + groups: [(WorkspaceWindowSession, [WorkspaceWindowController])], + selectedWindowIndex: Int + ) { + for (state, controllers) in groups where !controllers.isEmpty { + let selectedIndex = min(max(state.selectedIndex, 0), controllers.count - 1) + let selectedWindow = controllers[selectedIndex].window + selectedWindow?.tabGroup?.selectedWindow = selectedWindow + controllers.compactMap(\.window).forEach { $0.alphaValue = 1 } + selectedWindow?.orderFront(nil) + } + + if groups.indices.contains(selectedWindowIndex) { + let state = groups[selectedWindowIndex].0 + let controllers = groups[selectedWindowIndex].1 + if !controllers.isEmpty { + let selectedTabIndex = min(max(state.selectedIndex, 0), controllers.count - 1) + focus(controllers[selectedTabIndex]) + } + } + isRestoringSession = false + nativeRestoredControllers.removeAll() + nativeRestorationSession = nil + persistSession() + } + private func persistSession(synchronously: Bool = false) { guard didFinishLaunching, !isRestoringSession else { return } let session = captureWorkspaceSession() @@ -1184,6 +1308,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { controllers.forEach { controller in controllerWindowIndices[ObjectIdentifier(controller)] = windowIndex } + let windowIDs = controllers.compactMap(\.window).compactMap { window in + window.windowNumber > 0 ? CGWindowID(window.windowNumber) : nil + } windowSessions.append(WorkspaceWindowSession( tabs: tabs, selectedIndex: selectedIndex, @@ -1193,6 +1320,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { for: selectedWindow.screen ), displayFrame: selectedWindow.screen?.visibleFrame + ), + space: spaceRestorationBackend.captureAssignment( + forWindowIDs: windowIDs ) )) } diff --git a/Sources/MarginApp/NativeSpaceRestoration.swift b/Sources/MarginApp/NativeSpaceRestoration.swift new file mode 100644 index 0000000..11acf27 --- /dev/null +++ b/Sources/MarginApp/NativeSpaceRestoration.swift @@ -0,0 +1,249 @@ +import AppKit +import Foundation +import MarginPrivateSupport + +struct WorkspaceSpaceDescriptor: Equatable { + var managedSpaceID: UInt64 + var spaceUUID: String? + var displayIdentifier: String + var desktopOrdinal: Int? + var spaceType: Int + var isActive: Bool +} + +enum WorkspaceSpaceResolver { + static func resolve( + _ assignment: WorkspaceSpaceAssignment, + in catalog: [WorkspaceSpaceDescriptor] + ) -> WorkspaceSpaceDescriptor? { + let desktops = catalog.filter { $0.spaceType == 0 } + if let uuid = assignment.spaceUUID, + let exact = desktops.first(where: { $0.spaceUUID == uuid }) { + return exact + } + if assignment.spaceUUID == nil, + let id = assignment.managedSpaceID, + let hinted = desktops.first(where: { + $0.managedSpaceID == id + && displayMatches(assignment.displayIdentifier, $0.displayIdentifier) + }) { + return hinted + } + if let display = assignment.displayIdentifier, + let ordinal = assignment.desktopOrdinal, + let ordinalMatch = desktops.first(where: { + $0.displayIdentifier == display && $0.desktopOrdinal == ordinal + }) { + return ordinalMatch + } + if let display = assignment.displayIdentifier, + let active = desktops.first(where: { + $0.displayIdentifier == display && $0.isActive + }) { + return active + } + return nil + } + + private static func displayMatches(_ saved: String?, _ candidate: String) -> Bool { + saved == nil || saved == candidate + } +} + +protocol WorkspaceSpaceRestorationBackend: AnyObject { + var isAvailable: Bool { get } + func captureAssignment( + forWindowIDs windowIDs: [CGWindowID] + ) -> WorkspaceSpaceAssignment? + func restore( + windowIDs: [CGWindowID], + assignment: WorkspaceSpaceAssignment, + completion: @escaping (Bool) -> Void + ) +} + +final class AppKitSpaceRestorationBackend: WorkspaceSpaceRestorationBackend { + let isAvailable = false + + func captureAssignment( + forWindowIDs windowIDs: [CGWindowID] + ) -> WorkspaceSpaceAssignment? { + nil + } + + func restore( + windowIDs: [CGWindowID], + assignment: WorkspaceSpaceAssignment, + completion: @escaping (Bool) -> Void + ) { + completion(false) + } +} + +final class TahoeNativeSpaceRestorationBackend: WorkspaceSpaceRestorationBackend { + private static let supportedMajorVersion = 26 + private static let maximumVerificationAttempts = 20 + private static let verificationInterval: TimeInterval = 0.05 + + let isAvailable: Bool + + init(processInfo: ProcessInfo = .processInfo) { + isAvailable = processInfo.operatingSystemVersion.majorVersion + == Self.supportedMajorVersion + && MarginNativeSpacesAvailable() + } + + func captureAssignment( + forWindowIDs windowIDs: [CGWindowID] + ) -> WorkspaceSpaceAssignment? { + guard isAvailable, !windowIDs.isEmpty else { return nil } + let catalog = spaceCatalog() + let windowSpaces = Set(spaceIDs(for: windowIDs)) + let matchingDesktops = catalog.filter { + $0.spaceType == 0 && windowSpaces.contains($0.managedSpaceID) + } + guard matchingDesktops.count == 1, let space = matchingDesktops.first else { + return nil + } + return WorkspaceSpaceAssignment( + spaceUUID: space.spaceUUID, + managedSpaceID: space.managedSpaceID, + displayIdentifier: space.displayIdentifier, + desktopOrdinal: space.desktopOrdinal, + spaceType: space.spaceType + ) + } + + func restore( + windowIDs: [CGWindowID], + assignment: WorkspaceSpaceAssignment, + completion: @escaping (Bool) -> Void + ) { + guard isAvailable, + assignment.spaceType == 0, + !windowIDs.isEmpty, + let destination = WorkspaceSpaceResolver.resolve( + assignment, + in: spaceCatalog() + ) else { + completion(false) + return + } + if allWindows(windowIDs, occupy: destination.managedSpaceID) { + completion(true) + return + } + let invoked = windowIDs.withUnsafeBufferPointer { buffer in + MarginNativeSpacesMoveWindows( + buffer.baseAddress, + buffer.count, + destination.managedSpaceID + ) + } + guard invoked else { + completion(false) + return + } + verify( + windowIDs: windowIDs, + destinationSpaceID: destination.managedSpaceID, + remainingAttempts: Self.maximumVerificationAttempts, + completion: completion + ) + } + + private func verify( + windowIDs: [CGWindowID], + destinationSpaceID: UInt64, + remainingAttempts: Int, + completion: @escaping (Bool) -> Void + ) { + if allWindows(windowIDs, occupy: destinationSpaceID) { + completion(true) + return + } + guard remainingAttempts > 1 else { + completion(false) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + Self.verificationInterval) { + [weak self] in + guard let self else { + completion(false) + return + } + self.verify( + windowIDs: windowIDs, + destinationSpaceID: destinationSpaceID, + remainingAttempts: remainingAttempts - 1, + completion: completion + ) + } + } + + private func allWindows(_ windowIDs: [CGWindowID], occupy spaceID: UInt64) -> Bool { + windowIDs.allSatisfy { spaceIDs(for: [$0]).contains(spaceID) } + } + + private func spaceIDs(for windowIDs: [CGWindowID]) -> [UInt64] { + guard !windowIDs.isEmpty else { return [] } + let spaces = windowIDs.withUnsafeBufferPointer { buffer in + MarginNativeSpacesCopySpacesForWindows(buffer.baseAddress, buffer.count) + } + return (spaces as? [NSNumber])?.map(\.uint64Value) ?? [] + } + + private func spaceCatalog() -> [WorkspaceSpaceDescriptor] { + guard let managedDisplays = MarginNativeSpacesCopyManagedDisplaySpaces() + as? [[String: Any]] else { return [] } + var result: [WorkspaceSpaceDescriptor] = [] + for managedDisplay in managedDisplays { + guard let rawDisplayIdentifier = managedDisplay["Display Identifier"] as? String, + let spaces = managedDisplay["Spaces"] as? [[String: Any]] else { continue } + let displayIdentifier = normalizedDisplayIdentifier(rawDisplayIdentifier) + let activeID = spaceID(in: managedDisplay["Current Space"]) + var desktopOrdinal = 0 + for space in spaces { + guard let managedSpaceID = spaceID(in: space) else { continue } + let type = (space["type"] as? NSNumber)?.intValue + ?? Int(MarginNativeSpacesGetType(managedSpaceID)) + let ordinal: Int? + if type == 0 { + ordinal = desktopOrdinal + desktopOrdinal += 1 + } else { + ordinal = nil + } + let copiedName = MarginNativeSpacesCopyName(managedSpaceID) as String? + let dictionaryUUID = space["uuid"] as? String + result.append(WorkspaceSpaceDescriptor( + managedSpaceID: managedSpaceID, + spaceUUID: copiedName ?? dictionaryUUID, + displayIdentifier: displayIdentifier, + desktopOrdinal: ordinal, + spaceType: type, + isActive: activeID == managedSpaceID + )) + } + } + return result + } + + private func spaceID(in value: Any?) -> UInt64? { + guard let dictionary = value as? [String: Any] else { return nil } + return (dictionary["id64"] as? NSNumber)?.uint64Value + ?? (dictionary["ManagedSpaceID"] as? NSNumber)?.uint64Value + } + + private func normalizedDisplayIdentifier(_ identifier: String) -> String { + guard identifier == "Main" else { return identifier } + return WorkspaceScreenIdentity.identifier(for: NSScreen.main) ?? identifier + } +} + +enum WorkspaceSpaceRestorationBackendFactory { + static func make() -> WorkspaceSpaceRestorationBackend { + let native = TahoeNativeSpaceRestorationBackend() + return native.isAvailable ? native : AppKitSpaceRestorationBackend() + } +} diff --git a/Sources/MarginApp/WorkspaceSessionStore.swift b/Sources/MarginApp/WorkspaceSessionStore.swift index fb84615..6b20ee0 100644 --- a/Sources/MarginApp/WorkspaceSessionStore.swift +++ b/Sources/MarginApp/WorkspaceSessionStore.swift @@ -150,6 +150,15 @@ struct WorkspaceWindowSession: Codable, Equatable { var tabs: [WorkspaceTabSession] var selectedIndex: Int var frame: WorkspaceWindowFrame? + var space: WorkspaceSpaceAssignment? = nil +} + +struct WorkspaceSpaceAssignment: Codable, Equatable { + var spaceUUID: String? + var managedSpaceID: UInt64? + var displayIdentifier: String? + var desktopOrdinal: Int? + var spaceType: Int } struct WorkspaceSession: Codable, Equatable { diff --git a/Sources/MarginPrivateSupport/MarginPrivateSupport.c b/Sources/MarginPrivateSupport/MarginPrivateSupport.c new file mode 100644 index 0000000..97c0be1 --- /dev/null +++ b/Sources/MarginPrivateSupport/MarginPrivateSupport.c @@ -0,0 +1,212 @@ +#include "MarginPrivateSupport.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +typedef int (*SLSMainConnectionIDFunction)(void); +typedef CFArrayRef (*SLSCopyManagedDisplaySpacesFunction)(int); +typedef CFArrayRef (*SLSCopySpacesForWindowsFunction)(int, int, CFArrayRef); +typedef CFStringRef (*SLSSpaceCopyNameFunction)(int, uint64_t); +typedef int (*SLSSpaceGetTypeFunction)(int, uint64_t); +typedef int64_t (*SLSPerformBridgedOperationFunction)(void *); + +static SLSMainConnectionIDFunction main_connection_id; +static SLSCopyManagedDisplaySpacesFunction copy_managed_display_spaces; +static SLSCopySpacesForWindowsFunction copy_spaces_for_windows; +static SLSSpaceCopyNameFunction copy_space_name; +static SLSSpaceGetTypeFunction get_space_type; +static SLSPerformBridgedOperationFunction perform_bridged_operation; +static bool support_available; + +// SkyLight keeps the Tahoe bridge entry point local rather than exporting it +// through dlsym. Resolve that one address from the loaded Mach-O image while +// looking up every other private API dynamically. This fail-closed technique +// is adapted from yabai's Tahoe implementation; see NOTICE for attribution. +static void *find_local_symbol( + const struct mach_header_64 *header, + intptr_t slide, + const char *target +) { + const struct segment_command_64 *linkedit = NULL; + const struct symtab_command *symtab = NULL; + const uint8_t *cursor = (const uint8_t *)header + sizeof(*header); + + for (uint32_t index = 0; index < header->ncmds; index++) { + const struct load_command *command = (const struct load_command *)cursor; + if (command->cmd == LC_SEGMENT_64) { + const struct segment_command_64 *segment = + (const struct segment_command_64 *)command; + if (strncmp(segment->segname, SEG_LINKEDIT, sizeof(segment->segname)) == 0) { + linkedit = segment; + } + } else if (command->cmd == LC_SYMTAB) { + symtab = (const struct symtab_command *)command; + } + cursor += command->cmdsize; + } + + if (linkedit == NULL || symtab == NULL) return NULL; + uintptr_t linkedit_base = (uintptr_t)slide + linkedit->vmaddr - linkedit->fileoff; + const struct nlist_64 *symbols = + (const struct nlist_64 *)(linkedit_base + symtab->symoff); + const char *strings = (const char *)(linkedit_base + symtab->stroff); + + for (uint32_t index = 0; index < symtab->nsyms; index++) { + uint32_t string_index = symbols[index].n_un.n_strx; + if (string_index == 0 || string_index >= symtab->strsize) continue; + if (strcmp(strings + string_index, target) == 0) { + return (void *)((uintptr_t)slide + symbols[index].n_value); + } + } + return NULL; +} + +static void load_support(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + const char *path = + "/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight"; + void *handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL); + if (handle == NULL) return; + + main_connection_id = (SLSMainConnectionIDFunction)dlsym( + handle, "SLSMainConnectionID" + ); + copy_managed_display_spaces = (SLSCopyManagedDisplaySpacesFunction)dlsym( + handle, "SLSCopyManagedDisplaySpaces" + ); + copy_spaces_for_windows = (SLSCopySpacesForWindowsFunction)dlsym( + handle, "SLSCopySpacesForWindows" + ); + copy_space_name = (SLSSpaceCopyNameFunction)dlsym(handle, "SLSSpaceCopyName"); + get_space_type = (SLSSpaceGetTypeFunction)dlsym(handle, "SLSSpaceGetType"); + + for (uint32_t index = 0; index < _dyld_image_count(); index++) { + const char *image_name = _dyld_get_image_name(index); + if (image_name == NULL || strcmp(image_name, path) != 0) continue; + const struct mach_header *candidate = _dyld_get_image_header(index); + if (candidate == NULL || candidate->magic != MH_MAGIC_64) break; + perform_bridged_operation = (SLSPerformBridgedOperationFunction)find_local_symbol( + (const struct mach_header_64 *)candidate, + _dyld_get_image_vmaddr_slide(index), + "__ZL54SLSPerformAsynchronousBridgedWindowManagementOperationP47SLSAsynchronousBridgedWindowManagementOperation" + ); + break; + } + + Class operation_class = objc_getClass( + "SLSBridgedMoveWindowsToManagedSpaceOperation" + ); + SEL initializer = sel_registerName("initWithWindows:spaceID:"); + support_available = main_connection_id != NULL + && copy_managed_display_spaces != NULL + && copy_spaces_for_windows != NULL + && copy_space_name != NULL + && get_space_type != NULL + && perform_bridged_operation != NULL + && operation_class != Nil + && class_getInstanceMethod(operation_class, initializer) != NULL; + }); +} + +static CFArrayRef copy_window_id_array(const uint32_t *window_ids, size_t count) { + if (window_ids == NULL || count == 0) return NULL; + CFMutableArrayRef result = CFArrayCreateMutable( + kCFAllocatorDefault, + (CFIndex)count, + &kCFTypeArrayCallBacks + ); + if (result == NULL) return NULL; + + for (size_t index = 0; index < count; index++) { + uint32_t value = window_ids[index]; + CFNumberRef number = CFNumberCreate( + kCFAllocatorDefault, + kCFNumberSInt32Type, + &value + ); + if (number == NULL) { + CFRelease(result); + return NULL; + } + CFArrayAppendValue(result, number); + CFRelease(number); + } + return result; +} + +bool MarginNativeSpacesAvailable(void) { + load_support(); + return support_available; +} + +CFArrayRef MarginNativeSpacesCopyManagedDisplaySpaces(void) { + load_support(); + if (!support_available) return NULL; + return copy_managed_display_spaces(main_connection_id()); +} + +CFArrayRef MarginNativeSpacesCopySpacesForWindows( + const uint32_t *window_ids, + size_t window_count +) { + load_support(); + if (!support_available) return NULL; + CFArrayRef windows = copy_window_id_array(window_ids, window_count); + if (windows == NULL) return NULL; + CFArrayRef spaces = copy_spaces_for_windows(main_connection_id(), 0x7, windows); + CFRelease(windows); + return spaces; +} + +CFStringRef MarginNativeSpacesCopyName(uint64_t space_id) { + load_support(); + if (!support_available) return NULL; + return copy_space_name(main_connection_id(), space_id); +} + +int MarginNativeSpacesGetType(uint64_t space_id) { + load_support(); + if (!support_available) return -1; + return get_space_type(main_connection_id(), space_id); +} + +bool MarginNativeSpacesMoveWindows( + const uint32_t *window_ids, + size_t window_count, + uint64_t space_id +) { + load_support(); + if (!support_available || window_ids == NULL || window_count == 0) return false; + CFArrayRef windows = copy_window_id_array(window_ids, window_count); + if (windows == NULL) return false; + + Class operation_class = objc_getClass( + "SLSBridgedMoveWindowsToManagedSpaceOperation" + ); + SEL allocate = sel_registerName("alloc"); + SEL initialize = sel_registerName("initWithWindows:spaceID:"); + SEL release = sel_registerName("release"); + id operation = ((id (*)(id, SEL))objc_msgSend)((id)operation_class, allocate); + operation = ((id (*)(id, SEL, id, uint64_t))objc_msgSend)( + operation, + initialize, + (id)windows, + space_id + ); + if (operation == nil) { + CFRelease(windows); + return false; + } + + perform_bridged_operation((void *)operation); + ((void (*)(id, SEL))objc_msgSend)(operation, release); + CFRelease(windows); + return true; +} diff --git a/Sources/MarginPrivateSupport/include/MarginPrivateSupport.h b/Sources/MarginPrivateSupport/include/MarginPrivateSupport.h new file mode 100644 index 0000000..2e48b2e --- /dev/null +++ b/Sources/MarginPrivateSupport/include/MarginPrivateSupport.h @@ -0,0 +1,31 @@ +#ifndef MARGIN_PRIVATE_SUPPORT_H +#define MARGIN_PRIVATE_SUPPORT_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +bool MarginNativeSpacesAvailable(void); +CFArrayRef MarginNativeSpacesCopyManagedDisplaySpaces(void) CF_RETURNS_RETAINED; +CFArrayRef MarginNativeSpacesCopySpacesForWindows( + const uint32_t *window_ids, + size_t window_count +) CF_RETURNS_RETAINED; +CFStringRef MarginNativeSpacesCopyName(uint64_t space_id) CF_RETURNS_RETAINED; +int MarginNativeSpacesGetType(uint64_t space_id); +bool MarginNativeSpacesMoveWindows( + const uint32_t *window_ids, + size_t window_count, + uint64_t space_id +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift index 558598e..84c2676 100644 --- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift +++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift @@ -280,6 +280,13 @@ final class WorkspaceBehaviorTests: XCTestCase { CGRect(x: 40, y: 80, width: 1180, height: 780), displayIdentifier: "display-test", displayFrame: CGRect(x: 0, y: 0, width: 1440, height: 900) + ), + space: WorkspaceSpaceAssignment( + spaceUUID: "space-test", + managedSpaceID: 42, + displayIdentifier: "display-test", + desktopOrdinal: 1, + spaceType: 0 ) ) ], @@ -327,6 +334,81 @@ final class WorkspaceBehaviorTests: XCTestCase { ) XCTAssertNil(session.windows[0].tabs[0].restorationIdentifier) XCTAssertNil(session.windows[0].frame?.displayIdentifier) + XCTAssertNil(session.windows[0].space) + } + + func testSpaceResolverPrefersStableUUIDOverTransientSpaceID() throws { + let assignment = WorkspaceSpaceAssignment( + spaceUUID: "target-space", + managedSpaceID: 12, + displayIdentifier: "display-a", + desktopOrdinal: 0, + spaceType: 0 + ) + let resolved = try XCTUnwrap(WorkspaceSpaceResolver.resolve( + assignment, + in: [ + spaceDescriptor(id: 12, uuid: "recycled-space", ordinal: 0), + spaceDescriptor(id: 99, uuid: "target-space", ordinal: 3), + ] + )) + + XCTAssertEqual(resolved.managedSpaceID, 99) + } + + func testSpaceResolverFallsBackToDesktopOrdinalThenActiveDesktop() throws { + let ordinalAssignment = WorkspaceSpaceAssignment( + spaceUUID: "deleted-space", + managedSpaceID: 12, + displayIdentifier: "display-a", + desktopOrdinal: 1, + spaceType: 0 + ) + let catalog = [ + spaceDescriptor(id: 20, uuid: "first", ordinal: 0), + spaceDescriptor(id: 21, uuid: "second", ordinal: 1), + spaceDescriptor(id: 22, uuid: "active", ordinal: 2, isActive: true), + ] + XCTAssertEqual( + WorkspaceSpaceResolver.resolve(ordinalAssignment, in: catalog)?.managedSpaceID, + 21 + ) + + var activeAssignment = ordinalAssignment + activeAssignment.desktopOrdinal = 8 + XCTAssertEqual( + WorkspaceSpaceResolver.resolve(activeAssignment, in: catalog)?.managedSpaceID, + 22 + ) + } + + func testSpaceResolverNeverTargetsFullscreenOrAnotherDisplay() { + let assignment = WorkspaceSpaceAssignment( + spaceUUID: "missing", + managedSpaceID: 12, + displayIdentifier: "display-a", + desktopOrdinal: 1, + spaceType: 0 + ) + let catalog = [ + spaceDescriptor( + id: 20, + uuid: "fullscreen", + display: "display-a", + ordinal: nil, + type: 4, + isActive: true + ), + spaceDescriptor( + id: 21, + uuid: "other-display", + display: "display-b", + ordinal: 1, + isActive: true + ), + ] + + XCTAssertNil(WorkspaceSpaceResolver.resolve(assignment, in: catalog)) } func testWindowPlacementFollowsTheSameDisplayWhenItsLayoutChanges() throws { @@ -600,6 +682,24 @@ final class WorkspaceBehaviorTests: XCTestCase { wait(for: [expectation], timeout: timeout + 0.2) } + private func spaceDescriptor( + id: UInt64, + uuid: String, + display: String = "display-a", + ordinal: Int?, + type: Int = 0, + isActive: Bool = false + ) -> WorkspaceSpaceDescriptor { + WorkspaceSpaceDescriptor( + managedSpaceID: id, + spaceUUID: uuid, + displayIdentifier: display, + desktopOrdinal: ordinal, + spaceType: type, + isActive: isActive + ) + } + private func makeDocument(_ source: String) throws -> (directory: URL, file: URL) { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("margin-workspace-tests-\(UUID().uuidString)", isDirectory: true) From e1cc42089f7dea87c5591908fda77a9ddc70b6bc Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:01:00 +0300 Subject: [PATCH 3/7] Avoid switching Spaces during session restore Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- Sources/MarginApp/AppDelegate.swift | 88 ++++++++++++++++--- .../WorkspaceBehaviorTests.swift | 41 +++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift index feb6dfa..de04c4c 100644 --- a/Sources/MarginApp/AppDelegate.swift +++ b/Sources/MarginApp/AppDelegate.swift @@ -31,6 +31,27 @@ enum WorkspaceScreenIdentity { } } +enum WorkspaceLaunchAction: Equatable { + case focusRestoredWindow(Int) + case createEmptyWindow +} + +enum WorkspaceLaunchPolicy { + static func action( + previouslySelectedIndex: Int, + restoredWindowsOnActiveSpace: [Bool] + ) -> WorkspaceLaunchAction { + if restoredWindowsOnActiveSpace.indices.contains(previouslySelectedIndex), + restoredWindowsOnActiveSpace[previouslySelectedIndex] { + return .focusRestoredWindow(previouslySelectedIndex) + } + if let localIndex = restoredWindowsOnActiveSpace.firstIndex(of: true) { + return .focusRestoredWindow(localIndex) + } + return .createEmptyWindow + } +} + @MainActor final class WorkspaceWindowRestorationHandler: NSObject, NSWindowRestoration { static func restoreWindow( @@ -237,14 +258,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { let startupURLs = pendingURLs.isEmpty ? commandLineURLs() : pendingURLs pendingURLs.removeAll() let didRestoreNatively = !nativeRestoredControllers.isEmpty + var defersActivationUntilRestorationCompletes = false if startupURLs.isEmpty, didRestoreNatively { // AppKit has recreated and reconfigured every registered window, // including its native tab-group placement. + defersActivationUntilRestorationCompletes = true finishNativeWindowRestoration() } else if startupURLs.isEmpty, restoreLastSession() { // Rebuild the complete native tab graph before revealing it so // each independent window can return to its saved Space cleanly. + defersActivationUntilRestorationCompletes = true } else if startupURLs.isEmpty { makeWorkspaceWindow(for: nil) } else { @@ -259,7 +283,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { nativeRestorationSession = nil } - NSApplication.shared.activate(ignoringOtherApps: true) + if !defersActivationUntilRestorationCompletes { + NSApplication.shared.activate(ignoringOtherApps: true) + } } func application(_ application: NSApplication, open urls: [URL]) { @@ -284,6 +310,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { true } + func applicationShouldHandleReopen( + _ sender: NSApplication, + hasVisibleWindows _: Bool + ) -> Bool { + if let localWindow = activeSpaceDocumentWindow { + localWindow.makeKeyAndOrderFront(nil) + } else { + let controller = makeWorkspaceWindow(for: nil, ordersFront: false) + focus(controller) + } + sender.activate(ignoringOtherApps: true) + return false + } + func applicationDidResignActive(_ notification: Notification) { persistSession() } @@ -687,6 +727,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { return workspaceWindows.last(where: { $0.window?.isVisible == true })?.window } + private var activeSpaceDocumentWindow: NSWindow? { + NSApplication.shared.windows.first { window in + guard window.isOnActiveSpace, + (window.windowController is WorkspaceWindowController + || window.windowController is ComparisonWindowController) else { return false } + return window.tabGroup?.selectedWindow == nil + || window.tabGroup?.selectedWindow === window + } + } + private func open(_ urls: [URL]) { var seen = Set() var tabAnchor = activeTabAnchorWindow @@ -1238,25 +1288,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { groups: [(WorkspaceWindowSession, [WorkspaceWindowController])], selectedWindowIndex: Int ) { - for (state, controllers) in groups where !controllers.isEmpty { + var restoredWindowsOnActiveSpace: [Bool] = [] + var selectedControllers: [WorkspaceWindowController?] = [] + for (state, controllers) in groups { + guard !controllers.isEmpty else { + restoredWindowsOnActiveSpace.append(false) + selectedControllers.append(nil) + continue + } let selectedIndex = min(max(state.selectedIndex, 0), controllers.count - 1) - let selectedWindow = controllers[selectedIndex].window + let selectedController = controllers[selectedIndex] + let selectedWindow = selectedController.window selectedWindow?.tabGroup?.selectedWindow = selectedWindow controllers.compactMap(\.window).forEach { $0.alphaValue = 1 } selectedWindow?.orderFront(nil) + restoredWindowsOnActiveSpace.append(selectedWindow?.isOnActiveSpace == true) + selectedControllers.append(selectedController) } - if groups.indices.contains(selectedWindowIndex) { - let state = groups[selectedWindowIndex].0 - let controllers = groups[selectedWindowIndex].1 - if !controllers.isEmpty { - let selectedTabIndex = min(max(state.selectedIndex, 0), controllers.count - 1) - focus(controllers[selectedTabIndex]) - } + let launchAction = WorkspaceLaunchPolicy.action( + previouslySelectedIndex: selectedWindowIndex, + restoredWindowsOnActiveSpace: restoredWindowsOnActiveSpace + ) + let launchController: WorkspaceWindowController + switch launchAction { + case .focusRestoredWindow(let index): + launchController = selectedControllers.indices.contains(index) + ? selectedControllers[index] ?? makeWorkspaceWindow(for: nil, ordersFront: false) + : makeWorkspaceWindow(for: nil, ordersFront: false) + case .createEmptyWindow: + launchController = makeWorkspaceWindow(for: nil, ordersFront: false) } + isRestoringSession = false nativeRestoredControllers.removeAll() nativeRestorationSession = nil + focus(launchController) + NSApplication.shared.activate(ignoringOtherApps: true) persistSession() } diff --git a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift index 84c2676..78139ca 100644 --- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift +++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift @@ -34,6 +34,7 @@ final class WorkspaceBehaviorTests: XCTestCase { XCTAssertFalse(controller.isNavigatorVisible) XCTAssertFalse(controller.isCommentsVisible) XCTAssertFalse(controller.canShowComments) + XCTAssertNil(controller.sessionState) let initialWidth = window.frame.width let untitled = FileManager.default.temporaryDirectory @@ -411,6 +412,46 @@ final class WorkspaceBehaviorTests: XCTestCase { XCTAssertNil(WorkspaceSpaceResolver.resolve(assignment, in: catalog)) } + func testLaunchPolicyKeepsThePreviouslySelectedWindowWhenItIsLocal() { + XCTAssertEqual( + WorkspaceLaunchPolicy.action( + previouslySelectedIndex: 1, + restoredWindowsOnActiveSpace: [true, true, false] + ), + .focusRestoredWindow(1) + ) + } + + func testLaunchPolicyUsesAnotherLocalWindowInsteadOfSwitchingSpaces() { + XCTAssertEqual( + WorkspaceLaunchPolicy.action( + previouslySelectedIndex: 2, + restoredWindowsOnActiveSpace: [false, true, false] + ), + .focusRestoredWindow(1) + ) + } + + func testLaunchPolicyCreatesAnEmptyWindowWhenOnlySavedWindowIsRemote() { + XCTAssertEqual( + WorkspaceLaunchPolicy.action( + previouslySelectedIndex: 0, + restoredWindowsOnActiveSpace: [false] + ), + .createEmptyWindow + ) + } + + func testLaunchPolicyCreatesAnEmptyWindowWhenEverySavedWindowIsRemote() { + XCTAssertEqual( + WorkspaceLaunchPolicy.action( + previouslySelectedIndex: 1, + restoredWindowsOnActiveSpace: [false, false, false] + ), + .createEmptyWindow + ) + } + func testWindowPlacementFollowsTheSameDisplayWhenItsLayoutChanges() throws { let state = WorkspaceWindowFrame( CGRect(x: 1560, y: 80, width: 900, height: 650), From 09ddcf84f4f3d65e1971c8bd7461aeab8cc3f962 Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:57:46 +0300 Subject: [PATCH 4/7] Make Space restoration non-intrusive Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- Benchmarks/performance/LaunchBenchmark.swift | 21 ++ Resources/Info.plist | 2 +- Sources/MarginApp/AppDelegate.swift | 314 +++++++++++------- .../MarginApp/NativeSpaceRestoration.swift | 14 + .../MarginApp/WorkspaceWindowController.swift | 7 +- Sources/MarginApp/main.swift | 5 +- .../WorkspaceBehaviorTests.swift | 6 +- 7 files changed, 235 insertions(+), 134 deletions(-) diff --git a/Benchmarks/performance/LaunchBenchmark.swift b/Benchmarks/performance/LaunchBenchmark.swift index 8b2daf9..bbe3602 100644 --- a/Benchmarks/performance/LaunchBenchmark.swift +++ b/Benchmarks/performance/LaunchBenchmark.swift @@ -221,10 +221,20 @@ private func measureOnce(options: Options) throws -> Sample { let process = Process() process.executableURL = executable process.arguments = [options.documentPath] + let preferencesSuite = "ink.margin.editor.benchmark.\(UUID().uuidString)" let readyURL = FileManager.default.temporaryDirectory .appendingPathComponent("margin-ready-\(UUID().uuidString)", isDirectory: false) + let preferencesRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("margin-benchmark-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: preferencesRoot.appendingPathComponent("Library/Preferences", isDirectory: true), + withIntermediateDirectories: true + ) process.environment = ProcessInfo.processInfo.environment.merging([ + "CFFIXED_USER_HOME": preferencesRoot.path, + "CFPREFERENCES_AVOID_DAEMON": "1", "MARGIN_BENCHMARK_READY_FILE": readyURL.path, + "MARGIN_PREFERENCES_SUITE": preferencesSuite, ]) { _, benchmarkValue in benchmarkValue } process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice @@ -237,7 +247,18 @@ private func measureOnce(options: Options) throws -> Sample { } defer { stop(process) + if let benchmarkDefaults = UserDefaults(suiteName: preferencesSuite) { + benchmarkDefaults.removePersistentDomain(forName: preferencesSuite) + benchmarkDefaults.synchronize() + } + usleep(250_000) + let preferencesFile = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences", isDirectory: true) + .appendingPathComponent(preferencesSuite) + .appendingPathExtension("plist") + try? FileManager.default.removeItem(at: preferencesFile) try? FileManager.default.removeItem(at: readyURL) + try? FileManager.default.removeItem(at: preferencesRoot) } let timeout = UInt64(options.timeoutMilliseconds) * 1_000_000 diff --git a/Resources/Info.plist b/Resources/Info.plist index 818530e..070bc7f 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -32,7 +32,7 @@ Copyright © 2026 OpenProse, Inc. NSPrincipalClass NSApplication - NSQuitAlwaysKeepsWindows + LSUIElement CFBundleDocumentTypes diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift index de04c4c..d52fcc9 100644 --- a/Sources/MarginApp/AppDelegate.swift +++ b/Sources/MarginApp/AppDelegate.swift @@ -8,12 +8,6 @@ enum WorkspaceRestorationIdentifier { static func make(_ value: String) -> NSUserInterfaceItemIdentifier { NSUserInterfaceItemIdentifier(prefix + value) } - - static func parse(_ identifier: NSUserInterfaceItemIdentifier) -> String? { - guard identifier.rawValue.hasPrefix(prefix) else { return nil } - let value = String(identifier.rawValue.dropFirst(prefix.count)) - return value.isEmpty ? nil : value - } } enum WorkspaceScreenIdentity { @@ -52,19 +46,10 @@ enum WorkspaceLaunchPolicy { } } -@MainActor -final class WorkspaceWindowRestorationHandler: NSObject, NSWindowRestoration { - static func restoreWindow( - withIdentifier identifier: NSUserInterfaceItemIdentifier, - state: NSCoder, - completionHandler: @escaping (NSWindow?, Error?) -> Void - ) { - guard let delegate = NSApplication.shared.delegate as? AppDelegate else { - completionHandler(nil, nil) - return - } - completionHandler(delegate.restoreWorkspaceWindow(withIdentifier: identifier), nil) - } +private struct WorkspaceBackgroundReveal { + let windows: [NSWindow] + let selectedWindow: NSWindow + let assignment: WorkspaceSpaceAssignment? } protocol WorkspacePathRenameParticipating: AnyObject { @@ -206,6 +191,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private var didFinishLaunching = false private let sessionStore: WorkspaceSessionStore private let scratchpadStore: ScratchpadStore + private let recentWorkspaceStore: RecentWorkspaceStore private let sessionPersistenceQueue = DispatchQueue( label: "ink.margin.session-persistence", qos: .utility @@ -213,28 +199,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private var isRestoringSession = false private var isSessionPersistScheduled = false private var terminationSession: WorkspaceSession? - private var nativeRestorationSession: WorkspaceSession? - private var nativeRestoredControllers: [String: WorkspaceWindowController] = [:] + private var preparedReopenController: WorkspaceWindowController? private lazy var spaceRestorationBackend = WorkspaceSpaceRestorationBackendFactory.make() override init() { - sessionStore = WorkspaceSessionStore() - scratchpadStore = ScratchpadStore() + let defaults = Self.runtimeDefaults() + sessionStore = WorkspaceSessionStore(defaults: defaults) + scratchpadStore = ScratchpadStore(defaults: defaults) + recentWorkspaceStore = RecentWorkspaceStore(defaults: defaults) super.init() } - init(sessionStore: WorkspaceSessionStore, scratchpadStore: ScratchpadStore) { + init( + sessionStore: WorkspaceSessionStore, + scratchpadStore: ScratchpadStore, + recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore() + ) { self.sessionStore = sessionStore self.scratchpadStore = scratchpadStore + self.recentWorkspaceStore = recentWorkspaceStore super.init() } + private static func runtimeDefaults() -> UserDefaults { + guard let suiteName = ProcessInfo.processInfo.environment[ + "MARGIN_PREFERENCES_SUITE" + ], + !suiteName.isEmpty, + let defaults = UserDefaults(suiteName: suiteName) else { + return .standard + } + return defaults + } + func applicationWillFinishLaunching(_ notification: Notification) { - // Margin owns durable document continuity and always reopens its - // workspace. Register the matching AppKit preference as well so a - // normal Quit preserves native window state even when the system-wide - // “close windows when quitting” setting is enabled. - UserDefaults.standard.register(defaults: ["NSQuitAlwaysKeepsWindows": true]) WorkspacePaneFactory.makeEditor = { EditorViewController() } WorkspacePaneFactory.makeComments = { CommentsViewController() } AppMenu.install(for: NSApplication.shared, delegate: self) @@ -257,15 +255,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { let startupURLs = pendingURLs.isEmpty ? commandLineURLs() : pendingURLs pendingURLs.removeAll() - let didRestoreNatively = !nativeRestoredControllers.isEmpty var defersActivationUntilRestorationCompletes = false - if startupURLs.isEmpty, didRestoreNatively { - // AppKit has recreated and reconfigured every registered window, - // including its native tab-group placement. - defersActivationUntilRestorationCompletes = true - finishNativeWindowRestoration() - } else if startupURLs.isEmpty, restoreLastSession() { + if startupURLs.isEmpty, restoreLastSession() { // Rebuild the complete native tab graph before revealing it so // each independent window can return to its saved Space cleanly. defersActivationUntilRestorationCompletes = true @@ -274,20 +266,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } else { open(startupURLs) } - if didRestoreNatively, !startupURLs.isEmpty { - isRestoringSession = false - nativeRestoredControllers.values.compactMap(\.window).forEach { - $0.alphaValue = 1 - } - nativeRestoredControllers.removeAll() - nativeRestorationSession = nil - } if !defersActivationUntilRestorationCompletes { - NSApplication.shared.activate(ignoringOtherApps: true) + activatePreparedApplication() } } + func applicationShouldRestoreApplicationState(_ sender: NSApplication) -> Bool { + false + } + + func applicationShouldSaveApplicationState(_ sender: NSApplication) -> Bool { + false + } + func application(_ application: NSApplication, open urls: [URL]) { if didFinishLaunching { open(urls) @@ -311,16 +303,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } func applicationShouldHandleReopen( - _ sender: NSApplication, + _ _: NSApplication, hasVisibleWindows _: Bool ) -> Bool { - if let localWindow = activeSpaceDocumentWindow { + if let preparedReopenController, + let preparedWindow = preparedReopenController.window, + windowIsOnActiveSpace(preparedWindow) { + self.preparedReopenController = nil + preparedWindow.alphaValue = 1 + preparedWindow.makeKeyAndOrderFront(nil) + preparedWindow.makeMain() + } else if let localWindow = activeSpaceDocumentWindow { localWindow.makeKeyAndOrderFront(nil) + localWindow.makeMain() } else { let controller = makeWorkspaceWindow(for: nil, ordersFront: false) focus(controller) + controller.window?.makeMain() } - sender.activate(ignoringOtherApps: true) + // The reopen event already carries activation intent. Explicitly + // activating here can make AppKit choose a previously key window on a + // different Space even after the local window has been made key. return false } @@ -334,6 +337,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { @objc private func activeSpaceDidChange(_ notification: Notification) { schedulePersistSession() + DispatchQueue.main.async { [weak self] in + self?.prepareCurrentSpaceForReopen() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.prepareCurrentSpaceForReopen() + } } @objc private func windowScreenDidChange(_ notification: Notification) { @@ -729,7 +738,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private var activeSpaceDocumentWindow: NSWindow? { NSApplication.shared.windows.first { window in - guard window.isOnActiveSpace, + guard windowIsOnActiveSpace(window), (window.windowController is WorkspaceWindowController || window.windowController is ComparisonWindowController) else { return false } return window.tabGroup?.selectedWindow == nil @@ -737,6 +746,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } + private func windowIsOnActiveSpace(_ window: NSWindow) -> Bool { + if window.windowNumber > 0, + let result = spaceRestorationBackend.windowsAreOnActiveSpace([ + CGWindowID(window.windowNumber) + ]) { + return result + } + return window.isOnActiveSpace + } + + private func prepareCurrentSpaceForReopen() { + guard didFinishLaunching, !NSApplication.shared.isActive else { return } + + if let localWindow = activeSpaceDocumentWindow { + if localWindow !== preparedReopenController?.window { + discardPreparedReopenWindow() + } + localWindow.makeKey() + localWindow.makeMain() + return + } + + discardPreparedReopenWindow() + let controller = makeWorkspaceWindow(for: nil, ordersFront: false) + guard let window = controller.window else { return } + preparedReopenController = controller + window.alphaValue = 0 + window.orderFrontRegardless() + window.makeKey() + window.makeMain() + } + + private func discardPreparedReopenWindow() { + guard let controller = preparedReopenController else { return } + preparedReopenController = nil + controller.close() + } + private func open(_ urls: [URL]) { var seen = Set() var tabAnchor = activeTabAnchorWindow @@ -777,7 +824,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } - NSApplication.shared.activate(ignoringOtherApps: true) + activatePreparedApplication() } private func makeScratchpadWindow(tabbedTo parentWindow: NSWindow?) { @@ -808,6 +855,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { workspaceURL: url, scratchpad: scratchpad, restorationState: restorationState, + recentWorkspaceStore: recentWorkspaceStore, scratchpadStore: scratchpadStore, restorationIdentifier: restorationIdentifier ) @@ -1084,6 +1132,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { window.makeKeyAndOrderFront(nil) } + private func activatePreparedApplication() { + if NSApplication.shared.activationPolicy() != .regular { + NSApplication.shared.setActivationPolicy(.regular) + } + NSApplication.shared.activate(ignoringOtherApps: true) + } + private func commandLineURLs() -> [URL] { let workingDirectory = URL( fileURLWithPath: FileManager.default.currentDirectoryPath, @@ -1098,73 +1153,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } - func restoreWorkspaceWindow( - withIdentifier identifier: NSUserInterfaceItemIdentifier - ) -> NSWindow? { - guard commandLineURLs().isEmpty, - let restorationIdentifier = WorkspaceRestorationIdentifier.parse(identifier) - else { return nil } - if let existing = nativeRestoredControllers[restorationIdentifier] { - return existing.window - } - - let session: WorkspaceSession - if let nativeRestorationSession { - session = nativeRestorationSession - } else { - guard let loaded = sessionStore.load() else { return nil } - nativeRestorationSession = loaded - session = loaded - } - guard let windowState = session.windows.first(where: { window in - window.tabs.contains(where: { - $0.restorationIdentifier == restorationIdentifier - }) - }), - let state = windowState.tabs.first(where: { - $0.restorationIdentifier == restorationIdentifier - }), - isRestorable(state) else { return nil } - - isRestoringSession = true - let controller = makeWorkspaceWindow( - for: nil, - restorationState: state, - restorationIdentifier: restorationIdentifier, - ordersFront: false - ) - if spaceRestorationBackend.isAvailable, windowState.space != nil { - controller.window?.alphaValue = 0 - } - nativeRestoredControllers[restorationIdentifier] = controller - return controller.window - } - - private func finishNativeWindowRestoration() { - guard let session = nativeRestorationSession else { - finishSessionRestoration(groups: [], selectedWindowIndex: 0) - return - } - let restored = session.windows.enumerated().compactMap { - index, state -> (Int, WorkspaceWindowSession, [WorkspaceWindowController])? in - let controllers = state.tabs.compactMap { tab in - tab.restorationIdentifier.flatMap { nativeRestoredControllers[$0] } - } - guard !controllers.isEmpty else { return nil } - return (index, state, controllers) - } - let groups = restored.map { ($0.1, $0.2) } - let selectedWindowIndex = restored.firstIndex { - $0.0 == session.selectedWindowIndex - } ?? min(max(session.selectedWindowIndex, 0), max(groups.count - 1, 0)) - restoreSpaceAssignments(for: groups) { [weak self] in - self?.finishSessionRestoration( - groups: groups, - selectedWindowIndex: selectedWindowIndex - ) - } - } - private func restoreLastSession() -> Bool { guard let session = sessionStore.load() else { return false } let indexedWindows = session.windows.enumerated().compactMap { @@ -1284,6 +1272,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } } + private func revealBackgroundWindows( + _ reveals: [WorkspaceBackgroundReveal], + at index: Int = 0, + completion: @escaping () -> Void + ) { + guard reveals.indices.contains(index) else { + completion() + return + } + let reveal = reveals[index] + let insertedMoveToActiveSpace = !reveal.selectedWindow.collectionBehavior.contains( + .moveToActiveSpace + ) + reveal.selectedWindow.collectionBehavior.insert(.moveToActiveSpace) + reveal.selectedWindow.orderFrontRegardless() + if insertedMoveToActiveSpace { + reveal.selectedWindow.collectionBehavior.remove(.moveToActiveSpace) + } + + let finishReveal = { [weak self] in + reveal.windows.forEach { $0.alphaValue = 1 } + DispatchQueue.main.async { + self?.revealBackgroundWindows( + reveals, + at: index + 1, + completion: completion + ) + } + } + guard let assignment = reveal.assignment else { + finishReveal() + return + } + let windowIDs = reveal.windows.compactMap { window in + window.windowNumber > 0 ? CGWindowID(window.windowNumber) : nil + } + guard !windowIDs.isEmpty else { + finishReveal() + return + } + spaceRestorationBackend.restore( + windowIDs: windowIDs, + assignment: assignment + ) { _ in + finishReveal() + } + } + private func finishSessionRestoration( groups: [(WorkspaceWindowSession, [WorkspaceWindowController])], selectedWindowIndex: Int @@ -1299,10 +1335,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { let selectedIndex = min(max(state.selectedIndex, 0), controllers.count - 1) let selectedController = controllers[selectedIndex] let selectedWindow = selectedController.window - selectedWindow?.tabGroup?.selectedWindow = selectedWindow - controllers.compactMap(\.window).forEach { $0.alphaValue = 1 } - selectedWindow?.orderFront(nil) - restoredWindowsOnActiveSpace.append(selectedWindow?.isOnActiveSpace == true) + // Capture locality before ordering any restored window. Ordering a + // remote window while launch activation is in flight can switch + // Spaces, which would make subsequent isOnActiveSpace results refer + // to the wrong (newly activated) Space. + restoredWindowsOnActiveSpace.append( + selectedWindow.map(windowIsOnActiveSpace) == true + ) selectedControllers.append(selectedController) } @@ -1311,21 +1350,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { restoredWindowsOnActiveSpace: restoredWindowsOnActiveSpace ) let launchController: WorkspaceWindowController + let launchRestoredIndex: Int? switch launchAction { case .focusRestoredWindow(let index): launchController = selectedControllers.indices.contains(index) ? selectedControllers[index] ?? makeWorkspaceWindow(for: nil, ordersFront: false) : makeWorkspaceWindow(for: nil, ordersFront: false) + launchRestoredIndex = selectedControllers.indices.contains(index) + && selectedControllers[index] != nil ? index : nil case .createEmptyWindow: launchController = makeWorkspaceWindow(for: nil, ordersFront: false) + launchRestoredIndex = nil + } + + var backgroundReveals: [WorkspaceBackgroundReveal] = [] + for (index, (state, controllers)) in groups.enumerated() { + let windows = controllers.compactMap(\.window) + if index == launchRestoredIndex { + windows.forEach { $0.alphaValue = 1 } + } else if let selectedWindow = selectedControllers[index]?.window { + windows.forEach { $0.alphaValue = 0 } + backgroundReveals.append(WorkspaceBackgroundReveal( + windows: windows, + selectedWindow: selectedWindow, + assignment: state.space + )) + } } - isRestoringSession = false - nativeRestoredControllers.removeAll() - nativeRestorationSession = nil + // Complete activation with the current-Space target as the only newly + // ordered window. Once AppKit has committed that activation, reveal + // every other restored window without making it key or changing Space. focus(launchController) - NSApplication.shared.activate(ignoringOtherApps: true) - persistSession() + activatePreparedApplication() + revealBackgroundWindows(backgroundReveals) { [weak self] in + guard let self else { return } + self.isRestoringSession = false + self.persistSession() + } } private func persistSession(synchronously: Bool = false) { diff --git a/Sources/MarginApp/NativeSpaceRestoration.swift b/Sources/MarginApp/NativeSpaceRestoration.swift index 11acf27..3a81a18 100644 --- a/Sources/MarginApp/NativeSpaceRestoration.swift +++ b/Sources/MarginApp/NativeSpaceRestoration.swift @@ -52,6 +52,7 @@ enum WorkspaceSpaceResolver { protocol WorkspaceSpaceRestorationBackend: AnyObject { var isAvailable: Bool { get } + func windowsAreOnActiveSpace(_ windowIDs: [CGWindowID]) -> Bool? func captureAssignment( forWindowIDs windowIDs: [CGWindowID] ) -> WorkspaceSpaceAssignment? @@ -65,6 +66,10 @@ protocol WorkspaceSpaceRestorationBackend: AnyObject { final class AppKitSpaceRestorationBackend: WorkspaceSpaceRestorationBackend { let isAvailable = false + func windowsAreOnActiveSpace(_ windowIDs: [CGWindowID]) -> Bool? { + nil + } + func captureAssignment( forWindowIDs windowIDs: [CGWindowID] ) -> WorkspaceSpaceAssignment? { @@ -93,6 +98,15 @@ final class TahoeNativeSpaceRestorationBackend: WorkspaceSpaceRestorationBackend && MarginNativeSpacesAvailable() } + func windowsAreOnActiveSpace(_ windowIDs: [CGWindowID]) -> Bool? { + guard isAvailable, !windowIDs.isEmpty else { return nil } + let activeSpaceIDs = Set( + spaceCatalog().filter(\.isActive).map(\.managedSpaceID) + ) + guard !activeSpaceIDs.isEmpty else { return nil } + return !activeSpaceIDs.isDisjoint(with: spaceIDs(for: windowIDs)) + } + func captureAssignment( forWindowIDs windowIDs: [CGWindowID] ) -> WorkspaceSpaceAssignment? { diff --git a/Sources/MarginApp/WorkspaceWindowController.swift b/Sources/MarginApp/WorkspaceWindowController.swift index 04acb9b..145e08f 100644 --- a/Sources/MarginApp/WorkspaceWindowController.swift +++ b/Sources/MarginApp/WorkspaceWindowController.swift @@ -1046,8 +1046,11 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST window.toolbarStyle = .unified window.backgroundColor = MarginTheme.documentBackground window.identifier = WorkspaceRestorationIdentifier.make(restorationIdentifier) - window.restorationClass = WorkspaceWindowRestorationHandler.self - window.isRestorable = true + // Margin's Space-aware session is the single restoration authority. + // AppKit restoration can activate a remote saved window before Margin + // has selected the launch Space, so these windows must not participate + // in the competing system snapshot. + window.isRestorable = false // Let AppKit own live resize and full-screen sizing. Explicit frame // maxima are ignored by Auto Layout and can interact poorly with // split-view fitting sizes; a content minimum is the native contract. diff --git a/Sources/MarginApp/main.swift b/Sources/MarginApp/main.swift index 231009e..be7ce8c 100644 --- a/Sources/MarginApp/main.swift +++ b/Sources/MarginApp/main.swift @@ -3,6 +3,9 @@ import AppKit let application = NSApplication.shared let applicationDelegate = AppDelegate() -application.setActivationPolicy(.regular) +// Start without Launch Services activating a remembered remote Space. The +// delegate promotes Margin to a regular Dock app after it has prepared the +// correct current-Space window. +application.setActivationPolicy(.accessory) application.delegate = applicationDelegate application.run() diff --git a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift index 78139ca..6a7372d 100644 --- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift +++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift @@ -533,10 +533,8 @@ final class WorkspaceBehaviorTests: XCTestCase { controller.window?.identifier, WorkspaceRestorationIdentifier.make(controller.restorationIdentifier) ) - XCTAssertTrue(controller.window?.isRestorable == true) - XCTAssertTrue( - controller.window?.restorationClass === WorkspaceWindowRestorationHandler.self - ) + XCTAssertFalse(controller.window?.isRestorable == true) + XCTAssertNil(controller.window?.restorationClass) let textView = try XCTUnwrap(descendantTextView(in: controller.window?.contentView)) waitUntil { textView.isEditable } From 7171da2f765725c3ee09e2a3d64cdfac0e5100a2 Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:02:26 +0300 Subject: [PATCH 5/7] Harden reopen timing and benchmark isolation Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- Benchmarks/performance/LaunchBenchmark.swift | 1 - Sources/MarginApp/AppDelegate.swift | 22 ++++++++++++++++--- .../ComparisonWindowController.swift | 4 +++- Sources/MarginApp/RecentWorkspaceStore.swift | 6 ++++- .../MarginApp/WorkspaceWindowController.swift | 11 +++++++--- .../WorkspaceBehaviorTests.swift | 18 +++++++++++++++ 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Benchmarks/performance/LaunchBenchmark.swift b/Benchmarks/performance/LaunchBenchmark.swift index bbe3602..81edb81 100644 --- a/Benchmarks/performance/LaunchBenchmark.swift +++ b/Benchmarks/performance/LaunchBenchmark.swift @@ -251,7 +251,6 @@ private func measureOnce(options: Options) throws -> Sample { benchmarkDefaults.removePersistentDomain(forName: preferencesSuite) benchmarkDefaults.synchronize() } - usleep(250_000) let preferencesFile = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Preferences", isDirectory: true) .appendingPathComponent(preferencesSuite) diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift index d52fcc9..fed522e 100644 --- a/Sources/MarginApp/AppDelegate.swift +++ b/Sources/MarginApp/AppDelegate.swift @@ -2,6 +2,12 @@ import AppKit import ColorSync import MarginCore +enum MarginRuntimeEnvironment { + static var isPerformanceBenchmark: Bool { + ProcessInfo.processInfo.environment["MARGIN_BENCHMARK_READY_FILE"] != nil + } +} + enum WorkspaceRestorationIdentifier { private static let prefix = "ink.margin.workspace." @@ -192,6 +198,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private let sessionStore: WorkspaceSessionStore private let scratchpadStore: ScratchpadStore private let recentWorkspaceStore: RecentWorkspaceStore + private let shouldPersistSession: Bool private let sessionPersistenceQueue = DispatchQueue( label: "ink.margin.session-persistence", qos: .utility @@ -204,20 +211,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { override init() { let defaults = Self.runtimeDefaults() + let shouldPersistSession = !MarginRuntimeEnvironment.isPerformanceBenchmark sessionStore = WorkspaceSessionStore(defaults: defaults) scratchpadStore = ScratchpadStore(defaults: defaults) - recentWorkspaceStore = RecentWorkspaceStore(defaults: defaults) + recentWorkspaceStore = RecentWorkspaceStore( + defaults: defaults, + persistenceEnabled: shouldPersistSession + ) + self.shouldPersistSession = shouldPersistSession super.init() } init( sessionStore: WorkspaceSessionStore, scratchpadStore: ScratchpadStore, - recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore() + recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore(), + shouldPersistSession: Bool = true ) { self.sessionStore = sessionStore self.scratchpadStore = scratchpadStore self.recentWorkspaceStore = recentWorkspaceStore + self.shouldPersistSession = shouldPersistSession super.init() } @@ -356,6 +370,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { terminationSession = nil return .terminateCancel } + guard shouldPersistSession else { return .terminateNow } terminationSession = captureWorkspaceSession() persistSession(synchronously: true) workspaceWindows.forEach { @@ -367,6 +382,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } func applicationWillTerminate(_ notification: Notification) { + guard shouldPersistSession else { return } if let terminationSession { sessionPersistenceQueue.sync { [sessionStore] in sessionStore.save(terminationSession) @@ -1391,7 +1407,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } private func persistSession(synchronously: Bool = false) { - guard didFinishLaunching, !isRestoringSession else { return } + guard shouldPersistSession, didFinishLaunching, !isRestoringSession else { return } let session = captureWorkspaceSession() let save = { [sessionStore] in sessionStore.save(session) } if synchronously { diff --git a/Sources/MarginApp/ComparisonWindowController.swift b/Sources/MarginApp/ComparisonWindowController.swift index d6b8e48..2b8ed16 100644 --- a/Sources/MarginApp/ComparisonWindowController.swift +++ b/Sources/MarginApp/ComparisonWindowController.swift @@ -222,7 +222,9 @@ final class ComparisonWindowController: NSWindowController, NSWindowDelegate { window.tabbingMode = .disallowed window.minSize = NSSize(width: 620, height: 460) window.collectionBehavior.insert(.fullScreenPrimary) - window.setFrameAutosaveName("MarginComparisonWindow") + if !MarginRuntimeEnvironment.isPerformanceBenchmark { + window.setFrameAutosaveName("MarginComparisonWindow") + } if let visible = NSScreen.main?.visibleFrame { let width = min(1180, max(620, visible.width - 96)) diff --git a/Sources/MarginApp/RecentWorkspaceStore.swift b/Sources/MarginApp/RecentWorkspaceStore.swift index b1e6df8..b26b901 100644 --- a/Sources/MarginApp/RecentWorkspaceStore.swift +++ b/Sources/MarginApp/RecentWorkspaceStore.swift @@ -21,13 +21,16 @@ final class RecentWorkspaceStore { private let defaults: UserDefaults private let key: String + private let persistenceEnabled: Bool init( defaults: UserDefaults = .standard, - key: String = "MarginRecentWorkspaces.v1" + key: String = "MarginRecentWorkspaces.v1", + persistenceEnabled: Bool = true ) { self.defaults = defaults self.key = key + self.persistenceEnabled = persistenceEnabled } func workspaces(limit: Int = 10) -> [RecentWorkspace] { @@ -67,6 +70,7 @@ final class RecentWorkspaceStore { } func record(_ url: URL, at date: Date = Date(), limit: Int = 12) { + guard persistenceEnabled else { return } let standardizedURL = url.standardizedFileURL var entries = workspaces(limit: max(limit, 12)).map { PersistedWorkspace( diff --git a/Sources/MarginApp/WorkspaceWindowController.swift b/Sources/MarginApp/WorkspaceWindowController.swift index 145e08f..1941379 100644 --- a/Sources/MarginApp/WorkspaceWindowController.swift +++ b/Sources/MarginApp/WorkspaceWindowController.swift @@ -1072,7 +1072,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST private func configureInitialWindowFrame(_ window: NSWindow) { let frameName = "MarginWorkspaceWindow.v4.\(restorationIdentifier)" - let restored = window.setFrameUsingName(frameName) + let usesAutosavedLayout = !MarginRuntimeEnvironment.isPerformanceBenchmark + let restored = usesAutosavedLayout && window.setFrameUsingName(frameName) let restoredFrame = window.frame let restoredIsUsable = restored && restoredFrame.width >= 720 @@ -1093,7 +1094,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST // Version the frame key when the window layout changes materially so // an old, cramped frame does not become the new product default. - window.setFrameAutosaveName(frameName) + if usesAutosavedLayout { + window.setFrameAutosaveName(frameName) + } } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { @@ -1188,7 +1191,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST private func configureSplitView() { splitViewController.splitView.isVertical = true splitViewController.splitView.dividerStyle = .thin - splitViewController.splitView.autosaveName = "MarginWorkspaceSplitView" + if !MarginRuntimeEnvironment.isPerformanceBenchmark { + splitViewController.splitView.autosaveName = "MarginWorkspaceSplitView" + } navigatorItem.minimumThickness = 190 navigatorItem.maximumThickness = 350 diff --git a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift index 6a7372d..501cde7 100644 --- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift +++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift @@ -668,6 +668,24 @@ final class WorkspaceBehaviorTests: XCTestCase { XCTAssertEqual(store.urls(limit: 10), [first, second]) } + func testRecentWorkspaceStoreCanDisablePersistenceForBenchmarks() { + let suite = "margin-recents-disabled-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("Expected isolated defaults") + } + defer { defaults.removePersistentDomain(forName: suite) } + let store = RecentWorkspaceStore( + defaults: defaults, + key: "recents", + persistenceEnabled: false + ) + + store.record(URL(fileURLWithPath: "/tmp/benchmark.md")) + + XCTAssertNil(defaults.object(forKey: "recents")) + XCTAssertTrue(store.urls().isEmpty) + } + func testCollaborationOverviewIsLazyAndLoadsDurableActorActivityOnDemand() throws { let fixture = try makeDocument("# Collaboration\n\nA shared boundary.\n") defer { try? FileManager.default.removeItem(at: fixture.directory) } From dcf8a0cd36fde71fd3d59031b7b76520532bd694 Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:25:42 +0300 Subject: [PATCH 6/7] Document native Space restoration Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- Docs/RELEASE_NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Docs/RELEASE_NOTES.md b/Docs/RELEASE_NOTES.md index 7266c26..1f89532 100644 --- a/Docs/RELEASE_NOTES.md +++ b/Docs/RELEASE_NOTES.md @@ -2,6 +2,10 @@ ## Unreleased +- On current macOS, independent windows now return to their previous Spaces + without pulling the user away from the active Space. Reopening Margin keeps a + local window available on the current Space while restored windows remain in + place elsewhere. - Added durable untitled Markdown scratchpads. `⌘T` creates one in the current tab group, `⌘N` creates one in a separate window, and `⌘⇧S` promotes it to a named file. Scratch contents are private file-backed Markdown, autosave while From 7e7082d00c2f78c91a85ee9f2d28aabf803a7a73 Mon Sep 17 00:00:00 2001 From: jeanmonet <15112920+jeanmonet@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:24:34 +0300 Subject: [PATCH 7/7] Keep background Space changes passive Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com> --- Docs/RELEASE_NOTES.md | 6 +-- Sources/MarginApp/AppDelegate.swift | 58 +++-------------------------- 2 files changed, 8 insertions(+), 56 deletions(-) diff --git a/Docs/RELEASE_NOTES.md b/Docs/RELEASE_NOTES.md index 1f89532..b7fb0b9 100644 --- a/Docs/RELEASE_NOTES.md +++ b/Docs/RELEASE_NOTES.md @@ -3,9 +3,9 @@ ## Unreleased - On current macOS, independent windows now return to their previous Spaces - without pulling the user away from the active Space. Reopening Margin keeps a - local window available on the current Space while restored windows remain in - place elsewhere. + without pulling the user away from the active Space during cold launch. Once + running, ordinary Space changes perform no window creation or activation; + deliberate Dock or Finder activation follows native macOS app switching. - Added durable untitled Markdown scratchpads. `⌘T` creates one in the current tab group, `⌘N` creates one in a separate window, and `⌘⇧S` promotes it to a named file. Scratch contents are private file-backed Markdown, autosave while diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift index fed522e..9752056 100644 --- a/Sources/MarginApp/AppDelegate.swift +++ b/Sources/MarginApp/AppDelegate.swift @@ -206,7 +206,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private var isRestoringSession = false private var isSessionPersistScheduled = false private var terminationSession: WorkspaceSession? - private var preparedReopenController: WorkspaceWindowController? private lazy var spaceRestorationBackend = WorkspaceSpaceRestorationBackendFactory.make() override init() { @@ -250,12 +249,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { WorkspacePaneFactory.makeEditor = { EditorViewController() } WorkspacePaneFactory.makeComments = { CommentsViewController() } AppMenu.install(for: NSApplication.shared, delegate: self) - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(activeSpaceDidChange(_:)), - name: NSWorkspace.activeSpaceDidChangeNotification, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(windowScreenDidChange(_:)), @@ -320,14 +313,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { _ _: NSApplication, hasVisibleWindows _: Bool ) -> Bool { - if let preparedReopenController, - let preparedWindow = preparedReopenController.window, - windowIsOnActiveSpace(preparedWindow) { - self.preparedReopenController = nil - preparedWindow.alphaValue = 1 - preparedWindow.makeKeyAndOrderFront(nil) - preparedWindow.makeMain() - } else if let localWindow = activeSpaceDocumentWindow { + // Reopen is the only running-app event that may surface a window. + // In particular, never pre-create or key a window from an active-Space + // notification: doing so can steal focus from the app the user is + // switching to, and makes ordinary Space navigation observable work. + if let localWindow = activeSpaceDocumentWindow { localWindow.makeKeyAndOrderFront(nil) localWindow.makeMain() } else { @@ -349,16 +339,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { schedulePersistSession() } - @objc private func activeSpaceDidChange(_ notification: Notification) { - schedulePersistSession() - DispatchQueue.main.async { [weak self] in - self?.prepareCurrentSpaceForReopen() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.prepareCurrentSpaceForReopen() - } - } - @objc private func windowScreenDidChange(_ notification: Notification) { guard let window = notification.object as? NSWindow, window.windowController is WorkspaceWindowController else { return } @@ -772,34 +752,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { return window.isOnActiveSpace } - private func prepareCurrentSpaceForReopen() { - guard didFinishLaunching, !NSApplication.shared.isActive else { return } - - if let localWindow = activeSpaceDocumentWindow { - if localWindow !== preparedReopenController?.window { - discardPreparedReopenWindow() - } - localWindow.makeKey() - localWindow.makeMain() - return - } - - discardPreparedReopenWindow() - let controller = makeWorkspaceWindow(for: nil, ordersFront: false) - guard let window = controller.window else { return } - preparedReopenController = controller - window.alphaValue = 0 - window.orderFrontRegardless() - window.makeKey() - window.makeMain() - } - - private func discardPreparedReopenWindow() { - guard let controller = preparedReopenController else { return } - preparedReopenController = nil - controller.close() - } - private func open(_ urls: [URL]) { var seen = Set() var tabAnchor = activeTabAnchorWindow