import Cocoa /// The state stored for terminal window restoration. class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" static let version: Int = 2 let focusedSurface: String? let surfaceTree: Ghostty.SplitNode? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString self.surfaceTree = controller.surfaceTree } 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 self.focusedSurface = v.value.focusedSurface } func encode(with coder: NSCoder) { coder.encode(Self.version, forKey: Self.versionKey) 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 { 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. 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, 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 = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } // Setup our restored state on the controller if let focusedStr = state.focusedSurface, let focusedUUID = UUID(uuidString: focusedStr), let view = c.surfaceTree?.findUUID(uuid: focusedUUID) { c.focusedSurface = view restoreFocus(to: view, inWindow: window) } completionHandler(window, nil) } /// 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) } } } }