import Foundation import Observation /// Cast file theme configuration for terminal appearance. /// /// This struct represents the theme settings that can be included in an Asciinema cast file, /// controlling the visual appearance of the terminal recording. struct CastTheme: Codable { let foreground: String? let background: String? let palette: String? enum CodingKeys: String, CodingKey { case foreground = "fg" case background = "bg" case palette } } /// Represents the header structure of an Asciinema cast v2 file. /// /// The CastFile struct contains metadata about a terminal recording, /// including dimensions, timing, and optional theme information. /// This follows the Asciinema cast v2 format specification. struct CastFile: Codable { let version: Int let width: Int let height: Int let timestamp: TimeInterval? let title: String? let env: [String: String]? let theme: CastTheme? } /// Represents a single event in a terminal recording. /// /// Events capture terminal output, input, or resize operations /// with timestamps relative to the recording start. struct CastEvent: Codable { let time: TimeInterval let type: String // "o" for output, "i" for input, "r" for resize let data: String } /// Records terminal sessions in Asciinema cast v2 format. /// /// CastRecorder captures terminal output and resize events during a session, /// allowing export of the recording as a standard cast file that can be /// played back with any Asciinema-compatible player. /// /// ## Usage /// ```swift /// let recorder = CastRecorder(sessionId: "session123") /// recorder.startRecording() /// // Terminal output is recorded... /// recorder.stopRecording() /// let castData = recorder.exportCastFile() /// ``` @MainActor @Observable class CastRecorder { var isRecording = false var recordingStartTime: Date? var events: [CastEvent] = [] private let sessionId: String private let width: Int private let height: Int private var startTime: TimeInterval = 0 /// Creates a new cast recorder for a terminal session. /// /// - Parameters: /// - sessionId: Unique identifier for the session. /// - width: Terminal width in columns (default: 80). /// - height: Terminal height in rows (default: 24). init(sessionId: String, width: Int = 80, height: Int = 24) { self.sessionId = sessionId self.width = width self.height = height } /// Begins recording terminal events. /// /// Clears any previous events and sets the recording start time. /// Has no effect if recording is already in progress. func startRecording() { guard !isRecording else { return } isRecording = true recordingStartTime = Date() startTime = Date().timeIntervalSince1970 events.removeAll() } /// Stops recording terminal events. /// /// Has no effect if recording is not in progress. func stopRecording() { guard isRecording else { return } isRecording = false recordingStartTime = nil } /// Records terminal output data. /// /// - Parameter data: The terminal output text to record. /// /// Output events are timestamped relative to the recording start time. /// Has no effect if recording is not active. func recordOutput(_ data: String) { guard isRecording else { return } let currentTime = Date().timeIntervalSince1970 let relativeTime = currentTime - startTime let event = CastEvent( time: relativeTime, type: "o", // output data: data ) events.append(event) } /// Records a terminal resize event. /// /// - Parameters: /// - cols: New terminal width in columns. /// - rows: New terminal height in rows. /// /// Resize events are timestamped relative to the recording start time. /// Has no effect if recording is not active. func recordResize(cols: Int, rows: Int) { guard isRecording else { return } let currentTime = Date().timeIntervalSince1970 let relativeTime = currentTime - startTime let resizeData = "\(cols)x\(rows)" let event = CastEvent( time: relativeTime, type: "r", // resize data: resizeData ) events.append(event) } /// Exports the recording as an Asciinema cast v2 file. /// /// - Returns: The cast file data, or nil if export fails. /// /// The exported data contains a JSON header followed by /// newline-delimited JSON arrays representing each event. func exportCastFile() -> Data? { // Create header let header = CastFile( version: 2, width: width, height: height, timestamp: startTime, title: "VibeTunnel Recording - \(sessionId)", env: ["TERM": "xterm-256color", "SHELL": "/bin/zsh"], theme: nil ) guard let headerData = try? JSONEncoder().encode(header), let headerString = String(data: headerData, encoding: .utf8) else { return nil } // Build the cast file content var castContent = headerString + "\n" // Add all events for event in events { // Cast events are encoded as arrays [time, type, data] let eventArray: [Any] = [event.time, event.type, event.data] if let jsonData = try? JSONSerialization.data(withJSONObject: eventArray), let jsonString = String(data: jsonData, encoding: .utf8) { castContent += jsonString + "\n" } } return castContent.data(using: .utf8) } } /// Plays back terminal recordings from Asciinema cast files. /// /// CastPlayer parses cast v2 files and provides playback functionality /// with proper timing between events. /// /// ## Example /// ```swift /// if let player = CastPlayer(data: castFileData) { /// player.play(onEvent: { event in /// // Handle each event /// }, completion: { /// // Playback complete /// }) /// } /// ``` class CastPlayer { /// The cast file header containing metadata. let header: CastFile /// All events in the recording. let events: [CastEvent] /// Creates a cast player from cast file data. /// /// - Parameter data: Raw cast file data. /// /// - Returns: A configured player, or nil if the data is invalid. /// /// The initializer parses the cast file format, extracting the header /// from the first line and events from subsequent lines. init?(data: Data) { guard let content = String(data: data, encoding: .utf8) else { return nil } let lines = content.components(separatedBy: .newlines) guard !lines.isEmpty else { return nil } // Parse header (first line) guard let headerData = lines[0].data(using: .utf8), let header = try? JSONDecoder().decode(CastFile.self, from: headerData) else { return nil } // Parse events (remaining lines) var parsedEvents: [CastEvent] = [] for index in 1..= 3, let time = array[0] as? Double, let type = array[1] as? String, let data = array[2] as? String else { continue } let event = CastEvent(time: time, type: type, data: data) parsedEvents.append(event) } self.header = header self.events = parsedEvents } /// The total duration of the recording in seconds. /// /// Calculated from the timestamp of the last event. var duration: TimeInterval { events.last?.time ?? 0 } /// Plays back the recording with proper timing. /// /// - Parameters: /// - onEvent: Closure called for each event during playback. /// - completion: Closure called when playback completes. /// /// Events are delivered on the main actor with delays matching /// their original timing. The playback runs asynchronously. func play(onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) { let eventsToPlay = self.events Task { @Sendable in for event in eventsToPlay { // Wait for the appropriate time if event.time > 0 { try? await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000)) } await MainActor.run { onEvent(event) } } await MainActor.run { completion() } } } /// Modern async version of play that supports cancellation and error handling. /// /// - Parameter onEvent: Async closure called for each event during playback. /// - Throws: Throws if playback is cancelled or encounters an error. /// /// Events are delivered on the main actor with delays matching /// their original timing. @MainActor func play(onEvent: @Sendable (CastEvent) async -> Void) async throws { for event in events { // Check for cancellation try Task.checkCancellation() // Wait for the appropriate time if event.time > 0 { try await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000)) } await onEvent(event) } } }