-
-
Notifications
You must be signed in to change notification settings - Fork 291
feat(coordinator): restore session view state and recover after crashes (#1673) #1680
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aa3e562
05b15a5
0696de7
f156fc3
51d0684
2862ec6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -125,10 +125,46 @@ internal final class AppLaunchCoordinator { | |
| guard intents.isEmpty else { return } | ||
|
|
||
| let general = AppSettingsStorage.shared.loadGeneral() | ||
| if general.startupBehavior == .showWelcome { | ||
| switch general.startupBehavior { | ||
| case .showWelcome: | ||
| for window in NSApp.windows where Self.isMainWindow(window) { | ||
| window.close() | ||
| } | ||
| case .reopenLast: | ||
| reopenLastSessionIfArchiveMissing() | ||
| } | ||
| } | ||
|
|
||
| private func reopenLastSessionIfArchiveMissing() { | ||
| guard !NSApp.windows.contains(where: { Self.isMainWindow($0) }) else { return } | ||
|
|
||
| let connectionIds = LastOpenConnectionsStorage.shared.load() | ||
| guard !connectionIds.isEmpty else { return } | ||
|
|
||
| let connectionsById = Dictionary( | ||
| ConnectionStorage.shared.loadConnections().map { ($0.id, $0) }, | ||
| uniquingKeysWith: { first, _ in first } | ||
| ) | ||
| var openedAny = false | ||
| for connectionId in connectionIds { | ||
| guard let connection = connectionsById[connectionId] else { continue } | ||
| WindowManager.shared.openTab( | ||
| payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault) | ||
| ) | ||
|
Comment on lines
+151
to
+153
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the crash-recovery path where AppKit restored no windows, Useful? React with 👍 / 👎. |
||
| openedAny = true | ||
| Task { | ||
| do { | ||
| try await DatabaseManager.shared.ensureConnected(connection) | ||
| } catch { | ||
| Self.logger.error( | ||
| "[restore] reopen connect failed for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)" | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if openedAny { | ||
| WindowOpener.shared.orderOutWelcome() | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| // | ||
| // RestorationGroupRegistry.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| @MainActor | ||
| enum RestorationGroupRegistry { | ||
| struct WindowGroup { | ||
| let tabs: [QueryTab] | ||
| let selectedTabId: UUID? | ||
| } | ||
|
|
||
| private static var groups: [UUID: WindowGroup] = [:] | ||
| private static let entryLifetime: Duration = .seconds(10) | ||
|
|
||
| static func register(_ group: WindowGroup, for payloadId: UUID) { | ||
| groups[payloadId] = group | ||
| Task { @MainActor in | ||
| try? await Task.sleep(for: entryLifetime) | ||
| groups.removeValue(forKey: payloadId) | ||
| } | ||
| } | ||
|
|
||
| static func consume(for payloadId: UUID?) -> WindowGroup? { | ||
| guard let payloadId else { return nil } | ||
| return groups.removeValue(forKey: payloadId) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ internal struct RestoreResult { | |
| let tabs: [QueryTab] | ||
| let selectedTabId: UUID? | ||
| let source: RestoreSource | ||
| var lastActiveDatabase: String? = nil | ||
| var lastActiveSchema: String? = nil | ||
|
|
||
| enum RestoreSource { | ||
| case disk | ||
|
|
@@ -32,29 +34,53 @@ internal final class TabPersistenceCoordinator { | |
| // MARK: - Save | ||
|
|
||
| internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) { | ||
| let nonPreviewTabs = tabs.filter { !$0.isPreview } | ||
| guard !nonPreviewTabs.isEmpty else { | ||
| saveNow(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId) | ||
| } | ||
|
|
||
| internal func saveNow(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { | ||
| guard !windowedTabs.isEmpty else { | ||
| clearSavedState() | ||
| return | ||
| } | ||
| let persisted = nonPreviewTabs.map { convertToPersistedTab($0) } | ||
| let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) | ||
| ? selectedTabId : nonPreviewTabs.first?.id | ||
| scheduleSave(tabs: persisted, selectedTabId: normalizedSelectedId) | ||
| let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When persistence runs while a temporary preview tab is open, this now serializes every Useful? React with 👍 / 👎. |
||
| let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId }) | ||
| ? selectedTabId : windowedTabs.first?.tab.id | ||
| let active = currentActiveDatabaseAndSchema() | ||
| scheduleSave( | ||
| tabs: persisted, | ||
| selectedTabId: normalizedSelectedId, | ||
| lastActiveDatabase: active.database, | ||
| lastActiveSchema: active.schema | ||
| ) | ||
| } | ||
|
|
||
| internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) { | ||
| let nonPreviewTabs = tabs.filter { !$0.isPreview } | ||
| guard !nonPreviewTabs.isEmpty else { | ||
| saveNowSync(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId) | ||
| } | ||
|
|
||
| internal func saveNowSync(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { | ||
| guard !windowedTabs.isEmpty else { | ||
| saveTask?.cancel() | ||
| saveTask = nil | ||
| TabDiskActor.clearSync(connectionId: connectionId) | ||
| return | ||
| } | ||
| let persisted = nonPreviewTabs.map { convertToPersistedTab($0) } | ||
| let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) | ||
| ? selectedTabId : nonPreviewTabs.first?.id | ||
| TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: normalizedSelectedId) | ||
| let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } | ||
| let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId }) | ||
| ? selectedTabId : windowedTabs.first?.tab.id | ||
| let active = currentActiveDatabaseAndSchema() | ||
| TabDiskActor.saveSync( | ||
| connectionId: connectionId, | ||
| tabs: persisted, | ||
| selectedTabId: normalizedSelectedId, | ||
| lastActiveDatabase: active.database, | ||
| lastActiveSchema: active.schema | ||
| ) | ||
| } | ||
|
|
||
| private func currentActiveDatabaseAndSchema() -> (database: String?, schema: String?) { | ||
| guard let session = DatabaseManager.shared.session(for: connectionId) else { return (nil, nil) } | ||
| return (session.currentDatabase, session.currentSchema) | ||
| } | ||
|
|
||
| // MARK: - Clear | ||
|
|
@@ -70,18 +96,31 @@ internal final class TabPersistenceCoordinator { | |
|
|
||
| // MARK: - Private save scheduling | ||
|
|
||
| private func scheduleSave(tabs: [PersistedTab], selectedTabId: UUID?) { | ||
| private func scheduleSave( | ||
| tabs: [PersistedTab], | ||
| selectedTabId: UUID?, | ||
| lastActiveDatabase: String?, | ||
| lastActiveSchema: String? | ||
| ) { | ||
| saveTask?.cancel() | ||
| let connId = connectionId | ||
| let tabsCopy = tabs | ||
| let selectedId = selectedTabId | ||
| let activeDatabase = lastActiveDatabase | ||
| let activeSchema = lastActiveSchema | ||
| Self.logger.debug("[persist] saveNow queued tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public)") | ||
|
|
||
| saveTask = Task { | ||
| guard !Task.isCancelled else { return } | ||
| let t0 = Date() | ||
| do { | ||
| try await TabDiskActor.shared.save(connectionId: connId, tabs: tabsCopy, selectedTabId: selectedId) | ||
| try await TabDiskActor.shared.save( | ||
| connectionId: connId, | ||
| tabs: tabsCopy, | ||
| selectedTabId: selectedId, | ||
| lastActiveDatabase: activeDatabase, | ||
| lastActiveSchema: activeSchema | ||
| ) | ||
| Self.logger.debug("[persist] saveNow written tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") | ||
| } catch is CancellationError { | ||
| return | ||
|
|
@@ -114,30 +153,9 @@ internal final class TabPersistenceCoordinator { | |
| return RestoreResult( | ||
| tabs: restoredTabs, | ||
| selectedTabId: state.selectedTabId, | ||
| source: .disk | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - Private | ||
|
|
||
| private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab { | ||
| let persistedQuery: String | ||
| if (tab.content.query as NSString).length > TabQueryContent.maxPersistableQuerySize { | ||
| persistedQuery = "" | ||
| } else { | ||
| persistedQuery = tab.content.query | ||
| } | ||
|
|
||
| return PersistedTab( | ||
| id: tab.id, | ||
| title: tab.title, | ||
| query: persistedQuery, | ||
| tabType: tab.tabType, | ||
| tableName: tab.tableContext.tableName, | ||
| isView: tab.tableContext.isView, | ||
| databaseName: tab.tableContext.databaseName, | ||
| schemaName: tab.tableContext.schemaName, | ||
| sourceFileURL: tab.content.sourceFileURL | ||
| source: .disk, | ||
| lastActiveDatabase: state.lastActiveDatabase, | ||
| lastActiveSchema: state.lastActiveSchema | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ final class AppSettingsStorage { | |
| static let sync = "com.TablePro.settings.sync" | ||
| static let mcp = "com.TablePro.settings.mcp" | ||
| static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding" | ||
| static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast" | ||
| } | ||
|
|
||
| init(userDefaults: UserDefaults = .standard) { | ||
|
|
@@ -48,6 +49,17 @@ final class AppSettingsStorage { | |
| save(settings, key: Keys.general) | ||
| } | ||
|
|
||
| func migrateStartupBehaviorToReopenLastIfNeeded() { | ||
| guard !defaults.bool(forKey: Keys.startupReopenMigration) else { return } | ||
| defaults.set(true, forKey: Keys.startupReopenMigration) | ||
|
|
||
| guard defaults.data(forKey: Keys.general) != nil else { return } | ||
| var general = loadGeneral() | ||
| guard general.startupBehavior == .showWelcome else { return } | ||
| general.startupBehavior = .reopenLast | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For existing installs that already have Useful? React with 👍 / 👎. |
||
| saveGeneral(general) | ||
| } | ||
|
|
||
| // MARK: - Appearance Settings | ||
|
|
||
| func loadAppearance() -> AppearanceSettings { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| // | ||
| // LastOpenConnectionsStorage.swift | ||
| // TablePro | ||
| // | ||
| // Records which connections had open windows at last quit so the | ||
| // "Reopen Last Session" startup behavior can recover after a crash, | ||
| // where AppKit's window-restoration archive is never written. | ||
| // | ||
|
|
||
| import Foundation | ||
| import os | ||
|
|
||
| @MainActor | ||
| final class LastOpenConnectionsStorage { | ||
| static let shared = LastOpenConnectionsStorage() | ||
|
|
||
| private static let logger = Logger(subsystem: "com.TablePro", category: "LastOpenConnections") | ||
|
|
||
| private let fileURL: URL | ||
|
|
||
| private convenience init() { | ||
| let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first | ||
| ?? FileManager.default.temporaryDirectory | ||
| self.init(directory: appSupport.appendingPathComponent("TablePro", isDirectory: true)) | ||
| } | ||
|
|
||
| init(directory: URL) { | ||
| try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||
| fileURL = directory.appendingPathComponent("LastOpenConnections.json") | ||
| } | ||
|
|
||
| func save(connectionIds: [UUID]) { | ||
| guard !connectionIds.isEmpty else { | ||
| clear() | ||
| return | ||
| } | ||
| do { | ||
| let data = try JSONEncoder().encode(connectionIds) | ||
| try data.write(to: fileURL, options: .atomic) | ||
| } catch { | ||
| Self.logger.error("Failed to save last open connections: \(error.localizedDescription, privacy: .public)") | ||
| } | ||
| } | ||
|
|
||
| func load() -> [UUID] { | ||
| guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } | ||
| do { | ||
| let data = try Data(contentsOf: fileURL) | ||
| return try JSONDecoder().decode([UUID].self, from: data) | ||
| } catch { | ||
| Self.logger.error("Failed to load last open connections: \(error.localizedDescription, privacy: .public)") | ||
| return [] | ||
| } | ||
| } | ||
|
|
||
| func clear() { | ||
| guard FileManager.default.fileExists(atPath: fileURL.path) else { return } | ||
| try? FileManager.default.removeItem(at: fileURL) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Saving
LastOpenConnectionsonly fromapplicationWillTerminatedoes not support the crash/force-quit recovery this change adds: crashes and SIGKILL-style force quits never run this method, so the recovery list remains stale from a previous clean quit. For example, if the user opens a different connection after launch and the app crashes, the periodic tab files may be up to date but startup will not know to reopen that connection.Useful? React with 👍 / 👎.