From 670813f18e899b6d444eaeece3a01cfab505fb0a Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:41:31 -0400 Subject: [PATCH] feat: disk space watcher + diagnostic bundle export --- .../Services/DiagnosticsBundle.cs | 134 ++++++++++++++++++ src/TeamsISO.App/Services/DiskSpaceWatcher.cs | 100 +++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/TeamsISO.App/Services/DiagnosticsBundle.cs create mode 100644 src/TeamsISO.App/Services/DiskSpaceWatcher.cs diff --git a/src/TeamsISO.App/Services/DiagnosticsBundle.cs b/src/TeamsISO.App/Services/DiagnosticsBundle.cs new file mode 100644 index 0000000..c0297d6 --- /dev/null +++ b/src/TeamsISO.App/Services/DiagnosticsBundle.cs @@ -0,0 +1,134 @@ +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Text; + +namespace TeamsISO.App.Services; + +/// +/// Gathers logs + config + presets + version metadata into a single .zip the +/// operator can attach to a bug report. Surfaced via the "Export diagnostics" +/// button in About. +/// +/// We deliberately do NOT include screenshots or any process/memory dumps — +/// that's outside the scope of a v1 support bundle and would raise privacy +/// flags. The bundle has only files the user already wrote with their TeamsISO +/// usage; nothing here is hidden state. +/// +public static class DiagnosticsBundle +{ + /// + /// Build the bundle and return the path it was written to. + /// Throws on disk failure — the caller toasts/dialogs. + /// + public static string Export() + { + var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss"); + var fileName = $"teamsiso-diagnostics-{ts}.zip"; + var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var downloads = Path.Combine(outDir, "Downloads"); + if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/ + var outPath = Path.Combine(downloads, fileName); + + using var fs = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.None); + using var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false); + + WriteEnvironmentTxt(zip); + TryCopyDirectory(zip, "logs", LogsDirectory); + TryCopyFile(zip, "config.json", AppDataPath("config.json")); + TryCopyFile(zip, "presets.json", LocalAppDataPath("presets.json")); + TryCopyFile(zip, "window.json", LocalAppDataPath("window.json")); + TryCopyFile(zip, "ndi-config.v1.json", NdiConfigPath()); + TryCopyFile(zip, "output-name-template.txt", LocalAppDataPath("output-name-template.txt")); + + return outPath; + } + + private static void WriteEnvironmentTxt(ZipArchive zip) + { + var asm = typeof(DiagnosticsBundle).Assembly; + var version = asm.GetCustomAttribute()?.InformationalVersion + ?? asm.GetName().Version?.ToString() + ?? "unknown"; + var sb = new StringBuilder(); + sb.AppendLine("TeamsISO diagnostic bundle"); + sb.AppendLine($"Generated: {DateTimeOffset.Now:o}"); + sb.AppendLine($"TeamsISO version: {version}"); + sb.AppendLine($".NET runtime: {Environment.Version}"); + sb.AppendLine($"OS: {Environment.OSVersion}"); + sb.AppendLine($"Machine: {Environment.MachineName}"); + sb.AppendLine($"User: {Environment.UserName}"); + sb.AppendLine($"Process bits: {(Environment.Is64BitProcess ? "64" : "32")}"); + sb.AppendLine($"OS bits: {(Environment.Is64BitOperatingSystem ? "64" : "32")}"); + sb.AppendLine($"Working set: {Environment.WorkingSet / (1024 * 1024)} MB"); + sb.AppendLine(); + sb.AppendLine("Files included (when present):"); + sb.AppendLine(" logs/ Serilog rolling daily logs"); + sb.AppendLine(" config.json Engine settings (framerate, NDI groups, etc.)"); + sb.AppendLine(" presets.json Saved operator presets"); + sb.AppendLine(" window.json Last main-window placement"); + sb.AppendLine(" ndi-config.v1.json NDI Access Manager config (group routing)"); + sb.AppendLine(" output-name-template.txt NDI source name template override"); + + var entry = zip.CreateEntry("environment.txt"); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8); + w.Write(sb.ToString()); + } + + private static void TryCopyFile(ZipArchive zip, string entryName, string sourcePath) + { + try + { + if (!File.Exists(sourcePath)) return; + zip.CreateEntryFromFile(sourcePath, entryName); + } + catch + { + // One missing or locked file shouldn't kill the rest of the bundle. + } + } + + private static void TryCopyDirectory(ZipArchive zip, string prefix, string sourceDir) + { + if (!Directory.Exists(sourceDir)) return; + try + { + foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + try + { + var rel = Path.GetRelativePath(sourceDir, file).Replace('\\', '/'); + zip.CreateEntryFromFile(file, $"{prefix}/{rel}"); + } + catch + { + // Skip locked files (e.g., today's actively-written log). + } + } + } + catch + { + // Permission denied on the dir as a whole; nothing more to do. + } + } + + private static string LogsDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TeamsISO", "Logs"); + + private static string LocalAppDataPath(string fileName) => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TeamsISO", fileName); + + private static string AppDataPath(string fileName) => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "TeamsISO", fileName); + + private static string NdiConfigPath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "NDI", "ndi-config.v1.json"); +} diff --git a/src/TeamsISO.App/Services/DiskSpaceWatcher.cs b/src/TeamsISO.App/Services/DiskSpaceWatcher.cs new file mode 100644 index 0000000..0488ac7 --- /dev/null +++ b/src/TeamsISO.App/Services/DiskSpaceWatcher.cs @@ -0,0 +1,100 @@ +using System.IO; +using System.Windows.Threading; +using TeamsISO.App.ViewModels; +using TeamsISO.Engine.Controller; + +namespace TeamsISO.App.Services; + +/// +/// Polls the recording drive's free space every few seconds while recording is +/// on. Surfaces a coral warning toast at the soft threshold, and at the hard +/// threshold auto-disables recording so a long unattended show doesn't fill +/// the disk and crash the host's logger and write paths. +/// +/// Lifecycle is tied to the MainViewModel — instantiate after the VM is wired, +/// dispose with the host. +/// +public sealed class DiskSpaceWatcher : IDisposable +{ + /// Below this, toast a warning each tick. + public static readonly long SoftWarnBytes = 10L * 1024 * 1024 * 1024; // 10 GB + + /// Below this, auto-disable recording to save the show. + public static readonly long HardStopBytes = 1L * 1024 * 1024 * 1024; // 1 GB + + private readonly IIsoController _controller; + private readonly ToastViewModel _toast; + private readonly DispatcherTimer _timer; + private DateTimeOffset _lastWarnAt = DateTimeOffset.MinValue; + + public DiskSpaceWatcher(IIsoController controller, ToastViewModel toast, Dispatcher dispatcher) + { + _controller = controller; + _toast = toast; + _timer = new DispatcherTimer(DispatcherPriority.Background, dispatcher) + { + Interval = TimeSpan.FromSeconds(5), + }; + _timer.Tick += OnTick; + _timer.Start(); + } + + private void OnTick(object? sender, EventArgs e) + { + if (!_controller.RecordingEnabled) return; + var dir = _controller.RecordingDirectory; + if (string.IsNullOrEmpty(dir)) return; + + long freeBytes; + try + { + // DriveInfo wants a drive letter / mount root. Walk up the path + // until we hit a directory that exists (the recording dir might + // not have been created yet by the first ISO). + var probe = dir; + while (!Directory.Exists(probe)) + { + var parent = Path.GetDirectoryName(probe); + if (string.IsNullOrEmpty(parent) || parent == probe) break; + probe = parent; + } + if (!Directory.Exists(probe)) return; + var drive = new DriveInfo(Path.GetPathRoot(probe) ?? probe); + freeBytes = drive.AvailableFreeSpace; + } + catch + { + // If the drive query fails (network share dropped, weird path), + // skip this tick rather than spam the toast with errors. + return; + } + + if (freeBytes < HardStopBytes) + { + _controller.SetRecording(false, dir); + _toast.Warn($"Recording AUTO-STOPPED — drive has only {FormatBytes(freeBytes)} free"); + _lastWarnAt = DateTimeOffset.UtcNow; + } + else if (freeBytes < SoftWarnBytes) + { + // Throttle the soft warning so we don't toast every 5s for the + // last hour of disk space. + if (DateTimeOffset.UtcNow - _lastWarnAt < TimeSpan.FromMinutes(2)) return; + _toast.Warn($"Recording drive has {FormatBytes(freeBytes)} free — winding down soon"); + _lastWarnAt = DateTimeOffset.UtcNow; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):F1} GB"; + if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):F0} MB"; + return $"{bytes} bytes"; + } + + public void Dispose() + { + _timer.Stop(); + _timer.Tick -= OnTick; + } +}