asciinema_win 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,480 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module AsciinemaWin
6
+ # Terminal session recorder for Windows
7
+ #
8
+ # Captures terminal output by periodically sampling the screen buffer
9
+ # and detecting changes between frames. Produces asciicast v2 compatible
10
+ # recordings that can be played back or uploaded to asciinema.org.
11
+ #
12
+ # @example Record an interactive session
13
+ # recorder = AsciinemaWin::Recorder.new(title: "Demo")
14
+ # recorder.record("session.cast") do
15
+ # # Recording runs until Ctrl+D or block exits
16
+ # end
17
+ #
18
+ # @example Record a command
19
+ # recorder = AsciinemaWin::Recorder.new(command: "dir /s")
20
+ # recorder.record("command.cast")
21
+ class Recorder
22
+ # Default maximum idle time between events (seconds)
23
+ DEFAULT_IDLE_TIME_LIMIT = 2.0
24
+
25
+ # Default interval between screen captures (seconds)
26
+ DEFAULT_CAPTURE_INTERVAL = 0.1
27
+
28
+ # Minimum interval between captures to avoid CPU thrashing
29
+ MIN_CAPTURE_INTERVAL = 0.033 # ~30 FPS max
30
+
31
+ # @return [String, nil] Recording title
32
+ attr_reader :title
33
+
34
+ # @return [String, nil] Command to record
35
+ attr_reader :command
36
+
37
+ # @return [Float] Maximum idle time between events
38
+ attr_reader :idle_time_limit
39
+
40
+ # @return [Float] Interval between screen captures
41
+ attr_reader :capture_interval
42
+
43
+ # @return [Array<String>] Environment variables to capture
44
+ attr_reader :env_vars
45
+
46
+ # @return [Symbol] Current recording state (:idle, :recording, :paused, :stopped)
47
+ attr_reader :state
48
+
49
+ # Create a new recorder
50
+ #
51
+ # @param title [String, nil] Recording title
52
+ # @param command [String, nil] Command to record in subprocess
53
+ # @param idle_time_limit [Float] Maximum idle time (capped in output)
54
+ # @param capture_interval [Float] Time between screen captures
55
+ # @param env_vars [Array<String>] Environment variable names to capture
56
+ def initialize(
57
+ title: nil,
58
+ command: nil,
59
+ idle_time_limit: DEFAULT_IDLE_TIME_LIMIT,
60
+ capture_interval: DEFAULT_CAPTURE_INTERVAL,
61
+ env_vars: %w[SHELL TERM COMSPEC]
62
+ )
63
+ @title = title
64
+ @command = command
65
+ @idle_time_limit = idle_time_limit.to_f
66
+ @capture_interval = [capture_interval.to_f, MIN_CAPTURE_INTERVAL].max
67
+ @env_vars = env_vars
68
+
69
+ @state = :idle
70
+ @writer = nil
71
+ @start_time = nil
72
+ @last_buffer = nil
73
+ @capture_thread = nil
74
+ @stop_requested = false
75
+ @markers = []
76
+ end
77
+
78
+ # Start recording to a file
79
+ #
80
+ # @param output_path [String] Path to save the recording
81
+ # @yield [Recorder] Block for interactive recording
82
+ # @return [Hash] Recording statistics
83
+ # @raise [RecordingError] If recording fails
84
+ def record(output_path, &block)
85
+ raise RecordingError, "Already recording" if @state == :recording
86
+
87
+ @state = :recording
88
+ @stop_requested = false
89
+ @markers = []
90
+
91
+ begin
92
+ if @command
93
+ record_command(output_path)
94
+ else
95
+ record_interactive(output_path, &block)
96
+ end
97
+ ensure
98
+ @state = :stopped
99
+ end
100
+ end
101
+
102
+ # Pause recording (stops capturing but keeps file open)
103
+ #
104
+ # @return [void]
105
+ def pause
106
+ return unless @state == :recording
107
+
108
+ @state = :paused
109
+ end
110
+
111
+ # Resume recording after pause
112
+ #
113
+ # @return [void]
114
+ def resume
115
+ return unless @state == :paused
116
+
117
+ @state = :recording
118
+ end
119
+
120
+ # Stop recording
121
+ #
122
+ # @return [void]
123
+ def stop
124
+ @stop_requested = true
125
+ @state = :stopped
126
+ end
127
+
128
+ # Add a marker at the current position
129
+ #
130
+ # @param label [String] Marker label
131
+ # @return [void]
132
+ def add_marker(label = "")
133
+ return unless @writer && @start_time
134
+
135
+ time = current_time
136
+ @writer.write_marker(time, label)
137
+ @markers << { time: time, label: label }
138
+ end
139
+
140
+ private
141
+
142
+ # Record an interactive terminal session
143
+ #
144
+ # @param output_path [String] Output file path
145
+ # @yield [Recorder] Block for custom recording logic
146
+ # @return [Hash] Recording statistics
147
+ def record_interactive(output_path)
148
+ # Get initial terminal size
149
+ size = get_terminal_size
150
+ width = size[0]
151
+ height = size[1]
152
+
153
+ # Build header
154
+ header = Asciicast::Header.new(
155
+ width: width,
156
+ height: height,
157
+ title: @title,
158
+ idle_time_limit: @idle_time_limit,
159
+ env: capture_environment
160
+ )
161
+
162
+ # Open output file and create writer
163
+ File.open(output_path, "w", encoding: "UTF-8") do |file|
164
+ @writer = Asciicast::Writer.new(file, header)
165
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
166
+ @last_buffer = nil
167
+ @last_event_time = 0.0
168
+
169
+ # Print recording message
170
+ print_recording_started(output_path)
171
+
172
+ begin
173
+ if block_given?
174
+ # Start capture thread
175
+ start_capture_thread
176
+
177
+ # Yield control to block
178
+ yield self
179
+
180
+ # Wait a bit for final captures
181
+ sleep(@capture_interval * 2)
182
+ else
183
+ # Interactive recording with keyboard input
184
+ run_interactive_loop
185
+ end
186
+ ensure
187
+ stop_capture_thread
188
+ @writer.close
189
+ end
190
+
191
+ # Return statistics
192
+ {
193
+ path: output_path,
194
+ duration: @writer.last_event_time,
195
+ event_count: @writer.event_count,
196
+ width: width,
197
+ height: height,
198
+ markers: @markers.length
199
+ }
200
+ end
201
+ end
202
+
203
+ # Record a command execution
204
+ #
205
+ # @param output_path [String] Output file path
206
+ # @return [Hash] Recording statistics
207
+ def record_command(output_path)
208
+ # Get initial terminal size
209
+ size = get_terminal_size
210
+ width = size[0]
211
+ height = size[1]
212
+
213
+ # Build header
214
+ header = Asciicast::Header.new(
215
+ width: width,
216
+ height: height,
217
+ title: @title || @command,
218
+ command: @command,
219
+ idle_time_limit: @idle_time_limit,
220
+ env: capture_environment
221
+ )
222
+
223
+ File.open(output_path, "w", encoding: "UTF-8") do |file|
224
+ @writer = Asciicast::Writer.new(file, header)
225
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
226
+ @last_buffer = nil
227
+ @last_event_time = 0.0
228
+
229
+ print_recording_started(output_path)
230
+
231
+ begin
232
+ # Start capture thread
233
+ start_capture_thread
234
+
235
+ # Execute command and wait for it
236
+ system(@command)
237
+
238
+ # Wait for final output to be captured
239
+ sleep(@capture_interval * 3)
240
+ ensure
241
+ stop_capture_thread
242
+ @writer.close
243
+ end
244
+
245
+ print_recording_finished
246
+
247
+ {
248
+ path: output_path,
249
+ duration: @writer.last_event_time,
250
+ event_count: @writer.event_count,
251
+ width: width,
252
+ height: height,
253
+ markers: @markers.length
254
+ }
255
+ end
256
+ end
257
+
258
+ # Run interactive recording loop
259
+ #
260
+ # @return [void]
261
+ def run_interactive_loop
262
+ # Start capture thread
263
+ start_capture_thread
264
+
265
+ # Set up raw mode for input detection
266
+ puts "\e[33mRecording... Press Ctrl+D to stop, Ctrl+M for marker.\e[0m\n"
267
+
268
+ begin
269
+ loop do
270
+ break if @stop_requested
271
+
272
+ # Check for input (non-blocking)
273
+ if $stdin.respond_to?(:ready?) && $stdin.ready?
274
+ char = $stdin.getc
275
+ handle_input_char(char)
276
+ end
277
+
278
+ # Also check for Ctrl+D or EOF
279
+ if $stdin.eof?
280
+ break
281
+ end
282
+
283
+ sleep(0.05) # Small delay to avoid busy waiting
284
+ end
285
+ rescue Interrupt
286
+ # Ctrl+C pressed
287
+ @stop_requested = true
288
+ rescue EOFError
289
+ # End of input
290
+ @stop_requested = true
291
+ end
292
+ end
293
+
294
+ # Handle a single input character during recording
295
+ #
296
+ # @param char [String] Character pressed
297
+ # @return [void]
298
+ def handle_input_char(char)
299
+ case char
300
+ when "\x04" # Ctrl+D
301
+ @stop_requested = true
302
+ when "\r" # Ctrl+M (same as Enter)
303
+ add_marker
304
+ end
305
+ end
306
+
307
+ # Start the screen capture background thread
308
+ #
309
+ # @return [void]
310
+ def start_capture_thread
311
+ @capture_thread = Thread.new do
312
+ capture_loop
313
+ end
314
+ end
315
+
316
+ # Stop the capture thread
317
+ #
318
+ # @return [void]
319
+ def stop_capture_thread
320
+ @stop_requested = true
321
+
322
+ if @capture_thread
323
+ @capture_thread.join(1.0) # Wait up to 1 second
324
+ @capture_thread.kill if @capture_thread.alive?
325
+ @capture_thread = nil
326
+ end
327
+ end
328
+
329
+ # Main capture loop running in background thread
330
+ #
331
+ # @return [void]
332
+ def capture_loop
333
+ last_width = nil
334
+ last_height = nil
335
+
336
+ until @stop_requested
337
+ begin
338
+ # Skip if paused
339
+ if @state == :paused
340
+ sleep(@capture_interval)
341
+ next
342
+ end
343
+
344
+ # Capture current screen
345
+ buffer = ScreenBuffer.capture
346
+ next unless buffer
347
+
348
+ # Check for resize
349
+ if last_width && last_height
350
+ if buffer.width != last_width || buffer.height != last_height
351
+ emit_resize_event(buffer.width, buffer.height)
352
+ end
353
+ end
354
+
355
+ last_width = buffer.width
356
+ last_height = buffer.height
357
+
358
+ # Generate diff from previous buffer
359
+ if @last_buffer
360
+ diff = buffer.diff(@last_buffer)
361
+ emit_output_event(diff) unless diff.empty?
362
+ else
363
+ # First capture - emit full screen
364
+ output = buffer.to_ansi
365
+ emit_output_event(output) unless output.empty?
366
+ end
367
+
368
+ @last_buffer = buffer
369
+
370
+ sleep(@capture_interval)
371
+ rescue StandardError => e
372
+ # Log error but keep running
373
+ warn "Capture error: #{e.message}" if ENV["DEBUG"]
374
+ sleep(@capture_interval)
375
+ end
376
+ end
377
+ end
378
+
379
+ # Emit an output event with idle time limiting
380
+ #
381
+ # @param data [String] Output data
382
+ # @return [void]
383
+ def emit_output_event(data)
384
+ return if data.nil? || data.empty?
385
+ return unless @writer
386
+
387
+ time = current_time
388
+
389
+ # Apply idle time limit
390
+ if @idle_time_limit > 0 && @last_event_time
391
+ gap = time - @last_event_time
392
+ if gap > @idle_time_limit
393
+ # Adjust time to cap the idle period
394
+ time = @last_event_time + @idle_time_limit
395
+ end
396
+ end
397
+
398
+ @writer.write_output(time, data)
399
+ @last_event_time = time
400
+ end
401
+
402
+ # Emit a resize event
403
+ #
404
+ # @param width [Integer] New width
405
+ # @param height [Integer] New height
406
+ # @return [void]
407
+ def emit_resize_event(width, height)
408
+ return unless @writer
409
+
410
+ time = current_time
411
+ @writer.write_resize(time, width, height)
412
+ end
413
+
414
+ # Get current recording time offset
415
+ #
416
+ # @return [Float] Seconds since recording started
417
+ def current_time
418
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
419
+ end
420
+
421
+ # Get terminal dimensions
422
+ #
423
+ # @return [Array<Integer>] [width, height]
424
+ def get_terminal_size
425
+ if Gem.win_platform? && defined?(Rich::Win32Console)
426
+ size = Rich::Win32Console.get_size
427
+ return size if size
428
+ end
429
+
430
+ # Try IO#winsize
431
+ if $stdout.respond_to?(:winsize)
432
+ height, width = $stdout.winsize
433
+ return [width, height] if width > 0 && height > 0
434
+ end
435
+
436
+ # Default
437
+ [80, 24]
438
+ end
439
+
440
+ # Capture environment variables
441
+ #
442
+ # @return [Hash<String, String>] Environment variable map
443
+ def capture_environment
444
+ result = {}
445
+
446
+ @env_vars.each do |var|
447
+ value = ENV[var]
448
+ result[var] = value if value
449
+ end
450
+
451
+ # Add Windows-specific variables
452
+ result["SHELL"] ||= ENV["COMSPEC"] || "cmd.exe"
453
+ result["TERM"] ||= "xterm-256color"
454
+
455
+ result
456
+ end
457
+
458
+ # Print recording started message
459
+ #
460
+ # @param path [String] Output path
461
+ # @return [void]
462
+ def print_recording_started(path)
463
+ puts "\e[32masciinema-win: Recording started → #{path}\e[0m"
464
+ puts "\e[33mPress Ctrl+D to finish recording.\e[0m"
465
+ puts
466
+ end
467
+
468
+ # Print recording finished message
469
+ #
470
+ # @return [void]
471
+ def print_recording_finished
472
+ puts
473
+ puts "\e[32masciinema-win: Recording finished.\e[0m"
474
+ if @writer
475
+ puts "Duration: #{format("%.2f", @writer.last_event_time)}s"
476
+ puts "Events: #{@writer.event_count}"
477
+ end
478
+ end
479
+ end
480
+ end