Skip to content

Commit 7f15ebd

Browse files
committed
Termcast app, to record/save sessions, compatible with asciinema
1 parent 18b1ad0 commit 7f15ebd

6 files changed

Lines changed: 591 additions & 2 deletions

File tree

Package.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ let package = Package(
1212
],
1313
products: [
1414
.executable(name: "SwiftTermFuzz", targets: ["SwiftTermFuzz"]),
15+
.executable(name: "termcast", targets: ["Termcast"]),
1516
//.executable(name: "CaptureOutput", targets: ["CaptureOutput"]),
1617
.library(
1718
name: "SwiftTerm",
1819
targets: ["SwiftTerm"]
1920
),
2021
],
21-
dependencies: [],
22+
dependencies: [
23+
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
24+
],
2225
targets: [
2326
.target(
2427
name: "SwiftTerm",
@@ -30,6 +33,14 @@ let package = Package(
3033
dependencies: ["SwiftTerm"],
3134
path: "Sources/SwiftTermFuzz"
3235
),
36+
.executableTarget (
37+
name: "Termcast",
38+
dependencies: [
39+
"SwiftTerm",
40+
.product(name: "ArgumentParser", package: "swift-argument-parser")
41+
],
42+
path: "Sources/Termcast"
43+
),
3344
// .target (
3445
// name: "CaptureOutput",
3546
// dependencies: ["SwiftTerm"],

README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Features
5454
* Proper CoreText rendering can munch through the hardened Unicode test suites.
5555
* Sixel graphics (Use img2sixel to test)
5656
* iTerm2-style graphic rendering (Use imgcat to test)
57+
* Terminal session recording and playback with termcast
5758
* Fuzzed and abused
5859
* Seems pretty fast to me
5960

@@ -103,7 +104,53 @@ The core library currently does not provide a convenient way to connect to SSH,
103104
to avoid the additional dependency. But this git module references a module that pulls
104105
a precompiled SSH client ([Frugghi's SwiftSH](https://github.com/migueldeicaza/SwiftSH)), along with
105106
a [`UIKitSsshTerminalView`](https://github.com/migueldeicaza/SwiftTerm/blob/main/TerminalApp/iOSTerminal/UIKitSshTerminalView.swift)
106-
in the iOS sample that that connects the `TerminalView` for iOS to an SSH connection.
107+
in the iOS sample that that connects the `TerminalView` for iOS to an SSH connection.
108+
109+
## Termcast - Terminal Recording and Playback
110+
111+
SwiftTerm includes a `termcast` command-line tool that can record and playback terminal sessions in the [asciinema](https://asciinema.org/) `.cast` format. This tool is built using SwiftTerm's `LocalProcess` functionality.
112+
113+
### Recording Sessions
114+
115+
To record a terminal session:
116+
117+
```bash
118+
swift run termcast record output.cast
119+
```
120+
121+
Options:
122+
- `--command` / `-c`: Specify a command to run (defaults to your shell)
123+
- `--timeout` / `-t`: Set an automatic timeout in seconds
124+
125+
Examples:
126+
```bash
127+
# Record an interactive shell session
128+
swift run termcast record my-session.cast
129+
130+
# Record a specific command
131+
swift run termcast record -c "ls -la && echo 'Done'" command-demo.cast
132+
133+
# Record with a 30-second timeout
134+
swift run termcast record --timeout 30 timed-session.cast
135+
```
136+
137+
### Playing Back Sessions
138+
139+
To playback a recorded session:
140+
141+
```bash
142+
swift run termcast playback my-session.cast
143+
```
144+
145+
The playback will show the recorded terminal session with proper timing, including both input and output as they occurred during recording.
146+
147+
### Features
148+
149+
- **Full input/output capture**: Records both user input and program output with precise timing
150+
- **Raw terminal mode**: Properly handles terminal control sequences and special keys
151+
- **asciinema compatibility**: Uses the standard `.cast` format for interoperability
152+
- **Live display**: Shows the session live while recording
153+
- **Proper terminal handling**: Maintains correct line endings and terminal state
107154

108155
Working on SwiftTerm
109156
====================
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import Foundation
2+
3+
struct AsciicastHeader: Codable {
4+
let version: Int
5+
let width: Int
6+
let height: Int
7+
let timestamp: TimeInterval
8+
let command: String?
9+
let title: String?
10+
let env: [String: String]?
11+
}
12+
13+
enum AsciicastEventType: String, Codable {
14+
case output = "o"
15+
case input = "i"
16+
case resize = "r"
17+
case marker = "m"
18+
}
19+
20+
struct AsciicastEvent: Codable {
21+
let time: TimeInterval
22+
let eventType: AsciicastEventType
23+
let eventData: String
24+
25+
init(time: TimeInterval, eventType: AsciicastEventType, eventData: String) {
26+
self.time = time
27+
self.eventType = eventType
28+
self.eventData = eventData
29+
}
30+
31+
init(from decoder: Decoder) throws {
32+
var container = try decoder.unkeyedContainer()
33+
self.time = try container.decode(TimeInterval.self)
34+
self.eventType = try container.decode(AsciicastEventType.self)
35+
self.eventData = try container.decode(String.self)
36+
}
37+
38+
func encode(to encoder: Encoder) throws {
39+
var container = encoder.unkeyedContainer()
40+
try container.encode(time)
41+
try container.encode(eventType)
42+
try container.encode(eventData)
43+
}
44+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Foundation
2+
3+
class TermcastPlayer {
4+
private let decoder = JSONDecoder()
5+
6+
func playback(from filePath: String) throws {
7+
let url = URL(fileURLWithPath: filePath)
8+
let content = try String(contentsOf: url, encoding: .utf8)
9+
let lines = content.components(separatedBy: .newlines).filter { !$0.isEmpty }
10+
11+
guard !lines.isEmpty else {
12+
throw TermcastError.invalidFile("Empty cast file")
13+
}
14+
15+
// Parse header
16+
let headerData = lines[0].data(using: .utf8)!
17+
let header = try decoder.decode(AsciicastHeader.self, from: headerData)
18+
19+
// Set terminal size if possible
20+
setTerminalSize(width: header.width, height: header.height)
21+
22+
// Parse and replay events
23+
var lastTime: TimeInterval = 0
24+
25+
for line in lines.dropFirst() {
26+
let eventData = line.data(using: .utf8)!
27+
let event = try decoder.decode(AsciicastEvent.self, from: eventData)
28+
29+
// Calculate delay since last event
30+
let delay = event.time - lastTime
31+
if delay > 0 {
32+
usleep(UInt32(delay * 1_000_000)) // Convert to microseconds
33+
}
34+
35+
// Handle different event types
36+
switch event.eventType {
37+
case .output:
38+
print(event.eventData, terminator: "")
39+
fflush(stdout)
40+
case .resize:
41+
handleResize(event.eventData)
42+
case .input, .marker:
43+
// For playback, we typically don't replay input or markers
44+
// But we could add options to show them if needed
45+
break
46+
}
47+
48+
lastTime = event.time
49+
}
50+
51+
print() // Final newline
52+
}
53+
54+
private func setTerminalSize(width: Int, height: Int) {
55+
// Try to set the terminal size using ANSI escape codes
56+
// This may not work in all terminals, but it's worth trying
57+
print("\u{001B}[8;\(height);\(width)t", terminator: "")
58+
fflush(stdout)
59+
}
60+
61+
private func handleResize(_ data: String) {
62+
// Parse resize data in format "WIDTHxHEIGHT"
63+
let components = data.components(separatedBy: "x")
64+
guard components.count == 2,
65+
let width = Int(components[0]),
66+
let height = Int(components[1]) else {
67+
return
68+
}
69+
70+
setTerminalSize(width: width, height: height)
71+
}
72+
}
73+
74+
enum TermcastError: Error, LocalizedError {
75+
case invalidFile(String)
76+
case fileNotFound(String)
77+
78+
var errorDescription: String? {
79+
switch self {
80+
case .invalidFile(let message):
81+
return "Invalid cast file: \(message)"
82+
case .fileNotFound(let path):
83+
return "File not found: \(path)"
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)