par_term/session_logger/core.rs
1//! Core [`SessionLogger`] struct, state management, and recording methods.
2//!
3//! See the [module-level documentation](super) for information about security
4//! considerations and password redaction.
5//!
6//! Format-specific finalization (HTML headers/footers, asciicast serialization)
7//! lives in `super::format_writers`.
8
9use crate::config::SessionLogFormat;
10use crate::session_logger::writers::{html_escape, strip_ansi_escapes};
11use anyhow::{Context, Result};
12use chrono::{Local, Utc};
13use par_term_emu_core_rust::terminal::{RecordingEvent, RecordingEventType, RecordingSession};
14use parking_lot::Mutex;
15use std::fs::{File, OpenOptions};
16use std::io::{BufWriter, Write};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20/// Marker text written to the log when input is redacted during a password prompt.
21pub(super) const REDACTION_MARKER: &str = "[INPUT REDACTED - echo off]";
22
23/// Common password prompt patterns (case-insensitive matching).
24///
25/// These patterns are matched against terminal output (after stripping ANSI
26/// escape sequences) to detect when the user is being asked for a password.
27/// The match is performed on the last line of each output chunk.
28///
29/// SEC-009: Includes patterns for common non-English password prompts to reduce
30/// redaction gaps for internationalized systems and multilingual users.
31pub(super) const PASSWORD_PROMPT_PATTERNS: &[&str] = &[
32 "password:",
33 "password for",
34 "passwd:",
35 "[sudo]",
36 "passphrase:",
37 "passphrase for",
38 "enter pin",
39 "enter passphrase",
40 "enter password",
41 "old password:",
42 "new password:",
43 "retype password:",
44 "confirm password:",
45 "current password:",
46 "verification code:",
47 "login password:",
48 "ldap password:",
49 "key password:",
50 "decryption password:",
51 "encryption password:",
52 "(current) unix password:",
53 "token:",
54 // Two-factor / MFA prompts
55 "authenticator code:",
56 "2fa code:",
57 "otp:",
58 "one-time password:",
59 "one time password:",
60 "security code:",
61 "totp:",
62 // API key / secret prompts (interactive tools that prompt for credentials)
63 "api key:",
64 "api secret:",
65 "secret key:",
66 "access key:",
67 "access token:",
68 "secret token:",
69 "private key:",
70 "client secret:",
71 "auth token:",
72 "bearer token:",
73 // SSH / GPG passphrases
74 "enter passphrase for key",
75 "key passphrase:",
76 "gpg passphrase:",
77 // Database / service credential prompts
78 "db password:",
79 "database password:",
80 "mysql password:",
81 "postgres password:",
82 "redis password:",
83 // Cloud / vault prompts
84 "vault token:",
85 "vault password:",
86 "aws secret",
87 "azure secret",
88 // SEC-009: Non-English password prompt patterns.
89 // Covers Portuguese, Spanish, French, Russian, German, Japanese, Korean,
90 // Chinese, Italian, Dutch, Polish, Turkish, Hindi, Arabic, and Hebrew.
91 // All matched case-insensitively.
92 // Portuguese
93 "senha:",
94 "digite a senha",
95 "informe a senha",
96 "senha atual:",
97 "nova senha:",
98 "confirme a senha",
99 // Spanish
100 "contrase\u{00f1}a:", // contraseña:
101 "contrasena:", // contrasena: (ASCII fallback)
102 "introduzca la contrase\u{00f1}a",
103 "contrase\u{00f1}a actual:",
104 "nueva contrase\u{00f1}a:",
105 "confirme la contrase\u{00f1}a",
106 // French
107 "mot de passe:",
108 "entrez le mot de passe",
109 "mot de passe actuel:",
110 "nouveau mot de passe:",
111 "confirmez le mot de passe",
112 // Russian
113 "\u{043f}\u{0430}\u{0440}\u{043e}\u{043b}\u{044c}:", // пароль:
114 "\u{0432}\u{0432}\u{0435}\u{0434}\u{0438}\u{0442}\u{0435} \u{043f}\u{0430}\u{0440}\u{043e}\u{043b}\u{044c}", // введите пароль
115 "\u{043d}\u{043e}\u{0432}\u{044b}\u{0439} \u{043f}\u{0430}\u{0440}\u{043e}\u{043b}\u{044c}:", // новый пароль:
116 // German
117 "passwort:",
118 "geben sie das passwort",
119 "passwort eingeben",
120 "neues passwort:",
121 "passwort best\u{00e4}tigen", // passwort bestätigen
122 // Japanese
123 "\u{30d1}\u{30b9}\u{30ef}\u{30fc}\u{30c9}:", // パスワード:
124 "\u{30d1}\u{30b9}\u{30ef}\u{30fc}\u{30c9}\u{5165}\u{529b}", // パスワード入力
125 // Korean
126 "암호:", // 암호:
127 "비밀번호:", // 비밀번호:
128 // Chinese (Simplified)
129 "密码:", // 密码:
130 "请输入密码", // 请输入密码
131 // Italian
132 "password:", // (already covered by English "password:" — case-insensitive)
133 "inserire la password",
134 "nuova password:",
135 // Dutch
136 "wachtwoord:",
137 "voer het wachtwoord",
138 "nieuw wachtwoord:",
139 // Polish
140 "has\u{0142}o:", // hasło:
141 "wprowadź has\u{0142}o", // wprowadź hasło
142 "nowe has\u{0142}o:", // nowe hasło:
143 // Turkish
144 "ş}ifre:", // şifre: (note: also covered by lowercase match of "Şifre:")
145 "parola:",
146 // Hindi
147 "प}ासवर्द}:", // पासवर्ड: (Hindi often uses English loanword)
148 // Arabic
149 "ك}لمة ا}لمرور}:", // كلمة المرور:
150 // Hebrew
151 "ס}יסמא}:", // סיסמא:
152];
153
154/// Sensitive output line heuristics (case-insensitive substring matching).
155///
156/// SEC-006: These patterns detect terminal *output* lines that are likely to
157/// contain sensitive values being printed (e.g. `export API_KEY=abc123`,
158/// `aws_secret_access_key = ...`). When a line matches, the logger emits a
159/// warning comment in the log rather than the raw content.
160///
161/// Unlike password prompt detection (which suppresses subsequent *input*),
162/// these patterns trigger on *output* data — they redact or annotate the
163/// output line itself.
164///
165/// # Limitations
166///
167/// This is a heuristic. It cannot catch all forms of sensitive output (e.g.
168/// values printed without a recognisable key name, or base64-encoded secrets).
169/// Users who regularly work with credentials in the terminal should disable
170/// session logging for those sessions.
171pub(super) const SENSITIVE_OUTPUT_PATTERNS: &[&str] = &[
172 // Shell variable export of credential-like names
173 "export aws_access_key",
174 "export aws_secret",
175 "export api_key",
176 "export api_secret",
177 "export auth_token",
178 "export access_token",
179 "export secret_key",
180 "export private_key",
181 "export client_secret",
182 "export database_url",
183 "export db_password",
184 "export gh_token",
185 "export github_token",
186 "export gitlab_token",
187 "export npm_token",
188 "export pypi_token",
189 // AWS credential file / env output patterns
190 "aws_access_key_id",
191 "aws_secret_access_key",
192 "aws_session_token",
193 // Generic key=value patterns for common secret variable names
194 "api_key=",
195 "apikey=",
196 "api_secret=",
197 "access_token=",
198 "auth_token=",
199 "secret_key=",
200 "private_key=",
201 "client_secret=",
202 // CI/CD and hosting service tokens
203 "github_token=",
204 "gh_token=",
205 "heroku_api_key=",
206 "heroku_api_token=",
207 "npm_token=",
208 "pypi_token=",
209 "gitlab_token=",
210 "circleci_token=",
211 // HTTP Authorization Bearer tokens (e.g. from curl -v output or httpie)
212 "bearer ",
213 // .env file content echoed to terminal
214 "dotenv",
215 // Token/key output from CLI tools
216 "-----begin rsa private key-----",
217 "-----begin ec private key-----",
218 "-----begin openssh private key-----",
219 "-----begin pgp private key block-----",
220];
221
222/// Session logger that records terminal output to files.
223///
224/// The logger captures PTY output with timestamps and can export
225/// to multiple formats. It uses buffered writes for performance.
226///
227/// See the [module-level documentation](super) for information about
228/// sensitive data filtering.
229pub struct SessionLogger {
230 /// Whether logging is currently active
231 pub(super) active: bool,
232 /// The log format to use
233 pub(super) format: SessionLogFormat,
234 /// Output file path
235 pub(super) output_path: PathBuf,
236 /// Buffered writer for the log file
237 pub(super) writer: Option<BufWriter<File>>,
238 /// Recording session data (for asciicast format)
239 pub(super) recording: Option<RecordingSession>,
240 /// Recording start time (for relative timestamps)
241 pub(super) start_time: std::time::Instant,
242 /// Terminal dimensions
243 pub(super) dimensions: (usize, usize),
244 /// Session title
245 pub(super) title: Option<String>,
246 /// Whether password redaction is enabled (heuristic prompt detection)
247 pub(super) redact_passwords: bool,
248 /// First write error seen since logging started, if any.
249 ///
250 /// A transcript that stops growing mid-session (disk full, log file deleted)
251 /// is indistinguishable from an idle one, so the failure is latched here and
252 /// reported through [`SessionLogger::is_active`] rather than discarded.
253 pub(super) write_error: Option<String>,
254 /// Whether the logger has detected a password prompt in recent output
255 /// and is currently suppressing input recording.
256 pub(super) password_prompt_active: bool,
257 /// Whether echo is externally known to be suppressed (e.g., PTY echo off).
258 /// When true, input is always redacted regardless of prompt detection.
259 pub(super) echo_suppressed: bool,
260 /// Whether a redaction marker has already been emitted for the current
261 /// suppression period (to avoid flooding the log with repeated markers).
262 pub(super) redaction_marker_emitted: bool,
263}
264
265impl SessionLogger {
266 /// Create a new session logger.
267 ///
268 /// # Arguments
269 /// * `format` - The output format to use
270 /// * `log_dir` - Directory where log files are stored
271 /// * `dimensions` - Terminal dimensions (cols, rows)
272 /// * `title` - Optional session title
273 pub fn new(
274 format: SessionLogFormat,
275 log_dir: &Path,
276 dimensions: (usize, usize),
277 title: Option<String>,
278 ) -> Result<Self> {
279 // Generate filename with timestamp
280 let timestamp = Local::now().format("%Y%m%d_%H%M%S");
281 let filename = format!("session_{}.{}", timestamp, format.extension());
282 let output_path = log_dir.join(filename);
283
284 log::info!(
285 "Creating session logger: {:?} (format: {:?})",
286 output_path,
287 format
288 );
289
290 // Create the log file with restrictive permissions (owner read/write only)
291 // On Unix, use mode 0o600 to prevent world-readable session logs
292 // On Windows, file permissions work differently but this is still safe
293 let mut opts = OpenOptions::new();
294 opts.write(true).create(true).truncate(true);
295 #[cfg(unix)]
296 {
297 use std::os::unix::fs::OpenOptionsExt;
298 opts.mode(0o600);
299 }
300 let file = opts
301 .open(&output_path)
302 .with_context(|| format!("Failed to create session log file: {:?}", output_path))?;
303 let writer = BufWriter::with_capacity(8192, file); // 8KB buffer
304
305 // Initialize recording session for asciicast format
306 let recording = if format == SessionLogFormat::Asciicast {
307 let mut env = std::collections::HashMap::new();
308 env.insert("TERM".to_string(), "xterm-256color".to_string());
309 env.insert("COLS".to_string(), dimensions.0.to_string());
310 env.insert("ROWS".to_string(), dimensions.1.to_string());
311
312 Some(RecordingSession {
313 id: uuid::Uuid::new_v4().to_string(),
314 created_at: Utc::now().timestamp_millis() as u64,
315 initial_size: dimensions,
316 env,
317 events: Vec::new(),
318 duration: 0,
319 title: title
320 .clone()
321 .unwrap_or_else(|| "Terminal Recording".to_string()),
322 })
323 } else {
324 None
325 };
326
327 Ok(Self {
328 active: false,
329 format,
330 output_path,
331 writer: Some(writer),
332 recording,
333 start_time: std::time::Instant::now(),
334 dimensions,
335 title,
336 redact_passwords: true, // Enabled by default for safety
337 write_error: None,
338 password_prompt_active: false,
339 echo_suppressed: false,
340 redaction_marker_emitted: false,
341 })
342 }
343
344 /// Start logging.
345 pub fn start(&mut self) -> Result<()> {
346 if self.active {
347 return Ok(());
348 }
349
350 // A logger stopped by a write failure keeps the same unwritable file
351 // handle, so restarting it would fail again on the next byte. Surface the
352 // original error instead of pretending the restart worked.
353 if let Some(ref e) = self.write_error {
354 return Err(anyhow::anyhow!("session log is not writable: {}", e));
355 }
356
357 self.active = true;
358 self.start_time = std::time::Instant::now();
359
360 // Write format-specific header / startup comment.
361 match self.format {
362 SessionLogFormat::Html => {
363 self.write_html_header()?;
364 }
365 SessionLogFormat::Plain => {
366 // SEC-004: Write a startup warning so readers of the log file are
367 // aware of the redaction limitations. This comment appears at the
368 // top of every plain-text session log.
369 self.write_plain_redaction_warning()?;
370 }
371 SessionLogFormat::Asciicast => {
372 // Asciicast format: the header is written during finalization.
373 // No startup banner is added here; warnings are in the log file
374 // at the application level via log::warn!.
375 }
376 }
377
378 log::info!("Session logging started: {:?}", self.output_path);
379 Ok(())
380 }
381
382 /// Stop logging and finalize the log file.
383 pub fn stop(&mut self) -> Result<PathBuf> {
384 if !self.active {
385 return Ok(self.output_path.clone());
386 }
387
388 self.active = false;
389
390 // Finalize based on format (delegates to format_writers module)
391 super::format_writers::finalize_format(self)?;
392
393 // Flush and close the writer
394 if let Some(mut writer) = self.writer.take() {
395 writer
396 .flush()
397 .with_context(|| format!("Failed to flush session log: {:?}", self.output_path))?;
398 }
399
400 log::info!("Session logging stopped: {:?}", self.output_path);
401 Ok(self.output_path.clone())
402 }
403
404 /// Enable or disable password prompt detection and input redaction.
405 ///
406 /// When enabled, the logger monitors terminal output for common password
407 /// prompt patterns and suppresses input recording during password entry.
408 /// Enabled by default.
409 pub fn set_redact_passwords(&mut self, enabled: bool) {
410 self.redact_passwords = enabled;
411 }
412
413 /// Check whether password redaction is enabled.
414 pub fn redact_passwords(&self) -> bool {
415 self.redact_passwords
416 }
417
418 /// Externally signal that echo is suppressed (e.g., PTY echo off).
419 ///
420 /// When set to `true`, all input recording is suppressed regardless of
421 /// prompt detection. This is useful when the caller has access to the
422 /// terminal's echo mode state.
423 ///
424 /// Call with `false` when echo is re-enabled.
425 pub fn set_echo_suppressed(&mut self, suppressed: bool) {
426 if self.echo_suppressed != suppressed {
427 self.echo_suppressed = suppressed;
428 if !suppressed {
429 // Echo re-enabled; reset the marker flag so a new redaction
430 // period will emit a fresh marker.
431 self.redaction_marker_emitted = false;
432 }
433 }
434 }
435
436 /// Check whether echo is externally marked as suppressed.
437 pub fn echo_suppressed(&self) -> bool {
438 self.echo_suppressed
439 }
440
441 /// Record output data from the terminal.
442 pub fn record_output(&mut self, data: &[u8]) {
443 if !self.active {
444 return;
445 }
446
447 // Check for password prompts in the output if redaction is enabled.
448 // This must run before filtering so that subsequent input is suppressed.
449 if self.redact_passwords {
450 self.check_for_password_prompt(data);
451 }
452
453 // SEC-006: Check for sensitive credential output patterns line-by-line.
454 // Lines that match known sensitive-data heuristics are replaced with a
455 // redaction annotation before writing to the log.
456 let data_to_write: Vec<u8> = if self.redact_passwords {
457 self.filter_sensitive_output(data)
458 } else {
459 data.to_vec()
460 };
461 let data = data_to_write.as_slice();
462
463 let elapsed = self.start_time.elapsed().as_millis() as u64;
464
465 match self.format {
466 SessionLogFormat::Plain => {
467 // Strip ANSI escape sequences and write plain text
468 let text = strip_ansi_escapes(data);
469 self.write_bytes(text.as_bytes());
470 }
471 SessionLogFormat::Html => {
472 // Convert to HTML (basic escaping for now)
473 let text = String::from_utf8_lossy(data);
474 let escaped = html_escape(&text);
475 self.write_bytes(escaped.as_bytes());
476 }
477 SessionLogFormat::Asciicast => {
478 // Add event to recording
479 if let Some(ref mut recording) = self.recording {
480 recording.events.push(RecordingEvent {
481 timestamp: elapsed,
482 event_type: RecordingEventType::Output,
483 data: data.to_vec(),
484 metadata: None,
485 });
486 recording.duration = elapsed;
487 }
488 }
489 }
490 }
491
492 /// Record input data (keyboard input).
493 ///
494 /// When password redaction is active (either via prompt detection or
495 /// explicit echo suppression), input data is replaced with a redaction
496 /// marker. A newline or carriage return in the input data signals the
497 /// end of a password entry, clearing the prompt-detected suppression.
498 pub fn record_input(&mut self, data: &[u8]) {
499 if !self.active {
500 return;
501 }
502
503 let is_suppressed = self.echo_suppressed || self.password_prompt_active;
504
505 if is_suppressed && self.redact_passwords {
506 // Check if input contains a newline (password entry complete)
507 let has_newline = data.iter().any(|&b| b == b'\n' || b == b'\r');
508
509 // Emit a single redaction marker per suppression period
510 if !self.redaction_marker_emitted {
511 self.emit_redaction_marker();
512 self.redaction_marker_emitted = true;
513 }
514
515 if has_newline {
516 // Password entry complete; clear prompt-based suppression.
517 // (echo_suppressed is managed externally and not cleared here.)
518 self.password_prompt_active = false;
519 self.redaction_marker_emitted = false;
520 }
521 return;
522 }
523
524 // Only asciicast records input
525 if self.format == SessionLogFormat::Asciicast {
526 let elapsed = self.start_time.elapsed().as_millis() as u64;
527 if let Some(ref mut recording) = self.recording {
528 recording.events.push(RecordingEvent {
529 timestamp: elapsed,
530 event_type: RecordingEventType::Input,
531 data: data.to_vec(),
532 metadata: None,
533 });
534 recording.duration = elapsed;
535 }
536 }
537 }
538
539 /// Record a terminal resize event.
540 pub fn record_resize(&mut self, cols: usize, rows: usize) {
541 if !self.active {
542 return;
543 }
544
545 self.dimensions = (cols, rows);
546
547 // Only asciicast records resize events
548 if self.format == SessionLogFormat::Asciicast {
549 let elapsed = self.start_time.elapsed().as_millis() as u64;
550 if let Some(ref mut recording) = self.recording {
551 recording.events.push(RecordingEvent {
552 timestamp: elapsed,
553 event_type: RecordingEventType::Resize,
554 data: Vec::new(),
555 metadata: Some((cols, rows)),
556 });
557 recording.duration = elapsed;
558 }
559 }
560 }
561
562 /// Check if logging is active.
563 ///
564 /// A write failure clears `active`, so this reflects writer health and not
565 /// just the user's last toggle: a transcript that stopped growing mid-session
566 /// must not keep reporting itself as being recorded.
567 pub fn is_active(&self) -> bool {
568 self.active
569 }
570
571 /// The write failure that stopped logging, if any.
572 pub fn write_error(&self) -> Option<&str> {
573 self.write_error.as_deref()
574 }
575
576 /// Write to the log file, stopping logging on the first failure.
577 ///
578 /// Logging is not retried: once the file is gone or the disk is full every
579 /// subsequent write fails too, and the transcript already has a hole in it.
580 fn write_bytes(&mut self, bytes: &[u8]) {
581 let Some(ref mut writer) = self.writer else {
582 return;
583 };
584 if let Err(e) = writer.write_all(bytes) {
585 let msg = e.to_string();
586 log::error!(
587 "Session log write failed for {:?}: {} — logging stopped",
588 self.output_path,
589 msg
590 );
591 self.write_error = Some(msg);
592 self.active = false;
593 }
594 }
595
596 /// SEC-006: Check whether a single stripped line matches any sensitive
597 /// output heuristic.
598 ///
599 /// Exposed as `pub(super)` for unit-test access from `tests.rs`.
600 pub(super) fn line_is_sensitive(line: &str) -> bool {
601 let lower = line.to_ascii_lowercase();
602 SENSITIVE_OUTPUT_PATTERNS.iter().any(|p| lower.contains(p))
603 }
604
605 /// Get the output path.
606 pub fn output_path(&self) -> &PathBuf {
607 &self.output_path
608 }
609
610 /// Flush buffered data to disk.
611 pub fn flush(&mut self) -> Result<()> {
612 if let Some(ref mut writer) = self.writer {
613 writer
614 .flush()
615 .with_context(|| format!("Failed to flush session log: {:?}", self.output_path))?;
616 }
617 Ok(())
618 }
619
620 // === Private helper methods ===
621
622 /// SEC-006: Filter sensitive credential patterns from raw PTY output.
623 ///
624 /// Each line in `data` is stripped of ANSI escapes and checked against
625 /// [`SENSITIVE_OUTPUT_PATTERNS`]. Lines that match are replaced with a
626 /// `[OUTPUT REDACTED - sensitive data heuristic]` marker in the returned
627 /// byte vector. Non-matching lines are passed through unchanged (using the
628 /// *original* bytes, including ANSI codes, so that HTML/asciicast formats
629 /// preserve colour).
630 ///
631 /// The check operates on ANSI-stripped content (case-insensitive) to
632 /// avoid coloured prompt text defeating the heuristic.
633 fn filter_sensitive_output(&self, data: &[u8]) -> Vec<u8> {
634 const SENSITIVE_OUTPUT_MARKER: &str =
635 "[OUTPUT REDACTED - sensitive data heuristic matched]\n";
636
637 // Fast path: strip ANSI from the whole chunk and check if any line
638 // could be sensitive. If not, return the original bytes unchanged.
639 let stripped = strip_ansi_escapes(data);
640 let any_sensitive = stripped.lines().any(Self::line_is_sensitive);
641 if !any_sensitive {
642 return data.to_vec();
643 }
644
645 // Slow path: reconstruct the output, replacing sensitive lines.
646 // We work on the ANSI-stripped text for the per-line decision but
647 // need to write back sanitised content; since the raw bytes may have
648 // multi-byte ANSI sequences that don't align 1:1 with stripped lines,
649 // we rebuild the output from the stripped text (forgoing colour for
650 // the filtered chunk — acceptable given the security trade-off).
651 let mut result = Vec::with_capacity(data.len());
652 for line in stripped.lines() {
653 if Self::line_is_sensitive(line) {
654 result.extend_from_slice(SENSITIVE_OUTPUT_MARKER.as_bytes());
655 } else {
656 result.extend_from_slice(line.as_bytes());
657 result.push(b'\n');
658 }
659 }
660 // Preserve trailing newline status from original stripped text.
661 if !stripped.ends_with('\n') && result.last() == Some(&b'\n') {
662 result.pop();
663 }
664 result
665 }
666
667 /// Check terminal output for password prompt patterns.
668 ///
669 /// Examines the last line of the output data (after stripping ANSI escape
670 /// sequences) for common password prompt patterns. If a match is found,
671 /// sets `password_prompt_active` to suppress input recording.
672 fn check_for_password_prompt(&mut self, data: &[u8]) {
673 let text = strip_ansi_escapes(data);
674 // Get the last non-empty line (prompts are typically the last thing printed)
675 let last_line = text
676 .lines()
677 .rev()
678 .find(|line| !line.trim().is_empty())
679 .unwrap_or("")
680 .to_ascii_lowercase();
681
682 if last_line.is_empty() {
683 return;
684 }
685
686 if PASSWORD_PROMPT_PATTERNS
687 .iter()
688 .any(|pattern| last_line.contains(pattern))
689 && !self.password_prompt_active
690 {
691 self.password_prompt_active = true;
692 self.redaction_marker_emitted = false;
693 }
694 }
695
696 /// Emit a redaction marker into the recording/log.
697 fn emit_redaction_marker(&mut self) {
698 if self.format == SessionLogFormat::Asciicast {
699 let elapsed = self.start_time.elapsed().as_millis() as u64;
700 if let Some(ref mut recording) = self.recording {
701 recording.events.push(RecordingEvent {
702 timestamp: elapsed,
703 event_type: RecordingEventType::Input,
704 data: REDACTION_MARKER.as_bytes().to_vec(),
705 metadata: None,
706 });
707 recording.duration = elapsed;
708 }
709 }
710 }
711}
712
713impl Drop for SessionLogger {
714 fn drop(&mut self) {
715 if self.active {
716 let _ = self.stop();
717 }
718 }
719}
720
721/// Thread-safe wrapper for SessionLogger.
722///
723/// Uses `parking_lot::Mutex` because all access is from sync contexts (the winit
724/// event loop and background std threads). The non-async, non-poisoning API of
725/// `parking_lot` is sufficient and avoids the overhead of `tokio::sync::Mutex`.
726pub type SharedSessionLogger = Arc<Mutex<Option<SessionLogger>>>;
727
728/// Create a new shared session logger.
729pub fn create_shared_logger() -> SharedSessionLogger {
730 Arc::new(Mutex::new(None))
731}