diff --git a/CHANGELOG.md b/CHANGELOG.md index d11fc93..cc7468a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.1.0-alpha.3] - 2026-09-30 + +Still alpha versus **0.1.0-alpha.2**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line. + +### Added + +- Avalonia UI crash dialog for unhandled dispatcher, AppDomain, and task exceptions. The report is copyable and written as `crash-*.txt` under the product logs folder (`%AppData%/MaksIT/LTO Backup/logs`, and the Linux application-data equivalent). +- Flatpak desktop file and AppStream metainfo in `data/` (`com.maks_it.ltobackup`). README documents user and system `flatpak install`. +- Release publish is self-contained for `win-x64` and `linux-x64`. The portable zip stays win-x64. `WindowsInstaller` and `FlatpakPack` run in the release engine. GitHub release requires the zip, setup exe, and Flatpak; cleanup keeps those three. + +### Changed + +- Avalonia **12.1.2** (was 12.1.0). +- Linux UI stays on X11/XWayland (`UsePlatformDetect` only). Avalonia 12.1.2 native Wayland still hangs on GNOME `xdg_toplevel.configure(0, 0)` when launched from the GNOME app icon. +- Flatpak app id is lowercase `com.maks_it.ltobackup` (replaces `com.maks_it.LtoBackup`). Uninstall the old id before installing a new bundle. Finish args include `--device=all` (tape) and `--filesystem=home`. AppStream `` is stamped from the same `Directory.Build.props` version as the zip and setup exe, so `flatpak info` matches the bundle file name. +- Windows installer build also links `WixToolset.Util.wixext`. +- Vendored RepoUtils platform plugins: `CollectCoverage`, `ContainerEngineProbe`, `DiscoverPackageArtifacts`. + +### Removed + +- Unused npm pack plugins (`NpmBuild`, `NpmJestTest`, `NpmPack`, `NpmPackageSupport`, `NpmPublish`, `NpmReleaseVersion`). `NpmPublish` stays disabled in release settings. + ## [0.1.0-alpha.2] - 2026-09-06 Still alpha versus **0.1.0-alpha.1**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line. diff --git a/Directory.Build.props b/Directory.Build.props index f9ced42..5429a3d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,6 @@ latest enable enable - 0.1.0-alpha.2 + 0.1.0-alpha.3 diff --git a/README.md b/README.md index c1eab9f..6c27ae7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MaksIT.LTO.Backup -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-39.3%25-yellow) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-28.7%25-yellow) -![Method Coverage](https://img.shields.io/badge/Method%20Coverage-49.7%25-yellowgreen) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-39.8%25-yellow) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-29.9%25-yellow) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-49.5%25-yellowgreen) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/License-GPLv2-blue) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6) @@ -22,7 +22,7 @@ Device access modes: - **Physical** — real drive/library on **Windows** (`\\.\Tape0`, `\\.\Changer0`; admin) or **Linux** (`/dev/nst0`, `/dev/sg*`; `tape`/`disk` group) - **Emulated** — file-backed drive + library (no hardware; primary CI / no-hardware path) -> Current line is **`0.1.0-alpha.2`** (prerelease, not production-ready). Emulator coverage is the primary validation path; physical tape/changer and Linux SMB are not hardware-proven yet. Use at your own risk. +> Current line is **`0.1.0-alpha.3`** (prerelease, not production-ready). Emulator coverage is the primary validation path; physical tape/changer and Linux SMB are not hardware-proven yet. Use at your own risk. --- @@ -237,6 +237,32 @@ Vendored under `utils/` (from MaksIT RepoUtils community): GitHub assets are siblings: portable `maksit-lto-backup-{version}.zip` (win-x64), Windows setup exe (Avalonia UI), and Flatpak (Avalonia UI). The installer and Flatpak are not inside the zip. On Windows the Flatpak bundle is built via WSL Debian. +### Linux (Flatpak) + +GitHub releases include `maksit-lto-backup-{version}.flatpak` (Avalonia UI). + +**User** (this account only): + +```bash +flatpak install --user ./maksit-lto-backup-{version}.flatpak +flatpak run com.maks_it.ltobackup +``` + +**System** (all users): + +```bash +sudo flatpak install --system ./maksit-lto-backup-{version}.flatpak +flatpak run com.maks_it.ltobackup +``` + +Uninstall: `flatpak uninstall --user com.maks_it.ltobackup` or `sudo flatpak uninstall --system com.maks_it.ltobackup`. + +The previous id `com.maks_it.LtoBackup` is replaced by this lowercase id. Uninstall the old app before installing the new bundle if it was installed. + +If GNOME or KDE does not show a launcher icon, `flatpak run` may warn that `/var/lib/flatpak/exports/share` and `~/.local/share/flatpak/exports/share` are not on `XDG_DATA_DIRS`. Log out and back in once so the session picks up those paths. + +Linux uses X11/XWayland (Avalonia native Wayland still hangs on GNOME). The sandbox grants `--device=all` for tape and `--filesystem=home` for backup paths. AppStream and the desktop file live in [`data/`](data/). + Settings: - `utils/engines/test/scriptSettings.json` diff --git a/data/com.maks_it.ltobackup.desktop b/data/com.maks_it.ltobackup.desktop new file mode 100644 index 0000000..ebd4a0a --- /dev/null +++ b/data/com.maks_it.ltobackup.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=LTO Backup +GenericName=Tape backup +Comment=LTO tape backup and restore +Exec=maksit-lto-backup +Icon=com.maks_it.ltobackup +StartupWMClass=com.maks_it.ltobackup +StartupNotify=true +Terminal=false +Categories=Utility;Archiving; +Keywords=lto;tape;backup;restore; diff --git a/data/com.maks_it.ltobackup.metainfo.xml b/data/com.maks_it.ltobackup.metainfo.xml new file mode 100644 index 0000000..43660f3 --- /dev/null +++ b/data/com.maks_it.ltobackup.metainfo.xml @@ -0,0 +1,55 @@ + + + com.maks_it.ltobackup + LTO Backup + LTO tape backup and restore + + MaksIT + + CC0-1.0 + GPL-2.0 + +

Run LTO tape backup and restore from the desktop. Inventory a drive or library, start jobs, and watch progress without a vendor GUI.

+

The Flatpak is the Avalonia UI. Tape devices need host SCSI/sg access; backup destinations use the home folder.

+
    +
  • Drive and library inventory
  • +
  • Backup and restore jobs
  • +
  • Settings and a worker/service monitor
  • +
+
+ com.maks_it.ltobackup.desktop + + com.maks_it.LtoBackup + + + com.maks_it.LtoBackup + + https://maks-it.com/ + https://github.com/MAKS-IT-COM/maksit-lto-backup/issues + https://github.com/MAKS-IT-COM/maksit-lto-backup + https://www.buymeacoffee.com/maksitcom + + #33A5CF + #003D7A + + + + keyboard + pointing + 768 + + + + https://github.com/MAKS-IT-COM/maksit-lto-backup/blob/v0.1.0-alpha.3/CHANGELOG.md + +

Crash dialog with a copyable report and crash log. Linux Flatpak uses X11/XWayland, lowercase app id com.maks_it.ltobackup, and the same release version as the zip and setup exe.

+
+
+ + https://github.com/MAKS-IT-COM/maksit-lto-backup/blob/v0.1.0-alpha.2/CHANGELOG.md + +

Linux Flatpak uses X11/XWayland from the GNOME app icon and a lowercase app id. GitHub ships a sibling .flatpak of the Avalonia UI.

+
+
+
+
diff --git a/src/MaksIT.LTO.Backup.Shared/ErrorReport.cs b/src/MaksIT.LTO.Backup.Shared/ErrorReport.cs new file mode 100644 index 0000000..13f1cf4 --- /dev/null +++ b/src/MaksIT.LTO.Backup.Shared/ErrorReport.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + + +namespace MaksIT.LTO.Backup.Shared; + + +/// Formats unhandled exceptions for a copyable dialog and a crash log file. +public static class ErrorReport { + public static string Capture(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); + var body = Format(exception); + var path = TryWrite(body); + if (string.IsNullOrWhiteSpace(path)) + return body; + return body + Environment.NewLine + Environment.NewLine + "Log: " + path; + } + + public static string Format(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); + var text = new StringBuilder(); + text.AppendLine("LTO Backup " + Version()); + text.AppendLine("MaksIT"); + text.AppendLine(DateTimeOffset.UtcNow.ToString("u")); + text.AppendLine(RuntimeInformation.OSDescription); + text.AppendLine(RuntimeInformation.FrameworkDescription); + text.AppendLine((Environment.Is64BitProcess ? "64-bit" : "32-bit") + " process"); + text.AppendLine(); + AppendException(text, exception); + return text.ToString().TrimEnd(); + } + + public static string? TryWrite(string report) { + try { + var dir = UserSettingsPath.LogsDirectory(ConfigurationFileService.ProductFolder); + Directory.CreateDirectory(dir); + var name = "crash-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Environment.ProcessId + ".txt"; + var path = Path.Combine(dir, name); + File.WriteAllText(path, report ?? ""); + return path; + } + catch { + return null; + } + } + + private static string Version() { + var version = Assembly.GetEntryAssembly()?.GetName().Version; + return version is null ? "" : version.ToString(); + } + + private static void AppendException(StringBuilder text, Exception exception) { + var seen = new HashSet(); + var current = exception; + var depth = 0; + while (current is not null && seen.Add(current)) { + if (depth > 0) + text.AppendLine().AppendLine("--- inner ---"); + text.AppendLine(current.GetType().FullName); + text.AppendLine(current.Message); + if (!string.IsNullOrWhiteSpace(current.StackTrace)) + text.AppendLine(current.StackTrace); + if (current is AggregateException aggregate) { + foreach (var inner in aggregate.InnerExceptions) { + if (inner is null || ReferenceEquals(inner, current.InnerException) || !seen.Add(inner)) + continue; + text.AppendLine().AppendLine("--- aggregate ---"); + text.AppendLine(inner.ToString()); + } + } + + current = current.InnerException; + depth++; + } + } +} diff --git a/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs b/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs index c0b442c..00d2563 100644 --- a/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs +++ b/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs @@ -9,9 +9,14 @@ namespace MaksIT.LTO.Backup.Shared; /// public static class UserSettingsPath { public static string Get(string product, string fileName = "settings.json") => + Path.Combine(ProductDirectory(product), fileName); + + public static string LogsDirectory(string product) => + Path.Combine(ProductDirectory(product), "logs"); + + public static string ProductDirectory(string product) => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MaksIT", - product, - fileName); + product); } diff --git a/src/MaksIT.LTO.Backup.UI/Dialogs/ErrorDialog.cs b/src/MaksIT.LTO.Backup.UI/Dialogs/ErrorDialog.cs new file mode 100644 index 0000000..df857ec --- /dev/null +++ b/src/MaksIT.LTO.Backup.UI/Dialogs/ErrorDialog.cs @@ -0,0 +1,146 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using MaksIT.LTO.Backup.Shared; +using MaksIT.LTO.Backup.UI.ViewModels; +using MaksIT.LTO.Backup.UI.Windows; + + +namespace MaksIT.LTO.Backup.UI.Dialogs; + + +internal static class ErrorDialog { + private static int _open; + + public static void Report(Exception? exception) => + Present(exception, wait: false); + + public static void ReportBlocking(Exception? exception) => + Present(exception, wait: true); + + private static void Present(Exception? exception, bool wait) { + if (exception is null) + return; + var report = ErrorReport.Capture(exception); + var dispatcher = TryDispatcher(); + if (dispatcher is null) { + if (wait) + ShowStandalone(report); + return; + } + + if (dispatcher.CheckAccess()) { + if (wait) + ShowUntilClosed(report); + else + _ = ShowAsync(report); + return; + } + + if (wait) + dispatcher.Invoke(() => ShowUntilClosed(report)); + else + dispatcher.Post(() => _ = ShowAsync(report)); + } + + private static Dispatcher? TryDispatcher() { + try { + return Dispatcher.UIThread; + } + catch { + return null; + } + } + + private static async Task ShowAsync(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + var window = Create(report); + var owner = ActiveWindow(); + if (owner is { IsVisible: true }) { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + await window.ShowDialog(owner); + return; + } + + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + var closed = new TaskCompletionSource(); + window.Closed += (_, _) => closed.TrySetResult(); + window.Show(); + await closed.Task; + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static void ShowUntilClosed(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + var window = Create(report); + var owner = ActiveWindow(); + var closed = false; + window.Closed += (_, _) => closed = true; + if (owner is { IsVisible: true }) { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + window.Show(owner); + } + else { + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + window.Show(); + } + + var dispatcher = Dispatcher.UIThread; + while (!closed) + dispatcher.RunJobs(); + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static void ShowStandalone(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + AppBuilder.Configure() + .UsePlatformDetect() + .AfterSetup(builder => { + if (builder.Instance?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life) + return; + life.ShutdownMode = ShutdownMode.OnMainWindowClose; + life.MainWindow = Create(report); + }) + .StartWithClassicDesktopLifetime([]); + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static ErrorWindow Create(string report) => + new() { + DataContext = new ErrorReportViewModel(report), + WindowStartupLocation = WindowStartupLocation.CenterScreen + }; + + private static Window? ActiveWindow() { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life) + return null; + foreach (var window in life.Windows) { + if (window.IsActive && window is not ErrorWindow) + return window; + } + + return life.MainWindow is ErrorWindow ? null : life.MainWindow; + } +} diff --git a/src/MaksIT.LTO.Backup.UI/MaksIT.LTO.Backup.UI.csproj b/src/MaksIT.LTO.Backup.UI/MaksIT.LTO.Backup.UI.csproj index c6f5fb3..1a6dfe2 100644 --- a/src/MaksIT.LTO.Backup.UI/MaksIT.LTO.Backup.UI.csproj +++ b/src/MaksIT.LTO.Backup.UI/MaksIT.LTO.Backup.UI.csproj @@ -12,10 +12,10 @@ - - - - + + + + diff --git a/src/MaksIT.LTO.Backup.UI/Program.cs b/src/MaksIT.LTO.Backup.UI/Program.cs index f49be3b..4ab23dc 100644 --- a/src/MaksIT.LTO.Backup.UI/Program.cs +++ b/src/MaksIT.LTO.Backup.UI/Program.cs @@ -1,16 +1,48 @@ using Avalonia; using Avalonia.Logging; +using Avalonia.Threading; +using MaksIT.LTO.Backup.UI.Dialogs; namespace MaksIT.LTO.Backup.UI; internal static class Program { [STAThread] - public static void Main(string[] args) => - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) { + AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandled; + TaskScheduler.UnobservedTaskException += OnUnobservedTask; + try { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) { + ErrorDialog.ReportBlocking(ex); + } + } + // Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's + // xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon). public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() - .LogToTrace(LogEventLevel.Warning); + .LogToTrace(LogEventLevel.Warning) + .AfterSetup(_ => Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandled); + + private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) { + e.Handled = true; + ErrorDialog.Report(e.Exception); + } + + private static void OnDomainUnhandled(object? sender, UnhandledExceptionEventArgs e) { + if (e.ExceptionObject is not Exception ex) + return; + if (e.IsTerminating) + ErrorDialog.ReportBlocking(ex); + else + ErrorDialog.Report(ex); + } + + private static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e) { + e.SetObserved(); + ErrorDialog.Report(e.Exception); + } } diff --git a/src/MaksIT.LTO.Backup.UI/Themes/Dark.axaml b/src/MaksIT.LTO.Backup.UI/Themes/Dark.axaml index 26d950e..7ce86ce 100644 --- a/src/MaksIT.LTO.Backup.UI/Themes/Dark.axaml +++ b/src/MaksIT.LTO.Backup.UI/Themes/Dark.axaml @@ -11,6 +11,12 @@ +