diff --git a/CHANGELOG.md b/CHANGELOG.md index 9393f1a..d11fc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [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. + +### Added + +- Desktop packaging as sibling GitHub assets: Windows setup `maksit-lto-backup-{version}.exe` (Avalonia UI) and `maksit-lto-backup-{version}.flatpak` (Avalonia UI), alongside the portable `maksit-lto-backup-{version}.zip` (win-x64 console, Avalonia UI, and Worker). The installer and Flatpak are not packed inside the zip. On Windows the Flatpak bundle is built via WSL Debian. +- Faceted LTO cartridge app icon (not the brand M). + +### Changed + +- Avalonia UI is **MVVM** (`CommunityToolkit.Mvvm`): operations, settings, monitor, and service controls live in `MainViewModel`; `MainWindow` is a thin view. +- Configuration (console, UI, Worker) is written to `%AppData%/MaksIT/LTO Backup/settings.json` (WiX `installFolderName`). The shipped `configuration.json` next to the exe is seed-only. +- Dark theme uses the MAKS.IT origami blues (`#33A5CF` / `#006199`). +- Synced RepoUtils utils: ContainerRegistry JSON catalog (PascalCase Harbor / InCluster keys). + ## [0.1.0-alpha.1] - 2026-08-21 Alpha preview versus last published **0.0.2** (HEAD was console-only Windows on **net8.0**). Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line. @@ -46,3 +62,4 @@ Alpha preview versus last published **0.0.2** (HEAD was console-only Windows on - AES-GCM encrypted descriptors, checksum verification, cartridge EEPROM (MAM) read/write. - Dependency injection, console and file logging. - Build scripts and contributor docs (`README.md`, `CONTRIBUTING.md`, `LICENSE.md`). + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11604c1..b23fd53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ dotnet build MaksIT.LTO.slnx ### Configuration -Edit the shared file only: [`src/MaksIT.LTO.Backup.Shared/configuration.json`](src/MaksIT.LTO.Backup.Shared/configuration.json). Console, Avalonia UI, and Worker link it into their outputs. See [README.md](README.md). +Edit the shared seed only: [`src/MaksIT.LTO.Backup.Shared/configuration.json`](src/MaksIT.LTO.Backup.Shared/configuration.json). Console, Avalonia UI, and Worker link it into their outputs. Runtime saves go to `%AppData%/MaksIT/LTO Backup/settings.json`. See [README.md](README.md). ### Tests @@ -42,7 +42,7 @@ Coverage shields at the top of `README.md` are maintained by the **CoverageBadge 1. Update [CHANGELOG.md](CHANGELOG.md) and bump `` in [`Directory.Build.props`](Directory.Build.props) (`X.Y.Z` or SemVer prerelease such as `0.1.0-alpha.1`). 2. Commit on `main`, tag `v{version}` on HEAD (e.g. `v0.1.0-alpha.1`). GitHub marks hyphenated versions as prerelease. -3. Run `utils\Invoke-ReleasePackage.bat`. +3. Run `utils\Invoke-ReleasePackage.bat`. GitHub assets are the portable zip (win-x64), Windows setup exe (Avalonia UI), and Flatpak (Avalonia UI). ## Commit format diff --git a/Directory.Build.props b/Directory.Build.props index e3b44fa..f9ced42 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,6 @@ latest enable enable - 0.1.0-alpha.1 + 0.1.0-alpha.2 diff --git a/README.md b/README.md index f90018a..c1eab9f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MaksIT.LTO.Backup -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-39.2%25-yellow) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-28.4%25-yellow) -![Method Coverage](https://img.shields.io/badge/Method%20Coverage-49.5%25-yellowgreen) +![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) ![.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) @@ -14,7 +14,7 @@ See [LICENSE.md](LICENSE.md) (GPLv2). Changes: [CHANGELOG.md](CHANGELOG.md). Con | Host | Project | Role | |------|---------|------| | Console | `MaksIT.LTO.Backup` | Interactive menu / `backup --name` CLI | -| Avalonia | `MaksIT.LTO.Backup.UI` | Code-behind operations UI (Windows + Linux; no MVVM). SimpleTheme **Dark** styles aligned with Cluster Console | +| Avalonia | `MaksIT.LTO.Backup.UI` | MVVM operations UI (Windows + Linux; CommunityToolkit.Mvvm) | | Worker | `MaksIT.LTO.Backup.Service` | Scheduled library backups (Windows service / systemd) | Device access modes: @@ -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.1`** (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.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. --- @@ -104,13 +104,14 @@ Install/uninstall typically require administrator (Windows) or root (Linux syste ## Configuration -One shared model and one shared JSON for the whole solution: +One shared model for the whole solution. Seed JSON ships next to each host; runtime writes go to AppData so Program Files does not need elevation. - Model: [`src/MaksIT.LTO.Backup.Shared/Models/Configuration.cs`](src/MaksIT.LTO.Backup.Shared/Models/Configuration.cs) -- File: [`src/MaksIT.LTO.Backup.Shared/configuration.json`](src/MaksIT.LTO.Backup.Shared/configuration.json) +- Seed: [`src/MaksIT.LTO.Backup.Shared/configuration.json`](src/MaksIT.LTO.Backup.Shared/configuration.json) (copied next to the exe, never written) +- Runtime: `%AppData%/MaksIT/LTO Backup/settings.json` (same folder name as WiX: `Program Files\MaksIT\LTO Backup`) - Loader / workflows: `ConfigurationFileService`, `BackupOrchestrator`, `LibraryOrchestrator` in `MaksIT.LTO.Backup.Shared` -Both console and Avalonia UI link this file into their output. Edit the Shared copy only, or use the UI **Settings** tab and click **Save configuration**. +Edit the Shared seed for factory defaults, or use the UI **Settings** tab and click **Save configuration**. `DeviceMode` selects the backend (`Physical` / `Emulated`). `Topology` selects how hardware is used: @@ -196,7 +197,7 @@ Avalonia UI (Windows or Linux): dotnet run --project .\MaksIT.LTO.Backup.UI ``` -Publish hosts (or use RepoUtils release below): +Prefer RepoUtils for a GitHub release (`utils\Invoke-ReleasePackage.bat`). Assets are siblings: portable `maksit-lto-backup-{version}.zip` (win-x64 console, Avalonia UI, Worker), Windows setup `maksit-lto-backup-{version}.exe` (Avalonia UI), and `maksit-lto-backup-{version}.flatpak` (Avalonia UI). The installer and Flatpak are not inside the zip. Manual publish: ```powershell cd src @@ -234,6 +235,8 @@ Vendored under `utils/` (from MaksIT RepoUtils community): | Test | `utils\Invoke-TestEngine.bat` | | Release | `utils\Invoke-ReleasePackage.bat` | +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. + Settings: - `utils/engines/test/scriptSettings.json` diff --git a/src/MaksIT.LTO.Backup.Service/Program.cs b/src/MaksIT.LTO.Backup.Service/Program.cs index 21fa0d4..343e669 100644 --- a/src/MaksIT.LTO.Backup.Service/Program.cs +++ b/src/MaksIT.LTO.Backup.Service/Program.cs @@ -10,8 +10,7 @@ namespace MaksIT.LTO.Backup.Service; internal static class Program { public static int Main(string[] args) { var basePath = AppContext.BaseDirectory; - var configurationPath = Path.Combine(basePath, "configuration.json"); - var configurationFileService = new ConfigurationFileService(configurationPath); + var configurationFileService = new ConfigurationFileService(); var service = configurationFileService.Current.Service; var serviceName = string.IsNullOrWhiteSpace(service.ServiceName) ? "MaksIT.LTO.Backup" @@ -90,7 +89,7 @@ internal static class Program { --help, -h Show this help message Service Name: {serviceName} - Config File: configuration.json (next to the executable) + Config File: {UserSettingsPath.Get(ConfigurationFileService.ProductFolder)} Note: Install/uninstall typically require administrator / root privileges. Scheduling runs only when Topology = TapeLibrary. diff --git a/src/MaksIT.LTO.Backup.Shared/ConfigurationFileService.cs b/src/MaksIT.LTO.Backup.Shared/ConfigurationFileService.cs index 2a705ef..e9e43b2 100644 --- a/src/MaksIT.LTO.Backup.Shared/ConfigurationFileService.cs +++ b/src/MaksIT.LTO.Backup.Shared/ConfigurationFileService.cs @@ -6,11 +6,15 @@ using MaksIT.LTO.Backup.Shared.Models; namespace MaksIT.LTO.Backup.Shared; public sealed class ConfigurationFileService { + public const string ProductFolder = "LTO Backup"; + public const string SeedFileName = "configuration.json"; + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, PropertyNamingPolicy = null }; + private readonly string? _seedPath; private Configuration _current; public string FilePath { get; } @@ -18,7 +22,15 @@ public sealed class ConfigurationFileService { public Configuration Current => _current; public ConfigurationFileService(string? configurationPath = null) { - FilePath = configurationPath ?? Path.Combine(Directory.GetCurrentDirectory(), "configuration.json"); + if (!string.IsNullOrWhiteSpace(configurationPath)) { + FilePath = configurationPath; + _seedPath = null; + } + else { + FilePath = UserSettingsPath.Get(ProductFolder); + _seedPath = Path.Combine(AppContext.BaseDirectory, SeedFileName); + } + _current = LoadFromDisk(); } @@ -30,23 +42,40 @@ public sealed class ConfigurationFileService { public void Save(Configuration configuration) { ArgumentNullException.ThrowIfNull(configuration); - var root = File.Exists(FilePath) - ? JsonNode.Parse(File.ReadAllText(FilePath)) as JsonObject ?? new JsonObject() - : new JsonObject(); + var dir = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + var root = ReadRoot(File.Exists(FilePath) ? FilePath : ResolveReadPath()) ?? []; root["Configuration"] = JsonSerializer.SerializeToNode(configuration, SerializerOptions); File.WriteAllText(FilePath, root.ToJsonString(SerializerOptions)); _current = configuration; } private Configuration LoadFromDisk() { - if (!File.Exists(FilePath)) + var path = ResolveReadPath(); + if (path is null) return new Configuration(); - using var document = JsonDocument.Parse(File.ReadAllText(FilePath)); + using var document = JsonDocument.Parse(File.ReadAllText(path)); if (!document.RootElement.TryGetProperty("Configuration", out var value)) return new Configuration(); return JsonSerializer.Deserialize(value.GetRawText(), SerializerOptions) ?? new Configuration(); } + + private static JsonObject? ReadRoot(string? path) { + if (path is null || !File.Exists(path)) + return null; + + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject; + } + + private string? ResolveReadPath() { + if (File.Exists(FilePath)) + return FilePath; + if (_seedPath is not null && File.Exists(_seedPath)) + return _seedPath; + return null; + } } diff --git a/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs b/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs new file mode 100644 index 0000000..c0b442c --- /dev/null +++ b/src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs @@ -0,0 +1,17 @@ +namespace MaksIT.LTO.Backup.Shared; + + +/// +/// User-writable settings under AppData. The product folder must match the WiX +/// installFolderName (or Get-DesktopInstallFolderName from +/// appName + manufacturer), e.g. %AppData%/MaksIT/LTO Backup. +/// Shipped JSON next to the exe is seed-only. +/// +public static class UserSettingsPath { + public static string Get(string product, string fileName = "settings.json") => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MaksIT", + product, + fileName); +} diff --git a/src/MaksIT.LTO.Backup.UI/App.axaml.cs b/src/MaksIT.LTO.Backup.UI/App.axaml.cs index 84c43a7..1bf192b 100644 --- a/src/MaksIT.LTO.Backup.UI/App.axaml.cs +++ b/src/MaksIT.LTO.Backup.UI/App.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Controls.ApplicationLifetimes; using MaksIT.Core.Logging; using MaksIT.LTO.Backup.Shared; +using MaksIT.LTO.Backup.UI.ViewModels; namespace MaksIT.LTO.Backup.UI; @@ -18,18 +19,22 @@ public partial class App : Application { public override void OnFrameworkInitializationCompleted() { var basePath = AppContext.BaseDirectory; - var configurationPath = Path.Combine(basePath, "configuration.json"); + var userSettings = UserSettingsPath.Get(ConfigurationFileService.ProductFolder); _host = Host.CreateDefaultBuilder() .ConfigureAppConfiguration(builder => { builder.SetBasePath(basePath); builder.AddJsonFile("configuration.json", optional: false, reloadOnChange: true); + if (File.Exists(userSettings)) + builder.AddJsonFile(userSettings, optional: true, reloadOnChange: true); }) .ConfigureServices((_, services) => { - services.AddSingleton(_ => new ConfigurationFileService(configurationPath)); + services.AddSingleton(_ => new ConfigurationFileService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); }) .ConfigureLogging((_, logging) => { @@ -43,6 +48,7 @@ public partial class App : Application { if (_host is null) return; + _host.Services.GetRequiredService().Dispose(); await _host.StopAsync(); _host.Dispose(); _host = null; diff --git a/src/MaksIT.LTO.Backup.UI/Assets/icon.ico b/src/MaksIT.LTO.Backup.UI/Assets/icon.ico new file mode 100644 index 0000000..11212d8 Binary files /dev/null and b/src/MaksIT.LTO.Backup.UI/Assets/icon.ico differ diff --git a/src/MaksIT.LTO.Backup.UI/Assets/icon.png b/src/MaksIT.LTO.Backup.UI/Assets/icon.png new file mode 100644 index 0000000..1521e05 Binary files /dev/null and b/src/MaksIT.LTO.Backup.UI/Assets/icon.png differ diff --git a/src/MaksIT.LTO.Backup.UI/Assets/icon.svg b/src/MaksIT.LTO.Backup.UI/Assets/icon.svg new file mode 100644 index 0000000..5f5c96a --- /dev/null +++ b/src/MaksIT.LTO.Backup.UI/Assets/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MaksIT.LTO.Backup.UI/ConfirmDialog.axaml b/src/MaksIT.LTO.Backup.UI/ConfirmDialog.axaml index f8127e5..be7894e 100644 --- a/src/MaksIT.LTO.Backup.UI/ConfirmDialog.axaml +++ b/src/MaksIT.LTO.Backup.UI/ConfirmDialog.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="MaksIT.LTO.Backup.UI.ConfirmDialog" Title="Confirm" + Icon="/Assets/icon.ico" Width="440" Height="180" MinWidth="360" diff --git a/src/MaksIT.LTO.Backup.UI/DialogService.cs b/src/MaksIT.LTO.Backup.UI/DialogService.cs new file mode 100644 index 0000000..81b6ea5 --- /dev/null +++ b/src/MaksIT.LTO.Backup.UI/DialogService.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; + + +namespace MaksIT.LTO.Backup.UI; + +public interface IDialogService { + Window? Owner { get; set; } + + Task ConfirmAsync(string title, string message); +} + +public sealed class DialogService : IDialogService { + public Window? Owner { get; set; } + + public async Task ConfirmAsync(string title, string message) { + if (Owner is null) + return false; + + return await ConfirmDialog.ShowAsync(Owner, title, message); + } +} diff --git a/src/MaksIT.LTO.Backup.UI/MainWindow.axaml b/src/MaksIT.LTO.Backup.UI/MainWindow.axaml index ca33c32..215f369 100644 --- a/src/MaksIT.LTO.Backup.UI/MainWindow.axaml +++ b/src/MaksIT.LTO.Backup.UI/MainWindow.axaml @@ -3,6 +3,7 @@ xmlns:controls="using:MaksIT.LTO.Backup.UI.Controls" x:Class="MaksIT.LTO.Backup.UI.MainWindow" Title="MaksIT.LTO.Backup" + Icon="/Assets/icon.ico" Width="1100" Height="760" MinWidth="880" @@ -23,31 +24,24 @@ - + IsChecked="{Binding LiveMonitorEnabled}" /> - - - - - - -