mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
295 lines
9 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|