From 59f54a1d887f77acaf0dd0cda29e23899b2c93dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Dec 2023 21:10:21 -0800 Subject: [PATCH 1/9] macos: initial window save/restore is working for frames only --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/AppDelegate.swift | 21 +++++ .../Terminal/TerminalController.swift | 15 ++++ .../Features/Terminal/TerminalManager.swift | 4 +- .../Terminal/TerminalRestorable.swift | 77 +++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalRestorable.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 641133f89..71877844a 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; @@ -95,6 +96,7 @@ A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ClipboardConfirmation.xib; sourceTree = ""; }; A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ A59630992AEE1C6400D64628 /* Terminal.xib */, A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, + A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, @@ -366,6 +369,7 @@ files = ( A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, + A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 41c6ea5de..f0e835d08 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -96,6 +96,12 @@ class AppDelegate: NSObject, // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) + + // TODO: make this configurable via ghostty + // reset to system defaults + //UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") + // force state save + UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") // Hook up updater menu menuCheckForUpdates?.target = updaterController @@ -293,6 +299,21 @@ class AppDelegate: NSObject, private func focusedSurface() -> ghostty_surface_t? { return terminalManager.focusedSurface?.surface } + + //MARK: - Restorable State + + /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + + func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) { + Self.logger.debug("application will save window state") + } + + func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) { + Self.logger.debug("application will restore window state") + } //MARK: - UNUserNotificationCenterDelegate diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7e200cef2..186c0a198 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -148,6 +148,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, override func windowDidLoad() { guard let window = window else { return } + // Setting all three of these is required for restoration to work. + window.isRestorable = true + window.restorationClass = TerminalWindowRestoration.self + window.identifier = .init(String(describing: TerminalWindowRestoration.self)) + // If window decorations are disabled, remove our title if (!ghostty.windowDecorations) { window.styleMask.remove(.titled) } @@ -250,6 +255,16 @@ class TerminalController: NSWindowController, NSWindowDelegate, self.relabelTabs() } + func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { + let data = TerminalRestorableState() + data.encode(with: state) + AppDelegate.logger.warning("state window del encode") + } + + func window(_ window: NSWindow, didDecodeRestorableState state: NSCoder) { + AppDelegate.logger.warning("state window del restore") + } + //MARK: - First Responder @IBAction func newWindow(_ sender: Any?) { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 24e742971..b5c76a8fd 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -139,7 +139,7 @@ class TerminalManager { } /// Creates a window controller, adds it to our managed list, and returns it. - private func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration?) -> TerminalController { + func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration?) -> TerminalController { // Initialize our controller to load the window let c = TerminalController(ghostty, withBaseConfig: base) @@ -162,7 +162,7 @@ class TerminalManager { return c } - private func removeWindow(_ controller: TerminalController) { + func removeWindow(_ controller: TerminalController) { // Remove it from our managed set guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return } let w = self.windows[idx] diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift new file mode 100644 index 000000000..6894bcb92 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -0,0 +1,77 @@ +import Cocoa + +/// The state stored for terminal window restoration. +class TerminalRestorableState: NSObject, NSSecureCoding { + public static var supportsSecureCoding = true + + static let coderKey = "state" + static let versionKey = "version" + static let version: Int = 1 + + override init() { + super.init() + } + + required init?(coder aDecoder: NSCoder) { + // If the version doesn't match then we can't decode. In the future we can perform + // version upgrading or something but for now we only have one version so we + // don't bother. + guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { + return nil + } + } + + func encode(with coder: NSCoder) { + coder.encode(Self.version, forKey: Self.versionKey) + coder.encode(self, forKey: Self.coderKey) + } +} + +/// The NSWindowRestoration implementation that is called when a terminal window needs to be restored. +/// The encoding of a terminal window is handled elsewhere (usually NSWindowDelegate). +class TerminalWindowRestoration: NSObject, NSWindowRestoration { + enum RestoreError: Error { + case delegateInvalid + case identifierUnknown + case stateDecodeFailed + case windowDidNotLoad + } + + static func restoreWindow( + withIdentifier identifier: NSUserInterfaceItemIdentifier, + state: NSCoder, + completionHandler: @escaping (NSWindow?, Error?) -> Void + ) { + // Verify the identifier is what we expect + guard identifier == .init(String(describing: Self.self)) else { + completionHandler(nil, RestoreError.identifierUnknown) + return + } + + // Decode the state. If we can't decode the state, then we can't restore. + guard let state = TerminalRestorableState(coder: state) else { + completionHandler(nil, RestoreError.stateDecodeFailed) + return + } + + // The app delegate is definitely setup by now. If it isn't our AppDelegate + // then something is royally fucked up but protect against it anyhow. + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { + completionHandler(nil, RestoreError.delegateInvalid) + return + } + + // The window creation has to go through our terminalManager so that it + // can be found for events from libghostty. This uses the low-level + // createWindow so that AppKit can place the window wherever it should + // be. + let c = appDelegate.terminalManager.createWindow(withBaseConfig: nil) + guard let window = c.window else { + completionHandler(nil, RestoreError.windowDidNotLoad) + return + } + + completionHandler(window, nil) + AppDelegate.logger.warning("state RESTORE") + } +} From f0bf0fd8885506b3360fd50cca0b07b12ab0e7f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Dec 2023 21:47:20 -0800 Subject: [PATCH 2/9] config: window-save-state --- macos/Sources/AppDelegate.swift | 22 +++++++++++-------- macos/Sources/Ghostty/AppState.swift | 14 ++++++++++-- src/config/Config.zig | 32 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index f0e835d08..fb10b2b1d 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -96,17 +96,14 @@ class AppDelegate: NSObject, // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) - - // TODO: make this configurable via ghostty - // reset to system defaults - //UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") - // force state save - UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") // Hook up updater menu menuCheckForUpdates?.target = updaterController menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) + // Initial config loading + configDidReload(ghostty) + // Let's launch our first window. We only do this if we have no other windows. It // is possible to have other windows if we're opening a URL since `application(_:openFile:)` // is called before this. @@ -114,9 +111,6 @@ class AppDelegate: NSObject, terminalManager.newWindow() } - // Initial config loading - configDidReload(ghostty) - // Register our service provider. This must happen after everything // else is initialized. NSApp.servicesProvider = ServiceProvider() @@ -339,6 +333,16 @@ class AppDelegate: NSObject, //MARK: - GhosttyAppStateDelegate func configDidReload(_ state: Ghostty.AppState) { + // Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows + // configuration. This is the only way to carefully control whether macOS invokes the + // state restoration system. + switch (ghostty.windowSaveState) { + case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") + case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") + case "default": fallthrough + default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") + } + // Config could change keybindings, so update everything that depends on that syncMenuShortcuts() terminalManager.relabelAllTabs() diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index ca457f211..32f22da59 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -58,9 +58,19 @@ extension Ghostty { var v = false; let key = "quit-after-last-window-closed" _ = ghostty_config_get(config, &v, key, UInt(key.count)) - return v; + return v } - + + /// window-save-state + var windowSaveState: String { + guard let config = self.config else { return "" } + var v: UnsafePointer? = nil + let key = "window-save-state" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard let ptr = v else { return "" } + return String(cString: ptr) + } + /// True if we need to confirm before quitting. var needsConfirmQuit: Bool { guard let app = app else { return false } diff --git a/src/config/Config.zig b/src/config/Config.zig index 0a019b70d..c74fbc990 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -563,6 +563,31 @@ keybind: Keybinds = .{}, @"window-height": u32 = 0, @"window-width": u32 = 0, +/// Whether to enable saving and restoring window state. Window state +/// includes their position, size, tabs, splits, etc. Some window state +/// requires shell integration, such as preserving working directories. +/// See shell-integration for more information. +/// +/// There are three valid values for this configuration: +/// - "default" will use the default system behavior. On macOS, this +/// will only save state if the application is forcibly terminated +/// or if it is configured systemwide via Settings.app. +/// - "never" will never save window state. +/// - "always" will always save window state whenever Ghostty is exited. +/// +/// If you change this value so that window state is NOT saved while +/// window state is already saved, the next Ghostty launch will NOT restore +/// the window state. +/// +/// If you change this value so that window state is saved while Ghostty +/// is not running, the previous window state will not be restored because +/// Ghostty only saves state on exit if this is enabled. +/// +/// The default value is "default". +/// +/// This is currently only supported on macOS. This has no effect on Linux. +@"window-save-state": WindowSaveState = .always, // TODO: change before PR + /// Resize the window in discrete increments of the focused surface's /// cell size. If this is disabled, surfaces are resized in pixel increments. /// Currently only supported on macOS. @@ -2744,3 +2769,10 @@ pub const ClipboardAccess = enum { deny, ask, }; + +/// See window-save-state +pub const WindowSaveState = enum { + default, + never, + always, +}; From a5d249eb48eebdca81a86799f29a7ef1292b41b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Dec 2023 21:58:29 -0800 Subject: [PATCH 3/9] config: if window-save-state is never, never restore state --- .../Terminal/TerminalRestorable.swift | 19 +++++++++++++------ src/config/Config.zig | 11 ++++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 6894bcb92..7e3dcb6e5 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -48,12 +48,6 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - // Decode the state. If we can't decode the state, then we can't restore. - guard let state = TerminalRestorableState(coder: state) else { - completionHandler(nil, RestoreError.stateDecodeFailed) - return - } - // The app delegate is definitely setup by now. If it isn't our AppDelegate // then something is royally fucked up but protect against it anyhow. guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { @@ -61,6 +55,19 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } + // If our configuration is "never" then we never restore the state + // no matter what. + if (appDelegate.terminalManager.ghostty.windowSaveState == "never") { + completionHandler(nil, nil) + return + } + + // Decode the state. If we can't decode the state, then we can't restore. + guard let state = TerminalRestorableState(coder: state) else { + completionHandler(nil, RestoreError.stateDecodeFailed) + return + } + // The window creation has to go through our terminalManager so that it // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should diff --git a/src/config/Config.zig b/src/config/Config.zig index c74fbc990..c7ca27744 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -575,9 +575,14 @@ keybind: Keybinds = .{}, /// - "never" will never save window state. /// - "always" will always save window state whenever Ghostty is exited. /// -/// If you change this value so that window state is NOT saved while -/// window state is already saved, the next Ghostty launch will NOT restore -/// the window state. +/// If you change this value to "never" while Ghostty is not running, +/// the next Ghostty launch will NOT restore the window state. +/// +/// If you change this value to "default" while Ghostty is not running +/// and the previous exit saved state, the next Ghostty launch will +/// still restore the window state. This is because Ghostty cannot know +/// if the previous exit was due to a forced save or not (macOS doesn't +/// provide this information). /// /// If you change this value so that window state is saved while Ghostty /// is not running, the previous window state will not be restored because From 23906688344e62d3748c83aed34aa22bcc26057f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 12:59:45 -0800 Subject: [PATCH 4/9] macos: encode surface tree in state restore --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/AppDelegate.swift | 2 +- .../Terminal/TerminalController.swift | 9 +- .../Terminal/TerminalRestorable.swift | 50 ++++++----- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 84 ++++++++++++++++++- macos/Sources/Helpers/CodableBridge.swift | 25 ++++++ .../Sources/Helpers/SplitView/SplitView.swift | 2 +- 7 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 macos/Sources/Helpers/CodableBridge.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 71877844a..2e01d611f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; + A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; @@ -97,6 +98,7 @@ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; + A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ClipboardConfirmation.xib; sourceTree = ""; }; A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; @@ -153,6 +155,7 @@ isa = PBXGroup; children = ( A5CEAFFE29C2410700646FDA /* Backport.swift */, + A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, @@ -369,6 +372,7 @@ files = ( A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, + A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index fb10b2b1d..48dcc21ec 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -58,7 +58,7 @@ class AppDelegate: NSObject, private var dockMenu: NSMenu = NSMenu() /// The ghostty global state. Only one per process. - private let ghostty: Ghostty.AppState = Ghostty.AppState() + let ghostty: Ghostty.AppState = Ghostty.AppState() /// Manages our terminal windows. let terminalManager: TerminalManager diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 186c0a198..e2ba359ad 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -255,14 +255,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, self.relabelTabs() } + // Called when the window will be encoded. We handle the data encoding here in the + // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { - let data = TerminalRestorableState() + let data = TerminalRestorableState(from: self) data.encode(with: state) - AppDelegate.logger.warning("state window del encode") - } - - func window(_ window: NSWindow, didDecodeRestorableState state: NSCoder) { - AppDelegate.logger.warning("state window del restore") } //MARK: - First Responder diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 7e3dcb6e5..331806149 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -1,42 +1,48 @@ import Cocoa /// The state stored for terminal window restoration. -class TerminalRestorableState: NSObject, NSSecureCoding { - public static var supportsSecureCoding = true - - static let coderKey = "state" +class TerminalRestorableState: Codable { + static let selfKey = "state" static let versionKey = "version" - static let version: Int = 1 + static let version: Int = 3 - override init() { - super.init() + let surfaceTree: Ghostty.SplitNode? + + init(from controller: TerminalController) { + self.surfaceTree = controller.surfaceTree } - required init?(coder aDecoder: NSCoder) { + init?(coder aDecoder: NSCoder) { // If the version doesn't match then we can't decode. In the future we can perform // version upgrading or something but for now we only have one version so we // don't bother. guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { return nil } + + guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { + return nil + } + + self.surfaceTree = v.value.surfaceTree } func encode(with coder: NSCoder) { coder.encode(Self.version, forKey: Self.versionKey) - coder.encode(self, forKey: Self.coderKey) + coder.encode(CodableBridge(self), forKey: Self.selfKey) } } +enum TerminalRestoreError: Error { + case delegateInvalid + case identifierUnknown + case stateDecodeFailed + case windowDidNotLoad +} + /// The NSWindowRestoration implementation that is called when a terminal window needs to be restored. /// The encoding of a terminal window is handled elsewhere (usually NSWindowDelegate). class TerminalWindowRestoration: NSObject, NSWindowRestoration { - enum RestoreError: Error { - case delegateInvalid - case identifierUnknown - case stateDecodeFailed - case windowDidNotLoad - } - static func restoreWindow( withIdentifier identifier: NSUserInterfaceItemIdentifier, state: NSCoder, @@ -44,14 +50,14 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { ) { // Verify the identifier is what we expect guard identifier == .init(String(describing: Self.self)) else { - completionHandler(nil, RestoreError.identifierUnknown) + completionHandler(nil, TerminalRestoreError.identifierUnknown) return } // The app delegate is definitely setup by now. If it isn't our AppDelegate // then something is royally fucked up but protect against it anyhow. guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { - completionHandler(nil, RestoreError.delegateInvalid) + completionHandler(nil, TerminalRestoreError.delegateInvalid) return } @@ -64,7 +70,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Decode the state. If we can't decode the state, then we can't restore. guard let state = TerminalRestorableState(coder: state) else { - completionHandler(nil, RestoreError.stateDecodeFailed) + completionHandler(nil, TerminalRestoreError.stateDecodeFailed) return } @@ -74,11 +80,13 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // be. let c = appDelegate.terminalManager.createWindow(withBaseConfig: nil) guard let window = c.window else { - completionHandler(nil, RestoreError.windowDidNotLoad) + completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } + // Setup our restored state on the controller + c.surfaceTree = state.surfaceTree + completionHandler(window, nil) - AppDelegate.logger.warning("state RESTORE") } } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 13e742441..fba43443a 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -11,10 +11,33 @@ extension Ghostty { /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These /// values can further be split infinitely. /// - enum SplitNode: Equatable, Hashable { + enum SplitNode: Equatable, Hashable, Codable { case leaf(Leaf) case split(Container) + /// The parent of this node. + var parent: Container? { + get { + switch (self) { + case .leaf(let leaf): + return leaf.parent + + case .split(let container): + return container.parent + } + } + + set { + switch (self) { + case .leaf(let leaf): + leaf.parent = newValue + + case .split(let container): + container.parent = newValue + } + } + } + /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the /// next view to send focus to. @@ -91,7 +114,7 @@ extension Ghostty { } } - class Leaf: ObservableObject, Equatable, Hashable { + class Leaf: ObservableObject, Equatable, Hashable, Codable { let app: ghostty_app_t @Published var surface: SurfaceView @@ -115,9 +138,27 @@ extension Ghostty { static func == (lhs: Leaf, rhs: Leaf) -> Bool { return lhs.app == rhs.app && lhs.surface === rhs.surface } + + // MARK: - Codable + + required convenience init(from decoder: Decoder) throws { + // Decoding uses the global Ghostty app + guard let del = NSApplication.shared.delegate, + let appDel = del as? AppDelegate, + let app = appDel.ghostty.app else { + throw TerminalRestoreError.delegateInvalid + } + + self.init(app, nil) + } + + func encode(to encoder: Encoder) throws { + // We don't currently encode anything, but in the future we will + // want to encode pwd, etc... + } } - class Container: ObservableObject, Equatable, Hashable { + class Container: ObservableObject, Equatable, Hashable, Codable { let app: ghostty_app_t let direction: SplitViewDirection @@ -213,6 +254,43 @@ extension Ghostty { lhs.topLeft == rhs.topLeft && lhs.bottomRight == rhs.bottomRight } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case direction + case split + case topLeft + case bottomRight + } + + required init(from decoder: Decoder) throws { + // Decoding uses the global Ghostty app + guard let del = NSApplication.shared.delegate, + let appDel = del as? AppDelegate, + let app = appDel.ghostty.app else { + throw TerminalRestoreError.delegateInvalid + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.app = app + self.direction = try container.decode(SplitViewDirection.self, forKey: .direction) + self.split = try container.decode(CGFloat.self, forKey: .split) + self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft) + self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight) + + // Fix up the parent references + self.topLeft.parent = self + self.bottomRight.parent = self + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(direction, forKey: .direction) + try container.encode(split, forKey: .split) + try container.encode(topLeft, forKey: .topLeft) + try container.encode(bottomRight, forKey: .bottomRight) + } } /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right diff --git a/macos/Sources/Helpers/CodableBridge.swift b/macos/Sources/Helpers/CodableBridge.swift new file mode 100644 index 000000000..171252dcf --- /dev/null +++ b/macos/Sources/Helpers/CodableBridge.swift @@ -0,0 +1,25 @@ +import Cocoa + +/// A wrapper that allows a Swift Codable to implement NSSecureCoding. +class CodableBridge: NSObject, NSSecureCoding { + let value: Wrapped + init(_ value: Wrapped) { self.value = value } + + static var supportsSecureCoding: Bool { return true } + + required init?(coder aDecoder: NSCoder) { + // TODO: This outputs a warning with deprecation on decode. I don't know how to + // fix that yet but there must be something we can change with the encode/decode here + // to resolve it. + guard let data = aDecoder.decodeData() else { return nil } + guard let archiver = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } + guard let value = archiver.decodeDecodable(Wrapped.self, forKey: "value") else { return nil } + self.value = value + } + + func encode(with aCoder: NSCoder) { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + try? archiver.encodeEncodable(value, forKey: "value") + aCoder.encode(archiver.encodedData) + } +} diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift index 52fe52760..a03274fe6 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.swift @@ -163,6 +163,6 @@ struct SplitView: View { } } -enum SplitViewDirection { +enum SplitViewDirection: Codable { case horizontal, vertical } From 243379c50f4e78dd597d3a33f01a95564d4eb2b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 16:59:09 -0800 Subject: [PATCH 5/9] apprt/embedded: ghostty_surface_pwd --- include/ghostty.h | 1 + src/apprt/embedded.zig | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 0b70e2549..6b058d78c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -460,6 +460,7 @@ void ghostty_surface_split_resize(ghostty_surface_t, ghostty_split_resize_direct void ghostty_surface_split_equalize(ghostty_surface_t); bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, void *, bool); +uintptr_t ghostty_surface_pwd(ghostty_surface_t, char *, uintptr_t); ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); void ghostty_inspector_free(ghostty_surface_t); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 724eb395f..9b79fefca 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1355,6 +1355,25 @@ pub const CAPI = struct { return surface.core_surface.needsConfirmQuit(); } + /// Copies the surface working directory into the provided buffer and + /// returns the copied size. If the buffer is too small, there is no pwd, + /// or there is an error, then 0 is returned. + export fn ghostty_surface_pwd(surface: *Surface, buf: [*]u8, cap: usize) usize { + const pwd_ = surface.core_surface.pwd(global.alloc) catch |err| { + log.warn("error getting pwd err={}", .{err}); + return 0; + }; + const pwd = pwd_ orelse return 0; + defer global.alloc.free(pwd); + + // If the buffer is too small, return no pwd. + if (pwd.len > cap) return 0; + + // Copy into the buffer and return the length + @memcpy(buf[0..pwd.len], pwd); + return pwd.len; + } + /// Tell the surface that it needs to schedule a render export fn ghostty_surface_refresh(surface: *Surface) void { surface.refresh(); From a8568306c92eeba7cbad211b871175a1add621cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 17:06:34 -0800 Subject: [PATCH 6/9] macos: store pwd with save/restore state --- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 14 +++++++++++--- macos/Sources/Ghostty/SurfaceView.swift | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index fba43443a..35240b5fa 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -141,6 +141,10 @@ extension Ghostty { // MARK: - Codable + enum CodingKeys: String, CodingKey { + case pwd + } + required convenience init(from decoder: Decoder) throws { // Decoding uses the global Ghostty app guard let del = NSApplication.shared.delegate, @@ -149,12 +153,16 @@ extension Ghostty { throw TerminalRestoreError.delegateInvalid } - self.init(app, nil) + let container = try decoder.container(keyedBy: CodingKeys.self) + var config = SurfaceConfiguration() + config.workingDirectory = try container.decode(String?.self, forKey: .pwd) + + self.init(app, config) } func encode(to encoder: Encoder) throws { - // We don't currently encode anything, but in the future we will - // want to encode pwd, etc... + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(surface.pwd, forKey: .pwd) } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index a75cbe255..6ad8122a6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -283,6 +283,17 @@ extension Ghostty { return ghostty_surface_needs_confirm_quit(surface) } + /// Returns the pwd of the surface if it has one. + var pwd: String? { + guard let surface = self.surface else { return nil } + let v = String(unsafeUninitializedCapacity: 1024) { + Int(ghostty_surface_pwd(surface, $0.baseAddress, UInt($0.count))) + } + + if (v.count == 0) { return nil } + return v + } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { From c3b89fa215d7e936d5ccf6ae2e03f1a44c049775 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 18:49:21 -0800 Subject: [PATCH 7/9] macos: use securecoding for codablebridge to prevent warning Thanks Matt Polzin! --- macos/Sources/Helpers/CodableBridge.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Helpers/CodableBridge.swift b/macos/Sources/Helpers/CodableBridge.swift index 171252dcf..7f0655f83 100644 --- a/macos/Sources/Helpers/CodableBridge.swift +++ b/macos/Sources/Helpers/CodableBridge.swift @@ -8,10 +8,7 @@ class CodableBridge: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool { return true } required init?(coder aDecoder: NSCoder) { - // TODO: This outputs a warning with deprecation on decode. I don't know how to - // fix that yet but there must be something we can change with the encode/decode here - // to resolve it. - guard let data = aDecoder.decodeData() else { return nil } + guard let data = aDecoder.decodeObject(of: NSData.self, forKey: "data") as? Data else { return nil } guard let archiver = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } guard let value = archiver.decodeDecodable(Wrapped.self, forKey: "value") else { return nil } self.value = value @@ -20,6 +17,6 @@ class CodableBridge: NSObject, NSSecureCoding { func encode(with aCoder: NSCoder) { let archiver = NSKeyedArchiver(requiringSecureCoding: true) try? archiver.encodeEncodable(value, forKey: "value") - aCoder.encode(archiver.encodedData) + aCoder.encode(archiver.encodedData, forKey: "data") } } From f615a308e0847074d5cc495141ef7398dc57afc4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 18:52:40 -0800 Subject: [PATCH 8/9] config: change window-save-state to default --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index c7ca27744..9982cacef 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -591,7 +591,7 @@ keybind: Keybinds = .{}, /// The default value is "default". /// /// This is currently only supported on macOS. This has no effect on Linux. -@"window-save-state": WindowSaveState = .always, // TODO: change before PR +@"window-save-state": WindowSaveState = .default, /// Resize the window in discrete increments of the focused surface's /// cell size. If this is disabled, surfaces are resized in pixel increments. From 20f9a3baab6b011d2c7b24ccd3e4ef6c320d276a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 18:59:19 -0800 Subject: [PATCH 9/9] macos: reset state version to 1 --- macos/Sources/Features/Terminal/TerminalRestorable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 331806149..68695f5ee 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,7 +4,7 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 3 + static let version: Int = 1 let surfaceTree: Ghostty.SplitNode?