From 23906688344e62d3748c83aed34aa22bcc26057f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Dec 2023 12:59:45 -0800 Subject: [PATCH] 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 }