macOS: support migrations when restoring window state (#12461)

First two commits fix the issue when upgrading from 1.2.x to 1.3.x.
(#11304)

> To double check if this pr really fixes the issue, you can either
archive a release build, sign with the same profile, and override
manually.
> 
> Or you can find the `savedState` files (located in `~/Library/Daemon\
Containers/<uuid>`), can copy them the local build dir (which is what I
did), and run the debug build.

Following commits add tests for migrations and some logs.

**Currently the minimum version is set to 1.2.x**, since there's a lot
changes comparing to 1.1.x. It will be difficult to restore
`Ghostty.SplitNode` -> `SplitTree<Ghostty.SurfaceView>` without
introducing a lot of checks.
This commit is contained in:
Mitchell Hashimoto
2026-04-25 13:15:50 -07:00
committed by GitHub
7 changed files with 379 additions and 29 deletions

View File

@@ -859,8 +859,6 @@ class AppDelegate: NSObject,
}
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
Self.logger.debug("application will save window state")
guard ghostty.config.windowSaveState != "never" else { return }
// Encode our quick terminal state if we have it.

View File

@@ -3,15 +3,23 @@ import Cocoa
struct QuickTerminalRestorableState: TerminalRestorable {
static var version: Int { 1 }
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let screenStateEntries: QuickTerminalScreenStateCache.Entries
var focusedSurface: String? {
internalState.focusedSurface
}
var surfaceTree: SplitTree<Ghostty.SurfaceView> {
internalState.surfaceTree
}
var screenStateEntries: QuickTerminalScreenStateCache.Entries {
internalState.screenStateEntries
}
private let internalState: InternalState<Ghostty.SurfaceView>
init(from controller: QuickTerminalController) {
controller.saveScreenState(exitFullscreen: true)
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.screenStateEntries = controller.screenStateCache.stateByDisplay
self.internalState = .init(from: controller)
}
init(copy other: QuickTerminalRestorableState) {
@@ -24,3 +32,37 @@ struct QuickTerminalRestorableState: TerminalRestorable {
return config
}
}
extension QuickTerminalRestorableState {
/// Internal State we use to perform unit tests
///
/// Since we can't really change the type of `QuickTerminalRestorableState`
/// due to `CodableBridge<QuickTerminalRestorableState>` supporting secure coding,
/// we use an internal type to perform migration and tests
struct InternalState<ViewType: NSView & Codable & Identifiable>: Codable {
// MARK: - Version 1 (1.3.0)
let focusedSurface: String?
let surfaceTree: SplitTree<ViewType>
let screenStateEntries: QuickTerminalScreenStateCache.Entries
init(
focusedSurface: String?,
surfaceTree: SplitTree<ViewType>,
screenStateEntries: QuickTerminalScreenStateCache.Entries,
) {
self.focusedSurface = focusedSurface
self.surfaceTree = surfaceTree
self.screenStateEntries = screenStateEntries
}
}
}
extension QuickTerminalRestorableState.InternalState where ViewType == Ghostty.SurfaceView {
init(from controller: QuickTerminalController) {
self.init(
focusedSurface: controller.focusedSurface?.id.uuidString,
surfaceTree: controller.surfaceTree,
screenStateEntries: controller.screenStateCache.stateByDisplay,
)
}
}

View File

@@ -4,6 +4,8 @@ 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.
@@ -11,10 +13,20 @@ protocol TerminalRestorable: Codable {
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 }
@@ -22,11 +34,14 @@ extension TerminalRestorable {
// 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 {
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>.self, forKey: Self.selfKey) else {
AppDelegate.logger.error("error restoring terminal: decode failed")
return nil
}
@@ -36,33 +51,59 @@ extension TerminalRestorable {
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.
class TerminalRestorableState: TerminalRestorable {
class var version: Int { 7 }
final class TerminalRestorableState: TerminalRestorable {
static var version: Int { 7 }
static var minimumVersion: Int { 5 }
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let effectiveFullscreenMode: FullscreenMode?
let tabColor: TerminalTabColor
let titleOverride: String?
var focusedSurface: String? {
internalState.focusedSurface
}
var surfaceTree: SplitTree<Ghostty.SurfaceView> {
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<TerminalRestorableState>` supporting secure coding,
/// we use an internal type to perform migration and tests
private let internalState: InternalState<Ghostty.SurfaceView>
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
self.titleOverride = controller.titleOverride
internalState = .init(from: controller)
}
required init(copy other: TerminalRestorableState) {
self.surfaceTree = other.surfaceTree
self.focusedSurface = other.focusedSurface
self.effectiveFullscreenMode = other.effectiveFullscreenMode
self.tabColor = other.tabColor
self.titleOverride = other.titleOverride
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<Ghostty.SurfaceView>(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)
}
}
@@ -99,6 +140,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
// 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
}
@@ -121,8 +163,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
return
}
// Restore our tab color
(window as? TerminalWindow)?.tabColor = state.tabColor
// 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

View File

@@ -0,0 +1,45 @@
import AppKit
extension TerminalRestorableState {
/// Internal State we use to perform unit tests
///
/// Since we can't really change the type of `TerminalRestorableState`
/// due to `CodableBridge<TerminalRestorableState>` supporting secure coding,
/// we use an internal type to perform migration and tests
struct InternalState<ViewType: NSView & Codable & Identifiable>: Codable {
// MARK: - Version 5 (1.2.3)
let focusedSurface: String?
let surfaceTree: SplitTree<ViewType>
// MARK: - Version 7 (1.3.0)
let effectiveFullscreenMode: FullscreenMode?
let tabColor: TerminalTabColor?
let titleOverride: String?
init(
focusedSurface: String?,
surfaceTree: SplitTree<ViewType>,
effectiveFullscreenMode: FullscreenMode?,
tabColor: TerminalTabColor?,
titleOverride: String?,
) {
self.focusedSurface = focusedSurface
self.surfaceTree = surfaceTree
self.effectiveFullscreenMode = effectiveFullscreenMode
self.tabColor = tabColor
self.titleOverride = titleOverride
}
}
}
extension TerminalRestorableState.InternalState where ViewType == Ghostty.SurfaceView {
init(from controller: TerminalController) {
self.init(
focusedSurface: controller.focusedSurface?.id.uuidString,
surfaceTree: controller.surfaceTree,
effectiveFullscreenMode: controller.fullscreenStyle?.fullscreenMode,
tabColor: (controller.window as? TerminalWindow)?.tabColor,
titleOverride: controller.titleOverride,
)
}
}