mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-09 11:55:53 +00:00
261 lines
8.5 KiB
Swift
261 lines
8.5 KiB
Swift
import Foundation
|
|
import Logging
|
|
|
|
/// Generates Asciinema cast v2 format files from terminal session output.
|
|
///
|
|
/// Creates recordings of terminal sessions in the Asciinema cast format,
|
|
/// which can be played back using Asciinema players. Handles timing information,
|
|
/// terminal dimensions, and output/input event recording.
|
|
///
|
|
/// Format specification: https://docs.asciinema.org/manual/asciicast/v2/
|
|
struct CastFileGenerator {
|
|
private let logger = Logger(label: "VibeTunnel.CastFileGenerator")
|
|
|
|
/// Header structure for Asciinema cast v2 format.
|
|
///
|
|
/// Contains metadata about the terminal recording including
|
|
/// dimensions, timing, and environment information.
|
|
struct CastHeader: Codable {
|
|
let version: Int = 2
|
|
let width: Int
|
|
let height: Int
|
|
let timestamp: TimeInterval?
|
|
let duration: TimeInterval?
|
|
let idleTimeLimit: TimeInterval?
|
|
let command: String?
|
|
let title: String?
|
|
let env: [String: String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case version
|
|
case width
|
|
case height
|
|
case timestamp
|
|
case duration
|
|
case idleTimeLimit = "idle_time_limit"
|
|
case command
|
|
case title
|
|
case env
|
|
}
|
|
}
|
|
|
|
/// Represents a single event in the Asciinema recording.
|
|
///
|
|
/// Each event captures either terminal output or input at a specific timestamp.
|
|
struct CastEvent {
|
|
let time: TimeInterval
|
|
let eventType: String
|
|
let data: String
|
|
}
|
|
|
|
/// Generate a cast file from a session's stream-out file
|
|
func generateCastFile(
|
|
sessionId: String,
|
|
streamOutPath: String,
|
|
width: Int = 80,
|
|
height: Int = 24,
|
|
title: String? = nil,
|
|
command: String? = nil
|
|
)
|
|
throws -> Data
|
|
{
|
|
guard FileManager.default.fileExists(atPath: streamOutPath) else {
|
|
throw CastFileError.fileNotFound(streamOutPath)
|
|
}
|
|
|
|
let content = try String(contentsOfFile: streamOutPath, encoding: .utf8)
|
|
let lines = content.components(separatedBy: .newlines)
|
|
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
|
|
var outputData = Data()
|
|
var events: [CastEvent] = []
|
|
var startTime: Date?
|
|
var sessionWidth = width
|
|
var sessionHeight = height
|
|
|
|
// Parse the stream-out file
|
|
for line in lines {
|
|
guard let data = line.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data)
|
|
else {
|
|
continue
|
|
}
|
|
|
|
// Check if it's a header
|
|
if let dict = parsed as? [String: Any],
|
|
dict["version"] is Int,
|
|
let width = dict["width"] as? Int,
|
|
let height = dict["height"] as? Int
|
|
{
|
|
sessionWidth = width
|
|
sessionHeight = height
|
|
continue
|
|
}
|
|
|
|
// Parse as event [timestamp, type, data]
|
|
if let array = parsed as? [Any],
|
|
array.count >= 3,
|
|
let timestamp = array[0] as? TimeInterval,
|
|
let eventType = array[1] as? String,
|
|
let eventData = array[2] as? String
|
|
{
|
|
if startTime == nil {
|
|
startTime = Date()
|
|
}
|
|
|
|
events.append(CastEvent(
|
|
time: timestamp,
|
|
eventType: eventType,
|
|
data: eventData
|
|
))
|
|
}
|
|
}
|
|
|
|
// Generate header
|
|
let header = CastHeader(
|
|
width: sessionWidth,
|
|
height: sessionHeight,
|
|
timestamp: startTime?.timeIntervalSince1970,
|
|
duration: events.last?.time,
|
|
idleTimeLimit: nil,
|
|
command: command,
|
|
title: title,
|
|
env: nil
|
|
)
|
|
|
|
// Write header as first line
|
|
let headerData = try JSONEncoder().encode(header)
|
|
outputData.append(headerData)
|
|
outputData.append(Data("\n".utf8))
|
|
|
|
// Write events
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = .withoutEscapingSlashes
|
|
|
|
for event in events {
|
|
let eventArray: [Any] = [event.time, event.eventType, event.data]
|
|
let eventData = try JSONSerialization.data(withJSONObject: eventArray)
|
|
outputData.append(eventData)
|
|
outputData.append(Data("\n".utf8))
|
|
}
|
|
|
|
return outputData
|
|
}
|
|
|
|
/// Generate a cast file and save it to disk
|
|
func saveCastFile(
|
|
sessionId: String,
|
|
streamOutPath: String,
|
|
outputPath: String,
|
|
width: Int = 80,
|
|
height: Int = 24,
|
|
title: String? = nil,
|
|
command: String? = nil
|
|
)
|
|
throws
|
|
{
|
|
let castData = try generateCastFile(
|
|
sessionId: sessionId,
|
|
streamOutPath: streamOutPath,
|
|
width: width,
|
|
height: height,
|
|
title: title,
|
|
command: command
|
|
)
|
|
|
|
try castData.write(to: URL(fileURLWithPath: outputPath))
|
|
logger.info("Cast file saved to: \(outputPath)")
|
|
}
|
|
|
|
/// Generate a live cast stream that can be consumed in real-time
|
|
func streamCastEvents(
|
|
from streamOutPath: String,
|
|
startTime: Date
|
|
)
|
|
-> AsyncStream<Data>
|
|
{
|
|
AsyncStream { continuation in
|
|
Task {
|
|
let fileDescriptor = open(streamOutPath, O_RDONLY)
|
|
guard fileDescriptor >= 0 else {
|
|
logger.error("Failed to open file for streaming: \(streamOutPath)")
|
|
continuation.finish()
|
|
return
|
|
}
|
|
|
|
defer {
|
|
close(fileDescriptor)
|
|
continuation.finish()
|
|
}
|
|
|
|
var lastReadPosition: off_t = 0
|
|
|
|
while !Task.isCancelled {
|
|
let currentPosition = lseek(fileDescriptor, 0, SEEK_END)
|
|
let bytesToRead = currentPosition - lastReadPosition
|
|
|
|
if bytesToRead > 0 {
|
|
lseek(fileDescriptor, lastReadPosition, SEEK_SET)
|
|
|
|
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(bytesToRead) + 1)
|
|
defer { buffer.deallocate() }
|
|
|
|
let bytesRead = read(fileDescriptor, buffer, Int(bytesToRead))
|
|
if bytesRead > 0 {
|
|
let data = Data(bytes: buffer, count: bytesRead)
|
|
if let content = String(data: data, encoding: .utf8) {
|
|
let lines = content.components(separatedBy: .newlines)
|
|
for line in lines where !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
if let eventData = processLineToAsciinemaEvent(
|
|
line: line,
|
|
startTime: startTime
|
|
) {
|
|
continuation.yield(eventData)
|
|
}
|
|
}
|
|
}
|
|
lastReadPosition = currentPosition
|
|
}
|
|
}
|
|
|
|
// Sleep briefly before checking again
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processLineToAsciinemaEvent(line: String, startTime: Date) -> Data? {
|
|
guard let data = line.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
|
parsed.count >= 3,
|
|
let eventType = parsed[1] as? String,
|
|
let eventData = parsed[2] as? String
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let currentTime = Date()
|
|
let timestamp = currentTime.timeIntervalSince(startTime)
|
|
|
|
let event: [Any] = [timestamp, eventType, eventData]
|
|
return try? JSONSerialization.data(withJSONObject: event)
|
|
}
|
|
}
|
|
|
|
enum CastFileError: LocalizedError {
|
|
case fileNotFound(String)
|
|
case invalidFormat
|
|
case encodingError
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .fileNotFound(let path):
|
|
"Stream file not found: \(path)"
|
|
case .invalidFormat:
|
|
"Invalid stream file format"
|
|
case .encodingError:
|
|
"Failed to encode cast file"
|
|
}
|
|
}
|
|
}
|