import CoreGraphics import Foundation import Bonsplit enum SessionSnapshotSchema { static let currentVersion = 1 } enum SessionPersistencePolicy { static let defaultSidebarWidth: Double = 200 static let minimumSidebarWidth: Double = 186 static let maximumSidebarWidth: Double = 600 static let minimumWindowWidth: Double = 300 static let minimumWindowHeight: Double = 200 static let autosaveInterval: TimeInterval = 8.0 static let maxWindowsPerSnapshot: Int = 12 static let maxWorkspacesPerWindow: Int = 128 static let maxPanelsPerWorkspace: Int = 512 static let maxScrollbackLinesPerTerminal: Int = 4000 static let maxScrollbackCharactersPerTerminal: Int = 400_000 static func sanitizedSidebarWidth(_ candidate: Double?) -> Double { let fallback = defaultSidebarWidth guard let candidate, candidate.isFinite else { return fallback } return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth) } static func truncatedScrollback(_ text: String?) -> String? { guard let text, !text.isEmpty else { return nil } if text.count <= maxScrollbackCharactersPerTerminal { return text } let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal) let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart) return String(text[safeStart...]) } /// If truncation starts in the middle of an ANSI CSI escape sequence, advance /// to the first printable character after that sequence to avoid replaying /// malformed control bytes. private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index { guard initialStart > text.startIndex else { return initialStart } let escape = "\u{001B}" guard let lastEscape = text[.. String.Index? { var index = text.index(after: csiMarker) while index < upperBound { guard let scalar = text[index].unicodeScalars.first?.value else { index = text.index(after: index) continue } if scalar >= 0x40, scalar <= 0x7E { return index } index = text.index(after: index) } return nil } } enum SessionRestorePolicy { static func isRunningUnderAutomatedTests( environment: [String: String] = ProcessInfo.processInfo.environment ) -> Bool { if environment["CMUX_UI_TEST_MODE"] == "1" { return true } if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { return true } if environment["XCTestConfigurationFilePath"] != nil { return true } if environment["XCTestBundlePath"] != nil { return true } if environment["XCTestSessionIdentifier"] != nil { return true } if environment["XCInjectBundle"] != nil { return true } if environment["XCInjectBundleInto"] != nil { return true } if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } return false } static func shouldAttemptRestore( arguments: [String] = CommandLine.arguments, environment: [String: String] = ProcessInfo.processInfo.environment ) -> Bool { if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" { return false } if isRunningUnderAutomatedTests(environment: environment) { return false } let extraArgs = arguments .dropFirst() .filter { !$0.hasPrefix("-psn_") } // Any explicit launch argument is treated as an explicit open intent. return extraArgs.isEmpty } } struct SessionRectSnapshot: Codable, Equatable, Sendable { let x: Double let y: Double let width: Double let height: Double init(x: Double, y: Double, width: Double, height: Double) { self.x = x self.y = y self.width = width self.height = height } init(_ rect: CGRect) { self.x = Double(rect.origin.x) self.y = Double(rect.origin.y) self.width = Double(rect.size.width) self.height = Double(rect.size.height) } var cgRect: CGRect { CGRect(x: x, y: y, width: width, height: height) } } struct SessionDisplaySnapshot: Codable, Sendable { var displayID: UInt32? var frame: SessionRectSnapshot? var visibleFrame: SessionRectSnapshot? } enum SessionSidebarSelection: String, Codable, Sendable, Equatable { case tabs case notifications init(selection: SidebarSelection) { switch selection { case .tabs: self = .tabs case .notifications: self = .notifications } } var sidebarSelection: SidebarSelection { switch self { case .tabs: return .tabs case .notifications: return .notifications } } } struct SessionSidebarSnapshot: Codable, Sendable { var isVisible: Bool var selection: SessionSidebarSelection var width: Double? } struct SessionStatusEntrySnapshot: Codable, Sendable { var key: String var value: String var icon: String? var color: String? var timestamp: TimeInterval } struct SessionLogEntrySnapshot: Codable, Sendable { var message: String var level: String var source: String? var timestamp: TimeInterval } struct SessionProgressSnapshot: Codable, Sendable { var value: Double var label: String? } struct SessionGitBranchSnapshot: Codable, Sendable { var branch: String var isDirty: Bool } struct SessionTerminalPanelSnapshot: Codable, Sendable { var workingDirectory: String? var scrollback: String? } struct SessionBrowserPanelSnapshot: Codable, Sendable { var urlString: String? var shouldRenderWebView: Bool var pageZoom: Double var developerToolsVisible: Bool var backHistoryURLStrings: [String]? var forwardHistoryURLStrings: [String]? } struct SessionPanelSnapshot: Codable, Sendable { var id: UUID var type: PanelType var title: String? var customTitle: String? var directory: String? var isPinned: Bool var isManuallyUnread: Bool var gitBranch: SessionGitBranchSnapshot? var listeningPorts: [Int] var ttyName: String? var terminal: SessionTerminalPanelSnapshot? var browser: SessionBrowserPanelSnapshot? } enum SessionSplitOrientation: String, Codable, Sendable { case horizontal case vertical init(_ orientation: SplitOrientation) { switch orientation { case .horizontal: self = .horizontal case .vertical: self = .vertical } } var splitOrientation: SplitOrientation { switch self { case .horizontal: return .horizontal case .vertical: return .vertical } } } struct SessionPaneLayoutSnapshot: Codable, Sendable { var panelIds: [UUID] var selectedPanelId: UUID? } struct SessionSplitLayoutSnapshot: Codable, Sendable { var orientation: SessionSplitOrientation var dividerPosition: Double var first: SessionWorkspaceLayoutSnapshot var second: SessionWorkspaceLayoutSnapshot } indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { case pane(SessionPaneLayoutSnapshot) case split(SessionSplitLayoutSnapshot) private enum CodingKeys: String, CodingKey { case type case pane case split } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) switch type { case "pane": self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane)) case "split": self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split)) default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)") } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .pane(let pane): try container.encode("pane", forKey: .type) try container.encode(pane, forKey: .pane) case .split(let split): try container.encode("split", forKey: .type) try container.encode(split, forKey: .split) } } } struct SessionWorkspaceSnapshot: Codable, Sendable { var processTitle: String var customTitle: String? var customColor: String? var isPinned: Bool var currentDirectory: String var focusedPanelId: UUID? var layout: SessionWorkspaceLayoutSnapshot var panels: [SessionPanelSnapshot] var statusEntries: [SessionStatusEntrySnapshot] var logEntries: [SessionLogEntrySnapshot] var progress: SessionProgressSnapshot? var gitBranch: SessionGitBranchSnapshot? } struct SessionTabManagerSnapshot: Codable, Sendable { var selectedWorkspaceIndex: Int? var workspaces: [SessionWorkspaceSnapshot] } struct SessionWindowSnapshot: Codable, Sendable { var frame: SessionRectSnapshot? var display: SessionDisplaySnapshot? var tabManager: SessionTabManagerSnapshot var sidebar: SessionSidebarSnapshot } struct AppSessionSnapshot: Codable, Sendable { var version: Int var createdAt: TimeInterval var windows: [SessionWindowSnapshot] } enum SessionPersistenceStore { static func load(fileURL: URL? = nil) -> AppSessionSnapshot? { guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } guard let data = try? Data(contentsOf: fileURL) else { return nil } let decoder = JSONDecoder() guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } guard !snapshot.windows.isEmpty else { return nil } return snapshot } @discardableResult static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool { guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false } let directory = fileURL.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let data = try encoder.encode(snapshot) try data.write(to: fileURL, options: .atomic) return true } catch { return false } } static func removeSnapshot(fileURL: URL? = nil) { guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return } try? FileManager.default.removeItem(at: fileURL) } static func defaultSnapshotFileURL( bundleIdentifier: String? = Bundle.main.bundleIdentifier, appSupportDirectory: URL? = nil ) -> URL? { let resolvedAppSupport: URL if let appSupportDirectory { resolvedAppSupport = appSupportDirectory } else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { resolvedAppSupport = discovered } else { return nil } let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? bundleIdentifier! : "com.cmuxterm.app" let safeBundleId = bundleId.replacingOccurrences( of: "[^A-Za-z0-9._-]", with: "_", options: .regularExpression ) return resolvedAppSupport .appendingPathComponent("cmux", isDirectory: true) .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) } } enum SessionScrollbackReplayStore { static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE" private static let directoryName = "cmux-session-scrollback" private static let ansiEscape = "\u{001B}" private static let ansiReset = "\u{001B}[0m" static func replayEnvironment( for scrollback: String?, tempDirectory: URL = FileManager.default.temporaryDirectory ) -> [String: String] { guard let replayText = normalizedScrollback(scrollback) else { return [:] } guard let replayFileURL = writeReplayFile( contents: replayText, tempDirectory: tempDirectory ) else { return [:] } return [environmentKey: replayFileURL.path] } private static func normalizedScrollback(_ scrollback: String?) -> String? { guard let scrollback else { return nil } guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil } guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil } return ansiSafeReplayText(truncated) } /// Preserve ANSI color state safely across replay boundaries. private static func ansiSafeReplayText(_ text: String) -> String { guard text.contains(ansiEscape) else { return text } var output = text if !output.hasPrefix(ansiReset) { output = ansiReset + output } if !output.hasSuffix(ansiReset) { output += ansiReset } return output } private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { guard let data = contents.data(using: .utf8) else { return nil } let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) do { try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true, attributes: nil ) let fileURL = directory .appendingPathComponent(UUID().uuidString, isDirectory: false) .appendingPathExtension("txt") try data.write(to: fileURL, options: .atomic) return fileURL } catch { return nil } } }