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;
+ }
+}