diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8909acab2a..7d01c58db6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1001,6 +1001,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false private var didBootstrapInitialMainWindow = false private var isTerminatingApp = false + private var didPersistUpdateRelaunchSnapshot = false private var closedWindowHistorySuppressedWindowIds: Set = [] #if DEBUG var closeMainWindowContainingTabIdObserverForTesting: ((UUID, Bool) -> Void)? @@ -1763,7 +1764,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true closeAllWebInspectorsBeforeAppTeardown() - _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) + saveSessionSnapshotForAppTermination() stopSessionAutosaveTimer() CloudVMActionLauncher.shared.terminateAll() CmuxSSHURLProcessLauncher.shared.terminateAll() @@ -1792,7 +1793,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func persistSessionForUpdateRelaunch() { isTerminatingApp = true - _ = saveSessionSnapshotIncludingProcessDetectedIndexes(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() + didPersistUpdateRelaunchSnapshot = saveSessionSnapshotIncludingProcessDetectedIndexes( + includeScrollback: true, + removeWhenEmpty: false, + forceSynchronousWrite: true + ) } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { @@ -3687,7 +3693,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent includeScrollback: Bool, removeWhenEmpty: Bool = false, restorableAgentIndex: RestorableAgentSessionIndex? = nil, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, + forceSynchronousWrite: Bool = false ) -> Bool { if Self.shouldSkipSessionSaveDuringRestore( isApplyingSessionRestore: isApplyingSessionRestore, @@ -3700,7 +3707,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( isTerminatingApp: isTerminatingApp, - includeScrollback: includeScrollback + includeScrollback: includeScrollback, + forceSynchronousWrite: forceSynchronousWrite ) if writeSynchronously { TextBoxInputTextView.flushPendingSessionDraftAttachmentCopies() @@ -3958,14 +3966,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult private func saveSessionSnapshotIncludingProcessDetectedIndexes( includeScrollback: Bool, - removeWhenEmpty: Bool = false + removeWhenEmpty: Bool = false, + forceSynchronousWrite: Bool = false ) -> Bool { let resumeIndexes = ProcessDetectedResumeIndexes.loadSynchronously() return saveSessionSnapshot( includeScrollback: includeScrollback, removeWhenEmpty: removeWhenEmpty, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex, + forceSynchronousWrite: forceSynchronousWrite + ) + } + + private func saveSessionSnapshotForAppTermination() { + guard Self.shouldSaveSessionSnapshotOnApplicationTerminate( + didPersistUpdateRelaunchSnapshot: didPersistUpdateRelaunchSnapshot + ) else { + StartupBreadcrumbLog.append("appDelegate.willTerminate.snapshotSkipped.updateRelaunch") + return + } + + _ = saveSessionSnapshotIncludingProcessDetectedIndexes( + includeScrollback: true, + removeWhenEmpty: false ) } @@ -4004,9 +4028,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent nonisolated static func shouldWriteSessionSnapshotSynchronously( isTerminatingApp: Bool, - includeScrollback: Bool + includeScrollback: Bool, + forceSynchronousWrite: Bool = false + ) -> Bool { + if forceSynchronousWrite { return true } + return isTerminatingApp && includeScrollback + } + + nonisolated static func shouldSaveSessionSnapshotOnApplicationTerminate( + didPersistUpdateRelaunchSnapshot: Bool ) -> Bool { - isTerminatingApp && includeScrollback + !didPersistUpdateRelaunchSnapshot } nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index e5fe091345..b921b6a543 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -752,36 +752,6 @@ class TerminalController { return trimmed } - nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let url = URL(string: trimmed), - url.isFileURL, - !url.path.isEmpty { - return url.path - } - return trimmed.hasPrefix("/") ? trimmed : nil - } - - nonisolated static func shouldRemoveExportedScreenFile( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let standardizedFile = fileURL.standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return standardizedFile.path.hasPrefix(temporary.path + "/") - } - - nonisolated static func shouldRemoveExportedScreenDirectory( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let directory = fileURL.deletingLastPathComponent().standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return directory.path.hasPrefix(temporary.path + "/") - } - nonisolated static func parseReportedShellActivityState( _ rawState: String ) -> Workspace.PanelShellActivityState? { @@ -10006,6 +9976,35 @@ class TerminalController { return String(decoding: rawData, as: UTF8.self) } + private func readTerminalTextTail( + terminalPanel: TerminalPanel, + scope: ghostty_text_scope_e, + format: ghostty_text_format_e, + lineLimit: Int?, + byteLimit: Int? = nil + ) -> String? { + guard let surface = terminalPanel.surface.surface else { return nil } + let options = ghostty_text_tail_options_s( + scope: scope, + format: format, + max_lines: UInt(max(lineLimit ?? 0, 0)), + max_bytes: UInt(max(byteLimit ?? 0, 0)) + ) + var text = ghostty_text_s() + guard ghostty_surface_read_text_tail(surface, options, &text) else { + return nil + } + defer { + ghostty_surface_free_text(surface, &text) + } + + guard let ptr = text.text, text.text_len > 0 else { + return "" + } + let rawData = Data(bytes: ptr, count: Int(text.text_len)) + return String(decoding: rawData, as: UTF8.self) + } + private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { guard terminalPanel.surface.surface != nil else { return "ERROR: Terminal surface not found" } func readSelectionText(pointTag: ghostty_point_tag_e) -> String? { @@ -10014,44 +10013,15 @@ class TerminalController { var output: String if includeScrollback { - func candidateScore(_ text: String) -> (lines: Int, bytes: Int) { - let lines = text.isEmpty ? 0 : text.split(separator: "\n", omittingEmptySubsequences: false).count - return (lines, text.utf8.count) - } - - // Read all available regions and pick the most complete candidate. - // Different point tags can lose different rows around resize/reflow boundaries. - let screen = readSelectionText(pointTag: GHOSTTY_POINT_SCREEN) - let history = readSelectionText(pointTag: GHOSTTY_POINT_SURFACE) - let active = readSelectionText(pointTag: GHOSTTY_POINT_ACTIVE) - - var candidates: [String] = [] - if let screen { - candidates.append(screen) - } - if history != nil || active != nil { - var merged = history ?? "" - if let active { - if !merged.isEmpty, !merged.hasSuffix("\n"), !active.isEmpty { - merged.append("\n") - } - merged.append(active) - } - candidates.append(merged) - } - - if let best = candidates.max(by: { lhs, rhs in - let left = candidateScore(lhs) - let right = candidateScore(rhs) - if left.lines != right.lines { - return left.lines < right.lines - } - return left.bytes < right.bytes - }) { - output = best - } else { + guard let tail = readTerminalTextTail( + terminalPanel: terminalPanel, + scope: GHOSTTY_TEXT_SCOPE_SCREEN, + format: GHOSTTY_TEXT_FORMAT_PLAIN, + lineLimit: lineLimit + ) else { return "ERROR: Failed to read terminal text" } + output = tail } else { guard let viewport = readSelectionText(pointTag: GHOSTTY_POINT_VIEWPORT) else { return "ERROR: Failed to read terminal text" @@ -10067,106 +10037,26 @@ class TerminalController { return "OK \(base64)" } - private struct PasteboardItemSnapshot { - let representations: [(type: NSPasteboard.PasteboardType, data: Data)] - } - - private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { - guard let items = pasteboard.pasteboardItems else { return [] } - return items.map { item in - let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in - guard let data = item.data(forType: type) else { return nil } - return (type: type, data: data) - } - return PasteboardItemSnapshot(representations: representations) - } - } - - private func restorePasteboardItems( - _ snapshots: [PasteboardItemSnapshot], - to pasteboard: NSPasteboard - ) { - _ = pasteboard.clearContents() - guard !snapshots.isEmpty else { return } - - let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in - guard !snapshot.representations.isEmpty else { return nil } - let item = NSPasteboardItem() - for representation in snapshot.representations { - item.setData(representation.data, forType: representation.type) - } - return item - } - guard !restoredItems.isEmpty else { return } - _ = pasteboard.writeObjects(restoredItems) - } - - private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { - if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], - let firstURL = urls.first, - firstURL.isFileURL { - return firstURL.path - } - if let value = pasteboard.string(forType: .string) { - return value - } - return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) - } - - private func readTerminalTextFromVTExportForSnapshot( - terminalPanel: TerminalPanel, - lineLimit: Int? - ) -> String? { - let pasteboard = NSPasteboard.general - let snapshot = snapshotPasteboardItems(pasteboard) - defer { - restorePasteboardItems(snapshot, to: pasteboard) - } - - let initialChangeCount = pasteboard.changeCount - guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { - return nil - } - guard pasteboard.changeCount != initialChangeCount else { - return nil - } - guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { - return nil - } - - let fileURL = URL(fileURLWithPath: exportedPath) - defer { - if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL) - if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) - } - } - } - - guard let data = try? Data(contentsOf: fileURL), - var output = String(data: data, encoding: .utf8) else { - return nil - } - if let lineLimit { - output = tailTerminalLines(output, maxLines: lineLimit) - } - return output - } - func readTerminalTextForSnapshot( terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil, allowVTExport: Bool = true ) -> String? { - if includeScrollback, - allowVTExport, - let vtOutput = readTerminalTextFromVTExportForSnapshot( - terminalPanel: terminalPanel, - lineLimit: lineLimit - ) { - return vtOutput + if includeScrollback { + return readTerminalTextTail( + terminalPanel: terminalPanel, + scope: GHOSTTY_TEXT_SCOPE_SCREEN, + format: allowVTExport ? GHOSTTY_TEXT_FORMAT_VT : GHOSTTY_TEXT_FORMAT_PLAIN, + lineLimit: lineLimit, + byteLimit: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + ) ?? readTerminalTextTail( + terminalPanel: terminalPanel, + scope: GHOSTTY_TEXT_SCOPE_VIEWPORT, + format: GHOSTTY_TEXT_FORMAT_PLAIN, + lineLimit: lineLimit, + byteLimit: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + ) } let response = readTerminalTextBase64( diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index eb27f5d58b..8dcecdf273 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -108,14 +108,22 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updaterWillRelaunchApplication(_ updater: SPUUpdater) { - Task { @MainActor in - AppDelegate.shared?.persistSessionForUpdateRelaunch() - TerminalController.shared.stop() - NSApp.invalidateRestorableState() - for window in NSApp.windows { - window.invalidateRestorableState() + let prepareForRelaunch = { + MainActor.assumeIsolated { + AppDelegate.shared?.persistSessionForUpdateRelaunch() + TerminalController.shared.stop() + NSApp.invalidateRestorableState() + for window in NSApp.windows { + window.invalidateRestorableState() + } } } + + if Thread.isMainThread { + prepareForRelaunch() + } else { + DispatchQueue.main.sync(execute: prepareForRelaunch) + } } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 0edb658d02..a160889ed2 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -487,67 +487,6 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertFalse(truncated.hasPrefix("m")) } - func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() { - XCTAssertEqual( - TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"), - "/tmp/cmux-screen.txt" - ) - XCTAssertEqual( - TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "), - "/tmp/cmux-screen.txt" - ) - } - - func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() { - XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt")) - XCTAssertNil(TerminalController.normalizedExportedScreenPath(" ")) - XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil)) - } - - func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() { - let tempRoot = URL(fileURLWithPath: "/tmp") - .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) - let tempFile = tempRoot - .appendingPathComponent(UUID().uuidString, isDirectory: true) - .appendingPathComponent("screen.txt", isDirectory: false) - let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") - - XCTAssertTrue( - TerminalController.shouldRemoveExportedScreenDirectory( - fileURL: tempFile, - temporaryDirectory: tempRoot - ) - ) - XCTAssertFalse( - TerminalController.shouldRemoveExportedScreenDirectory( - fileURL: outsideFile, - temporaryDirectory: tempRoot - ) - ) - } - - func testShouldRemoveExportedScreenFileOnlyWithinTemporaryRoot() { - let tempRoot = URL(fileURLWithPath: "/tmp") - .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) - let tempFile = tempRoot - .appendingPathComponent(UUID().uuidString, isDirectory: true) - .appendingPathComponent("screen.txt", isDirectory: false) - let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") - - XCTAssertTrue( - TerminalController.shouldRemoveExportedScreenFile( - fileURL: tempFile, - temporaryDirectory: tempRoot - ) - ) - XCTAssertFalse( - TerminalController.shouldRemoveExportedScreenFile( - fileURL: outsideFile, - temporaryDirectory: tempRoot - ) - ) - } - func testWindowUnregisterSnapshotPersistencePolicy() { XCTAssertTrue(AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false)) XCTAssertFalse(AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true)) @@ -648,6 +587,26 @@ final class SessionPersistenceTests: XCTestCase { includeScrollback: true ) ) + XCTAssertTrue( + AppDelegate.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: false, + includeScrollback: false, + forceSynchronousWrite: true + ) + ) + } + + func testUpdateRelaunchSkipsDuplicateTerminationSnapshot() { + XCTAssertFalse( + AppDelegate.shouldSaveSessionSnapshotOnApplicationTerminate( + didPersistUpdateRelaunchSnapshot: true + ) + ) + XCTAssertTrue( + AppDelegate.shouldSaveSessionSnapshotOnApplicationTerminate( + didPersistUpdateRelaunchSnapshot: false + ) + ) } func testRestoreCompletionSavePolicySkipsManualReopen() { diff --git a/ghostty b/ghostty index 176bd550f6..dac500b2ec 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 176bd550f6fedd29e85cd92470e5dfadf295ebf7 +Subproject commit dac500b2ecaf5f9b2c39d1c25df391960cdf1d02 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 44a70d5004..f24475024f 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -31,3 +31,4 @@ aef980e27b584a9d914f1ff0499b13c6ed1973e0 c6b8d560ad6b53d73396f80ba6995cb880ae9de 6eed7af9240789ba18ccc617e51c384663be34a5 68bf3282478a92640d248c0b52b70cb41387aaed5baee9daa32e1019525f2d07 ff6e1260d2e7767de55b8d9307b328e4060545b7 02e5017a0d27ce5ada9ad92f675ce8c80dcebbc4bcfbe4060b6814b12b28cde9 176bd550f6fedd29e85cd92470e5dfadf295ebf7 60c612900a6101d2fa88e0d5c8debbcbe7f66230cca6b39f8ce24d5fd8c267ed +dac500b2ecaf5f9b2c39d1c25df391960cdf1d02 5db2d42d724e2c93e6f27a8299df90b41007173a8b21edbd003c89b8754f3a7c