import Cocoa protocol TerminalRestorable: Codable { static var selfKey: String { get } static var versionKey: String { get } static var version: Int { get } /// Minimum version that can be decoded safely static var minimumVersion: Int { get } init(copy other: Self) /// Returns a base configuration to use when restoring terminal surfaces. /// Override this to provide custom environment variables or other configuration. var baseConfig: Ghostty.SurfaceConfiguration? { get } } extension TerminalRestorable { static var minimumVersion: Int { version } } extension TerminalRestorable { static var selfKey: String { "state" } static var versionKey: String { "version" } private var debugDescription: String { withUnsafePointer(to: self) { ptr in "<\(ptr)>[version: \(Self.version)]" } } /// Default implementation returns nil (no custom base config). var baseConfig: Ghostty.SurfaceConfiguration? { nil } 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. let current = aDecoder.decodeInteger(forKey: Self.versionKey) guard current >= Self.minimumVersion else { AppDelegate.logger.error("error restoring terminal: version not supported: expected=\(Self.minimumVersion, privacy: .public), got=\(current, privacy: .public)") return nil } guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { AppDelegate.logger.error("error restoring terminal: decode failed") return nil } self.init(copy: v.value) } func encode(with coder: NSCoder) { coder.encode(Self.version, forKey: Self.versionKey) coder.encode(CodableBridge(self), forKey: Self.selfKey) AppDelegate.logger.debug("saved terminal state: \(debugDescription)") } } /// The state stored for terminal window restoration. final class TerminalRestorableState: TerminalRestorable { static var version: Int { 7 } static var minimumVersion: Int { 5 } var focusedSurface: String? { internalState.focusedSurface } var surfaceTree: SplitTree { internalState.surfaceTree } var effectiveFullscreenMode: FullscreenMode? { internalState.effectiveFullscreenMode } var tabColor: TerminalTabColor? { internalState.tabColor } var titleOverride: String? { internalState.titleOverride } /// Internal State we use to perform unit tests /// /// Since we can't really change the type of `TerminalRestorableState` /// due to `CodableBridge` supporting secure coding, /// we use an internal type to perform migration and tests private let internalState: InternalState init(from controller: TerminalController) { internalState = .init(from: controller) } required init(copy other: TerminalRestorableState) { self.internalState = other.internalState } /// This is just wrapper around internalState /// /// - Important: If you intend to add more things, go to `InternalState`. init(from decoder: any Decoder) throws { self.internalState = try InternalState(from: decoder) } /// This is just wrapper around internalState /// /// - Important: If you intend to add more things, go to `InternalState`. func encode(to encoder: any Encoder) throws { try internalState.encode(to: encoder) } } 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 { 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, 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, TerminalRestoreError.delegateInvalid) return } // If our configuration is "never" then we never restore the state // no matter what. Note its safe to use "ghostty.config" directly here // because window restoration is only ever invoked on app start so we // don't have to deal with config reloads. if appDelegate.ghostty.config.windowSaveState == "never" { AppDelegate.logger.warning("skip restoration: window-save-state=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, TerminalRestoreError.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 // be. let c = TerminalController.init( appDelegate.ghostty, withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } // Restore our tab color and avoid unnecessary `invalidateRestorableState` calls if let tabColor = state.tabColor { (window as? TerminalWindow)?.tabColor = tabColor } // Restore the tab title override c.titleOverride = state.titleOverride // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? for view in c.surfaceTree where view.id.uuidString == focusedStr { foundView = view break } if let view = foundView { c.focusedSurface = view restoreFocus(to: view, inWindow: window) } } completionHandler(window, nil) guard let mode = state.effectiveFullscreenMode, mode != .native else { // We let AppKit handle native fullscreen return } // Give the window to AppKit first, then adjust its frame and style // to minimise any visible frame changes. c.toggleFullscreen(mode: mode) } /// This restores the focus state of the surfaceview within the given window. When restoring, /// the view isn't immediately attached to the window since we have to wait for SwiftUI to /// catch up. Therefore, we sit in an async loop waiting for the attachment to happen. private static func restoreFocus(to: Ghostty.SurfaceView, inWindow: NSWindow, attempts: Int = 0) { // For the first attempt, we schedule it immediately. Subsequent events wait a bit // so we don't just spin the CPU at 100%. Give up after some period of time. let after: DispatchTime if attempts == 0 { after = .now() } else if attempts > 40 { // 2 seconds, give up return } else { after = .now() + .milliseconds(50) } DispatchQueue.main.asyncAfter(deadline: after) { // If the view is not attached to a window yet then we repeat. guard let viewWindow = to.window else { restoreFocus(to: to, inWindow: inWindow, attempts: attempts + 1) return } // If the view is attached to some other window, we give up guard viewWindow == inWindow else { return } inWindow.makeFirstResponder(to) // If the window is main, then we also make sure it comes forward. This // prevents a bug found in #1177 where sometimes on restore the windows // would be behind other applications. if viewWindow.isMainWindow { viewWindow.orderFront(nil) } } } }