vibetunnel/ios/VibeTunnel/Models/CastFile.swift
2025-06-21 14:39:44 +02:00

295 lines
9 KiB
Swift

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..<lines.count {
let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty,
let lineData = line.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: lineData) as? [Any],
array.count >= 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()
}
}
}
}