471 lines
15 KiB
Swift
471 lines
15 KiB
Swift
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[..<initialStart].lastIndex(of: Character(escape)) else {
|
|
return initialStart
|
|
}
|
|
let csiMarker = text.index(after: lastEscape)
|
|
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
|
|
return initialStart
|
|
}
|
|
|
|
// If a final CSI byte exists before the truncation boundary, we are not
|
|
// inside a partial sequence.
|
|
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
|
|
return initialStart
|
|
}
|
|
|
|
// We are inside a CSI sequence. Skip to the first character after the
|
|
// sequence terminator if it exists.
|
|
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
|
|
return initialStart
|
|
}
|
|
let next = text.index(after: final)
|
|
return next < text.endIndex ? next : text.endIndex
|
|
}
|
|
|
|
private static func csiFinalByteIndex(
|
|
in text: String,
|
|
from csiMarker: String.Index,
|
|
upperBound: String.Index
|
|
) -> 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
|
|
}
|
|
|
|
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 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
|
|
}
|
|
}
|
|
}
|