Compare commits

...

1 Commits

Author SHA1 Message Date
Maksym Sadovnychyy
9c5b839976 (feature): mvvm ui, appdata settings, and desktop packages 2026-09-06 15:33:46 +02:00
49 changed files with 5015 additions and 1659 deletions

View File

@ -13,4 +13,4 @@ Manifest: `.cursor/maksit-skills.json`.
For **C# layout and analyzer prefs**, read **`common/csharp` SKILL.md** (Style section) and apply **repo-root `.editorconfig`** when present — the repo file is authoritative when they differ.
**Layout reminder:** `MaksIT.LTO.Core` (tape/library), `MaksIT.LTO.Backup.Shared` (config + orchestrators), console + Avalonia + Worker (`MaksIT.LTO.Backup.Service`) hosts, `MaksIT.LTO.Tests` (emulator). Prefer emulator-backed tests when physical LTO gear is unavailable. Physical mode works on Windows (`\\.\Tape0` / `\\.\Changer0`) and Linux (`/dev/nst*` / `/dev/sg*`); SMB works on both. Scheduled backups require `Topology=TapeLibrary`.
**Layout reminder:** `MaksIT.LTO.Core` (tape/library), `MaksIT.LTO.Backup.Shared` (config + orchestrators), console + Avalonia MVVM UI + Worker (`MaksIT.LTO.Backup.Service`) hosts, `MaksIT.LTO.Tests` (emulator). Prefer emulator-backed tests when physical LTO gear is unavailable. Physical mode works on Windows (`\\.\Tape0` / `\\.\Changer0`) and Linux (`/dev/nst*` / `/dev/sg*`); SMB works on both. Scheduled backups require `Topology=TapeLibrary`.

View File

@ -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`).

View File

@ -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 `<Version>` 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

View File

@ -3,6 +3,6 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.1.0-alpha.1</Version>
<Version>0.1.0-alpha.2</Version>
</PropertyGroup>
</Project>

View File

@ -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`

View File

@ -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.

View File

@ -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<Configuration>(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;
}
}

View File

@ -0,0 +1,17 @@
namespace MaksIT.LTO.Backup.Shared;
/// <summary>
/// User-writable settings under AppData. The product folder must match the WiX
/// <c>installFolderName</c> (or <c>Get-DesktopInstallFolderName</c> from
/// <c>appName</c> + <c>manufacturer</c>), e.g. <c>%AppData%/MaksIT/LTO Backup</c>.
/// Shipped JSON next to the exe is seed-only.
/// </summary>
public static class UserSettingsPath {
public static string Get(string product, string fileName = "settings.json") =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MaksIT",
product,
fileName);
}

View File

@ -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<BackupOrchestrator>();
services.AddSingleton<LibraryOrchestrator>();
services.AddSingleton<ScheduledBackupRunner>();
services.AddSingleton<IDialogService, DialogService>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
})
.ConfigureLogging((_, logging) => {
@ -43,6 +48,7 @@ public partial class App : Application {
if (_host is null)
return;
_host.Services.GetRequiredService<MainViewModel>().Dispose();
await _host.StopAsync();
_host.Dispose();
_host = null;

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="LTO Backup">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#33A5CF"/>
<stop offset="1" stop-color="#002A6A"/>
</linearGradient>
<linearGradient id="cart" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8ED6F0"/>
<stop offset="1" stop-color="#006199"/>
</linearGradient>
<clipPath id="icon">
<rect width="256" height="256" rx="57" ry="57"/>
</clipPath>
</defs>
<g clip-path="url(#icon)">
<rect width="256" height="256" fill="url(#bg)"/>
<ellipse cx="128" cy="8" rx="150" ry="88" fill="#ffffff" opacity="0.16"/>
<rect x="58" y="72" width="140" height="112" rx="18" fill="url(#cart)"/>
<circle cx="128" cy="128" r="28" fill="#E8F4FC"/>
<circle cx="128" cy="128" r="12" fill="#003D7A"/>
<rect x="188" y="118" width="36" height="14" rx="4" fill="#33A5CF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 989 B

View File

@ -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"

View File

@ -0,0 +1,21 @@
using Avalonia.Controls;
namespace MaksIT.LTO.Backup.UI;
public interface IDialogService {
Window? Owner { get; set; }
Task<bool> ConfirmAsync(string title, string message);
}
public sealed class DialogService : IDialogService {
public Window? Owner { get; set; }
public async Task<bool> ConfirmAsync(string title, string message) {
if (Owner is null)
return false;
return await ConfirmDialog.ShowAsync(Owner, title, message);
}
}

View File

@ -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 @@
<Border Grid.Row="1" Classes="statusBar" Margin="0,0,0,8">
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox Name="LiveMonitorCheckBox"
Content="Live monitor"
<CheckBox Content="Live monitor"
VerticalAlignment="Center"
Margin="0,0,10,0"
IsCheckedChanged="LiveMonitorCheckBox_Changed" />
IsChecked="{Binding LiveMonitorEnabled}" />
<TextBlock Text="Every" VerticalAlignment="Center" Margin="0,0,6,0" />
<ComboBox Name="MonitorIntervalCombo"
Width="64"
SelectedIndex="1"
SelectionChanged="MonitorIntervalCombo_SelectionChanged">
<ComboBoxItem Content="2 s" Tag="2" />
<ComboBoxItem Content="5 s" Tag="5" />
<ComboBoxItem Content="10 s" Tag="10" />
<ComboBoxItem Content="30 s" Tag="30" />
</ComboBox>
<Button Content="Refresh now" Click="RefreshMonitor_Click" Width="96" Margin="10,0,0,0" />
<ComboBox Width="64"
ItemsSource="{Binding MonitorIntervalOptions}"
SelectedItem="{Binding SelectedMonitorInterval}"
DisplayMemberBinding="{Binding Display}" />
<Button Content="Refresh now" Command="{Binding RefreshMonitorNowCommand}" Width="96" Margin="10,0,0,0" />
</StackPanel>
<TextBlock Name="HeaderStatusText"
VerticalAlignment="Center"
<TextBlock VerticalAlignment="Center"
TextWrapping="Wrap"
Text="Drive and library status will appear here." />
Text="{Binding HeaderStatusText}" />
</DockPanel>
</Border>
<TabControl Grid.Row="2" Name="MainTabs">
<TabControl Grid.Row="2">
<TabItem Header="Monitor">
<Grid Margin="8">
<Grid.RowDefinitions>
@ -72,24 +66,24 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Mode:" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="1" Name="MonitorDriveModeText" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding MonitorDriveModeText}" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="Topology:" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="3" Name="MonitorDriveTopologyText" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding MonitorDriveTopologyText}" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Text="State:" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Name="MonitorDriveStateText" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding MonitorDriveStateText}" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock Grid.Row="2" Text="Path:" Margin="0,0,0,4" />
<TextBlock Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" Name="MonitorDrivePathText" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding MonitorDrivePathText}" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock Grid.Row="3" Text="Status code:" />
<TextBlock Grid.Row="3" Grid.Column="1" Name="MonitorDriveStatusCodeText" Margin="0,0,12,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding MonitorDriveStatusCodeText}" Margin="0,0,12,0" />
<TextBlock Grid.Row="3" Grid.Column="2" Text="Abs. block:" />
<TextBlock Grid.Row="3" Grid.Column="3" Name="MonitorDriveBlockText" />
<TextBlock Grid.Row="3" Grid.Column="3" Text="{Binding MonitorDriveBlockText}" />
</Grid>
</controls:GroupBox>
<controls:GroupBox Grid.Row="1" Name="MonitorLibraryGroup" Header="Tape library">
<controls:GroupBox Grid.Row="1" Header="Tape library" IsVisible="{Binding IsTapeLibraryUi}">
<StackPanel>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
@ -105,24 +99,25 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Mode:" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="1" Name="MonitorLibraryModeText" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding MonitorLibraryModeText}" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="Summary:" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="3" Name="MonitorLibrarySummaryText" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding MonitorLibrarySummaryText}" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Text="Path:" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Name="MonitorLibraryPathText" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding MonitorLibraryPathText}" TextWrapping="Wrap" Margin="0,0,0,4" />
<TextBlock Grid.Row="2" Text="Drives occupied:" />
<TextBlock Grid.Row="2" Grid.Column="1" Name="MonitorLibraryDrivesText" Margin="0,0,12,0" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding MonitorLibraryDrivesText}" Margin="0,0,12,0" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="Updated:" />
<TextBlock Grid.Row="2" Grid.Column="3" Name="MonitorUpdatedText" />
<TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MonitorUpdatedText}" />
</Grid>
<TextBlock Name="MonitorLibraryErrorText" Classes="warning" TextWrapping="Wrap" />
<TextBlock Classes="warning" TextWrapping="Wrap" Text="{Binding MonitorLibraryErrorText}" />
</StackPanel>
</controls:GroupBox>
<DataGrid Grid.Row="2"
Name="MonitorInventoryGrid"
ItemsSource="{Binding MonitorInventory}"
IsVisible="{Binding IsTapeLibraryUi}"
AutoGenerateColumns="False"
IsReadOnly="True">
<DataGrid.Columns>
@ -136,7 +131,7 @@
</Grid>
</TabItem>
<TabItem Header="Drive" Name="DriveTab">
<TabItem Header="Drive" IsVisible="{Binding IsStandaloneDriveUi}">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8">
<TextBlock Classes="hint"
@ -146,9 +141,9 @@
<controls:GroupBox Header="Drive">
<StackPanel Orientation="Horizontal">
<Button Name="LoadTapeButton" Content="Load tape" Click="LoadTape_Click" Width="110" Margin="0,0,8,0" />
<Button Name="EjectTapeButton" Content="Eject tape" Click="EjectTape_Click" Width="110" Margin="0,0,8,0" />
<Button Name="StatusButton" Content="Get status" Click="GetStatus_Click" Width="110" />
<Button Content="Load tape" Command="{Binding LoadTapeCommand}" Width="110" Margin="0,0,8,0" />
<Button Content="Eject tape" Command="{Binding EjectTapeCommand}" Width="110" Margin="0,0,8,0" />
<Button Content="Get status" Command="{Binding GetStatusCommand}" Width="110" />
</StackPanel>
</controls:GroupBox>
@ -159,10 +154,10 @@
TextWrapping="Wrap"
Text="Applies to the tape currently in this standalone drive." />
<StackPanel Orientation="Horizontal">
<Button Content="Read attributes" Click="ReadAttributes_Click" Width="120" Margin="0,0,12,0" />
<Button Content="Read attributes" Command="{Binding ReadAttributesCommand}" Width="120" Margin="0,0,12,0" />
<TextBlock Text="Barcode:" VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBox Name="StandaloneBarcodeText" Width="200" Margin="0,0,12,0" />
<Button Content="Write barcode" Click="WriteStandaloneBarcode_Click" Width="110" />
<TextBox Text="{Binding StandaloneBarcodeText}" Width="200" Margin="0,0,12,0" />
<Button Content="Write barcode" Command="{Binding WriteStandaloneBarcodeCommand}" Width="110" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
@ -170,7 +165,7 @@
</ScrollViewer>
</TabItem>
<TabItem Header="Library" Name="LibraryTab">
<TabItem Header="Library" IsVisible="{Binding IsTapeLibraryUi}">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@ -180,45 +175,42 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Name="LibraryUnavailableText"
Text="Library operations require Topology = TapeLibrary in Settings."
<TextBlock Text="Library operations require Topology = TapeLibrary in Settings."
TextWrapping="Wrap"
Margin="0,0,0,8"
IsVisible="False" />
<controls:GroupBox Grid.Row="1" Name="LibraryLoadedGroup" Header="Loaded cartridge">
<controls:GroupBox Grid.Row="1" Header="Loaded cartridge" IsVisible="{Binding IsTapeLibraryUi}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="Active drive:" VerticalAlignment="Center" Margin="0,0,8,0" />
<ComboBox Name="LibraryActiveDriveCombo"
Width="460"
DisplayMemberBinding="{Binding Summary}"
SelectionChanged="LibraryActiveDriveCombo_SelectionChanged" />
<ComboBox Width="460"
ItemsSource="{Binding OccupiedLibraryDrives}"
SelectedItem="{Binding SelectedOccupiedDrive}"
DisplayMemberBinding="{Binding Summary}" />
</StackPanel>
<TextBlock Name="LibraryLoadedSummaryText"
FontWeight="SemiBold"
<TextBlock FontWeight="SemiBold"
Margin="0,0,0,4"
Text="No cartridge in a drive bay." />
<TextBlock Name="LibraryLoadedHintText"
Classes="hint"
Text="{Binding LibraryLoadedSummaryText}" />
<TextBlock Classes="hint"
TextWrapping="Wrap"
Margin="0,0,0,8"
Text="Move a cartridge from a storage slot into a drive bay (destination 1000+)." />
Text="{Binding LibraryLoadedHintText}" />
<StackPanel Orientation="Horizontal">
<Button Content="Read MAM" Click="ReadAttributes_Click" Width="100" Margin="0,0,12,0" />
<Button Content="Read MAM" Command="{Binding ReadAttributesCommand}" Width="100" Margin="0,0,12,0" />
<TextBlock Text="Barcode:" VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBox Name="LibraryBarcodeText"
Width="200"
<TextBox Width="200"
Margin="0,0,12,0"
Text="{Binding LibraryBarcodeText}"
ToolTip.Tip="Writes MAM barcode for the cartridge in the selected drive bay" />
<Button Content="Assign barcode" Click="WriteLibraryBarcode_Click" Width="120" />
<Button Content="Assign barcode" Command="{Binding WriteLibraryBarcodeCommand}" Width="120" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox Grid.Row="2" Name="LibraryActionsGroup" Header="Load / unload">
<controls:GroupBox Grid.Row="2" Header="Load / unload" IsVisible="{Binding IsTapeLibraryUi}">
<StackPanel>
<Button Content="Refresh inventory" Click="RefreshInventory_Click" Width="140" HorizontalAlignment="Left" Margin="0,0,0,8" />
<Button Content="Refresh inventory" Command="{Binding RefreshInventoryCommand}" Width="140" HorizontalAlignment="Left" Margin="0,0,0,8" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
@ -234,33 +226,36 @@
<TextBlock Grid.Row="0" Text="Cartridge (source):" VerticalAlignment="Center" Margin="0,0,0,6" />
<ComboBox Grid.Row="0" Grid.Column="1"
Name="LoadSourceCombo"
Margin="0,0,8,6"
ItemsSource="{Binding LoadSources}"
SelectedItem="{Binding SelectedLoadSource}"
DisplayMemberBinding="{Binding Display}"
ToolTip.Tip="Occupied storage slot or drive bay to take the cartridge from" />
<Button Grid.Row="0" Grid.Column="2"
Content="Load into drive"
Click="LoadIntoDrive_Click"
Command="{Binding LoadIntoDriveCommand}"
Width="120"
Margin="0,0,0,6"
ToolTip.Tip="Move the selected cartridge into the selected empty drive bay" />
<TextBlock Grid.Row="1" Text="Target drive bay:" VerticalAlignment="Center" Margin="0,0,0,6" />
<ComboBox Grid.Row="1" Grid.Column="1"
Name="LoadDriveCombo"
Margin="0,0,8,6"
ItemsSource="{Binding LoadDrives}"
SelectedItem="{Binding SelectedLoadDrive}"
DisplayMemberBinding="{Binding Display}"
ToolTip.Tip="Empty drive bay (e.g. 1000, 1001)" />
<TextBlock Grid.Row="2" Text="Unload from drive:" VerticalAlignment="Center" />
<ComboBox Grid.Row="2" Grid.Column="1"
Name="UnloadDriveCombo"
Margin="0,0,8,0"
ItemsSource="{Binding UnloadDrives}"
SelectedItem="{Binding SelectedUnloadDrive}"
DisplayMemberBinding="{Binding Display}"
ToolTip.Tip="Drive bay that currently holds a cartridge" />
<Button Grid.Row="2" Grid.Column="2"
Content="Unload to storage"
Click="UnloadFromDrive_Click"
Command="{Binding UnloadFromDriveCommand}"
Width="120"
ToolTip.Tip="Move the cartridge from the selected drive into the first empty storage slot" />
</Grid>
@ -269,10 +264,16 @@
<StackPanel Margin="0,6,0,0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="From:" VerticalAlignment="Center" Margin="0,0,6,0" />
<ComboBox Name="SourceSlotCombo" Width="300" Margin="0,0,12,0" DisplayMemberBinding="{Binding Display}" />
<ComboBox Width="300" Margin="0,0,12,0"
ItemsSource="{Binding SourceSlots}"
SelectedItem="{Binding SelectedSourceSlot}"
DisplayMemberBinding="{Binding Display}" />
<TextBlock Text="To:" VerticalAlignment="Center" Margin="0,0,6,0" />
<ComboBox Name="DestinationSlotCombo" Width="300" Margin="0,0,12,0" DisplayMemberBinding="{Binding Display}" />
<Button Content="Move" Click="MoveMedium_Click" Width="80" />
<ComboBox Width="300" Margin="0,0,12,0"
ItemsSource="{Binding DestinationSlots}"
SelectedItem="{Binding SelectedDestinationSlot}"
DisplayMemberBinding="{Binding Display}" />
<Button Content="Move" Command="{Binding MoveMediumCommand}" Width="80" />
</StackPanel>
<TextBlock Classes="hint"
TextWrapping="Wrap"
@ -282,9 +283,10 @@
</StackPanel>
</controls:GroupBox>
<TextBlock Grid.Row="3" Name="LibraryInventoryLabel" Text="Inventory" FontWeight="SemiBold" Margin="0,0,0,4" />
<TextBlock Grid.Row="3" Text="Inventory" FontWeight="SemiBold" Margin="0,0,0,4" IsVisible="{Binding IsTapeLibraryUi}" />
<DataGrid Grid.Row="4"
Name="InventoryGrid"
ItemsSource="{Binding LibraryInventory}"
IsVisible="{Binding IsTapeLibraryUi}"
AutoGenerateColumns="False"
IsReadOnly="True">
<DataGrid.Columns>
@ -297,26 +299,24 @@
</Grid>
</TabItem>
<TabItem Header="Backup" Name="BackupTab">
<TabItem Header="Backup">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8">
<controls:GroupBox Header="Loaded cartridge">
<StackPanel>
<StackPanel Name="BackupDriveSelectorPanel" Orientation="Horizontal" Margin="0,0,0,6" IsVisible="False">
<StackPanel Orientation="Horizontal" Margin="0,0,0,6" IsVisible="{Binding IsSavedTapeLibrary}">
<TextBlock Text="Drive:" VerticalAlignment="Center" Margin="0,0,8,0" Width="64" />
<ComboBox Name="BackupDriveCombo"
Width="500"
DisplayMemberBinding="{Binding Summary}"
SelectionChanged="BackupDriveCombo_SelectionChanged" />
<ComboBox Width="500"
ItemsSource="{Binding OccupiedLibraryDrives}"
SelectedItem="{Binding SelectedOccupiedDrive}"
DisplayMemberBinding="{Binding Summary}" />
</StackPanel>
<TextBlock Name="BackupLoadedSummaryText"
FontWeight="SemiBold"
<TextBlock FontWeight="SemiBold"
Margin="0,0,0,4"
Text="Checking loaded cartridge…" />
<TextBlock Name="BackupLoadedHintText"
Classes="hint"
Text="{Binding BackupLoadedSummaryText}" />
<TextBlock Classes="hint"
TextWrapping="Wrap"
Text="Backup/restore require a loaded cartridge (standalone drive tape, or library media in a drive bay)." />
Text="{Binding BackupLoadedHintText}" />
</StackPanel>
</controls:GroupBox>
@ -324,23 +324,22 @@
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="Job:" VerticalAlignment="Center" Margin="0,0,8,0" Width="64" />
<ComboBox Name="BackupCombo"
Width="400"
DisplayMemberBinding="{Binding Name}"
SelectionChanged="BackupCombo_SelectionChanged" />
<ComboBox Width="400"
ItemsSource="{Binding BackupJobs}"
SelectedItem="{Binding SelectedBackup}"
DisplayMemberBinding="{Binding Name}" />
</StackPanel>
<TextBlock Name="JobSummaryText"
Classes="hint"
<TextBlock Classes="hint"
TextWrapping="Wrap"
Text="Select a backup job to see source, destination, LTO generation, and barcode." />
Text="{Binding JobSummaryText}" />
</StackPanel>
</controls:GroupBox>
<controls:GroupBox Name="BackupOperationsGroup" Header="Operations">
<controls:GroupBox Header="Operations" IsEnabled="{Binding IsCartridgeLoaded}">
<StackPanel Orientation="Horizontal">
<Button Name="RunBackupButton" Content="Run backup" Click="RunBackup_Click" Width="120" Margin="0,0,8,0" />
<Button Name="RunRestoreButton" Content="Run restore" Click="RunRestore_Click" Width="120" Margin="0,0,8,0" />
<Button Name="EraseButton" Content="Short erase" Click="Erase_Click" Width="100" />
<Button Content="Run backup" Command="{Binding RunBackupCommand}" Width="120" Margin="0,0,8,0" />
<Button Content="Run restore" Command="{Binding RunRestoreCommand}" Width="120" Margin="0,0,8,0" />
<Button Content="Short erase" Command="{Binding EraseCommand}" Width="100" />
</StackPanel>
</controls:GroupBox>
</StackPanel>
@ -371,29 +370,29 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Device mode:" VerticalAlignment="Center" Margin="0,0,0,6" />
<ComboBox Grid.Row="0" Grid.Column="1" Name="DeviceModeCombo" Margin="0,0,0,6" Width="200" HorizontalAlignment="Left">
<ComboBoxItem Content="Emulated" />
<ComboBoxItem Content="Physical" />
</ComboBox>
<ComboBox Grid.Row="0" Grid.Column="1" Margin="0,0,0,6" Width="200" HorizontalAlignment="Left"
ItemsSource="{Binding DeviceModeOptions}"
SelectedItem="{Binding DeviceMode}" />
<TextBlock Grid.Row="1" Text="Topology:" VerticalAlignment="Center" Margin="0,0,0,6" />
<ComboBox Grid.Row="1" Grid.Column="1" Name="TopologyCombo" Margin="0,0,0,6" Width="200" HorizontalAlignment="Left"
SelectionChanged="TopologyCombo_SelectionChanged">
<ComboBoxItem Content="StandaloneDrive" />
<ComboBoxItem Content="TapeLibrary" />
</ComboBox>
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,0,0,6" Width="200" HorizontalAlignment="Left"
ItemsSource="{Binding TopologyOptions}"
SelectedItem="{Binding SelectedTopology}" />
<TextBlock Grid.Row="2" Text="Tape path:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="2" Grid.Column="1" Name="TapePathText" Margin="0,0,0,6" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding TapePathText}" Margin="0,0,0,6" />
<TextBlock Grid.Row="3" Name="LibraryPathLabel" Text="Library path:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="3" Grid.Column="1" Name="LibraryPathText" Margin="0,0,0,6" />
<TextBlock Grid.Row="3" Text="Library path:" VerticalAlignment="Center" Margin="0,0,0,6"
IsVisible="{Binding IsTapeLibraryUi}" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding LibraryPathText}" Margin="0,0,0,6"
IsVisible="{Binding IsTapeLibraryUi}" />
<TextBlock Grid.Row="4" Text="Write delay (ms):" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="4" Grid.Column="1" Name="WriteDelayText" Width="100" HorizontalAlignment="Left" Margin="0,0,0,6" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding WriteDelayText}" Width="100" HorizontalAlignment="Left" Margin="0,0,0,6" />
<TextBlock Grid.Row="5" Text="Fail-fast checksum:" VerticalAlignment="Center" />
<CheckBox Grid.Row="5" Grid.Column="1" Name="FailFastChecksumCheck" Content="Abort restore on checksum mismatch" VerticalAlignment="Center" />
<CheckBox Grid.Row="5" Grid.Column="1" Content="Abort restore on checksum mismatch" VerticalAlignment="Center"
IsChecked="{Binding FailFastChecksum}" />
</Grid>
</controls:GroupBox>
@ -405,9 +404,9 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Data directory:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="EmulatorDataDirText" />
<TextBox Grid.Column="1" Text="{Binding EmulatorDataDirText}" />
</Grid>
<Grid Name="EmulatorLibrarySettingsGrid">
<Grid IsVisible="{Binding IsTapeLibraryUi}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
@ -419,13 +418,13 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Slots:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="0" Grid.Column="1" Name="EmulatorSlotCountText" Width="100" HorizontalAlignment="Left" Margin="0,0,0,6" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding EmulatorSlotCountText}" Width="100" HorizontalAlignment="Left" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Text="Drives:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="1" Grid.Column="1" Name="EmulatorDriveCountText" Width="100" HorizontalAlignment="Left" Margin="0,0,0,6" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding EmulatorDriveCountText}" Width="100" HorizontalAlignment="Left" Margin="0,0,0,6" />
<TextBlock Grid.Row="2" Text="I/E ports:" VerticalAlignment="Center" />
<TextBox Grid.Row="2" Grid.Column="1" Name="EmulatorIePortCountText" Width="100" HorizontalAlignment="Left" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding EmulatorIePortCountText}" Width="100" HorizontalAlignment="Left" />
</Grid>
</StackPanel>
</controls:GroupBox>
@ -434,8 +433,12 @@
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Job:" VerticalAlignment="Center" Margin="0,0,8,0" Width="64" />
<ComboBox Name="SettingsBackupCombo" Width="400" DisplayMemberBinding="{Binding Name}" SelectionChanged="SettingsBackupCombo_SelectionChanged" />
<CheckBox Name="BackupDisabledCheck" Content="Disabled" Margin="16,0,0,0" VerticalAlignment="Center"
<ComboBox Width="400"
ItemsSource="{Binding BackupJobs}"
SelectedItem="{Binding SelectedSettingsBackup}"
DisplayMemberBinding="{Binding Name}" />
<CheckBox Content="Disabled" Margin="16,0,0,0" VerticalAlignment="Center"
IsChecked="{Binding BackupDisabled}"
ToolTip.Tip="When checked, aggregations skip this job." />
</StackPanel>
@ -454,73 +457,76 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Name:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="0" Grid.Column="1" Name="BackupNameText" Margin="0,0,0,6" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding BackupNameText}" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Text="Barcode:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="1" Grid.Column="1" Name="BackupBarcodeText" Margin="0,0,0,6"
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding BackupBarcodeText}" Margin="0,0,0,6"
ToolTip.Tip="Standalone / single-tape MAM label. Library pools use Tape rotation below." />
<TextBlock Grid.Row="2" Text="LTO generation:" VerticalAlignment="Center" Margin="0,0,0,6" />
<ComboBox Grid.Row="2" Grid.Column="1" Name="BackupLtoGenCombo" Width="140" HorizontalAlignment="Left" Margin="0,0,0,6">
<ComboBoxItem Content="LTO1" /><ComboBoxItem Content="LTO2" /><ComboBoxItem Content="LTO3" />
<ComboBoxItem Content="LTO4" /><ComboBoxItem Content="LTO5" /><ComboBoxItem Content="LTO6" />
<ComboBoxItem Content="LTO7" /><ComboBoxItem Content="LTO8" /><ComboBoxItem Content="LTO9" />
</ComboBox>
<ComboBox Grid.Row="2" Grid.Column="1" Width="140" HorizontalAlignment="Left" Margin="0,0,0,6"
ItemsSource="{Binding LtoGenerationOptions}"
SelectedItem="{Binding BackupLtoGen}" />
<TextBlock Grid.Row="3" Text="Write mode:" VerticalAlignment="Center" Margin="0,0,0,6" />
<ComboBox Grid.Row="3" Grid.Column="1" Name="BackupWriteModeCombo" Width="140" HorizontalAlignment="Left" Margin="0,0,0,6">
<ComboBoxItem Content="Overwrite" />
<ComboBoxItem Content="Append" />
</ComboBox>
<ComboBox Grid.Row="3" Grid.Column="1" Width="140" HorizontalAlignment="Left" Margin="0,0,0,6"
ItemsSource="{Binding WriteModeOptions}"
SelectedItem="{Binding BackupWriteMode}" />
<TextBlock Grid.Row="4" Text="Source path:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="4" Grid.Column="1" Name="BackupSourcePathText" Margin="0,0,0,6" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding BackupSourcePathText}" Margin="0,0,0,6" />
<TextBlock Grid.Row="5" Text="Destination path:" VerticalAlignment="Center" />
<TextBox Grid.Row="5" Grid.Column="1" Name="BackupDestinationPathText" />
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding BackupDestinationPathText}" />
</Grid>
<controls:GroupBox Name="LibraryTapesGroup" Header="Tape rotation (library)" Margin="0,12,0,0">
<controls:GroupBox Header="Tape rotation (library)" Margin="0,12,0,0" IsVisible="{Binding IsTapeLibraryUi}">
<StackPanel>
<TextBlock Classes="hint" TextWrapping="Wrap" Margin="0,0,0,6"
Text="Media pool for this job. Pick barcodes not assigned to other jobs. Never-used tapes first, then oldest LastUsedUtc. Drive claim is automatic." />
<DataGrid Name="BackupTapesGrid"
Height="120"
<DataGrid Height="120"
AutoGenerateColumns="False"
IsReadOnly="False"
Margin="0,0,0,6">
Margin="0,0,0,6"
ItemsSource="{Binding TapeRows}"
SelectedItem="{Binding SelectedTapeRow}">
<DataGrid.Columns>
<DataGridTextColumn Header="Barcode" Binding="{Binding Barcode}" Width="*" />
<DataGridTextColumn Header="Last used (UTC)" Binding="{Binding LastUsedUtcDisplay}" Width="220" IsReadOnly="True" />
</DataGrid.Columns>
</DataGrid>
<StackPanel Orientation="Horizontal">
<ComboBox Name="AvailableTapeBarcodeCombo"
Width="320"
<ComboBox Width="320"
Margin="0,0,8,0"
PlaceholderText="Available library barcodes"
ItemsSource="{Binding AvailableTapes}"
SelectedItem="{Binding SelectedAvailableTape}"
DisplayMemberBinding="{Binding Display}" />
<Button Content="Refresh list" Click="RefreshAvailableTapes_Click" Width="100" Margin="0,0,8,0"
<Button Content="Refresh list" Command="{Binding RefreshAvailableTapesCommand}" Width="100" Margin="0,0,8,0"
ToolTip.Tip="Reload unassigned barcodes from library inventory" />
<Button Content="Add tape" Click="AddTape_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Remove selected" Click="RemoveTape_Click" Width="120" />
<Button Content="Add tape" Command="{Binding AddTapeCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Remove selected" Command="{Binding RemoveTapeCommand}" Width="120" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox Name="AggregationsGroup" Header="Aggregations (schedule waves)">
<controls:GroupBox Header="Aggregations (schedule waves)" IsVisible="{Binding IsTapeLibraryUi}">
<StackPanel>
<TextBlock Classes="hint" TextWrapping="Wrap" Margin="0,0,0,6"
Text="Worker schedules aggregations only. Each wave runs its jobs sequentially with exclusive lease and automatic drive claim/unload." />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Wave:" VerticalAlignment="Center" Margin="0,0,8,0" Width="64" />
<ComboBox Name="SettingsAggregationCombo" Width="280" DisplayMemberBinding="{Binding Name}"
SelectionChanged="SettingsAggregationCombo_SelectionChanged" Margin="0,0,8,0" />
<CheckBox Name="AggregationDisabledCheck" Content="Disabled" VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Content="Add wave" Click="AddAggregation_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Remove wave" Click="RemoveAggregation_Click" Width="100" />
<ComboBox Width="280"
ItemsSource="{Binding Aggregations}"
SelectedItem="{Binding SelectedAggregation}"
DisplayMemberBinding="{Binding Name}"
Margin="0,0,8,0" />
<CheckBox Content="Disabled" VerticalAlignment="Center" Margin="0,0,8,0"
IsChecked="{Binding AggregationDisabled}" />
<Button Content="Add wave" Command="{Binding AddAggregationCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Remove wave" Command="{Binding RemoveAggregationCommand}" Width="100" />
</StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
@ -528,73 +534,81 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Name:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="AggregationNameText" />
<TextBox Grid.Column="1" Text="{Binding AggregationNameText}" />
</Grid>
<TextBlock Text="Jobs in this wave (ordered):" FontWeight="SemiBold" Margin="0,0,0,4" />
<ListBox Name="AggregationJobsList" Height="90" Margin="0,0,0,6" />
<ListBox Height="90" Margin="0,0,0,6"
ItemsSource="{Binding AggregationJobNames}"
SelectedItem="{Binding SelectedAggregationJobName}" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<ComboBox Name="AggregationAddJobCombo" Width="280" Margin="0,0,8,0" DisplayMemberBinding="{Binding Name}"
<ComboBox Width="280" Margin="0,0,8,0"
ItemsSource="{Binding BackupJobs}"
SelectedItem="{Binding SelectedAggregationAddJob}"
DisplayMemberBinding="{Binding Name}"
PlaceholderText="Backup job to add" />
<Button Content="Add job" Click="AggregationAddJob_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Remove job" Click="AggregationRemoveJob_Click" Width="100" Margin="0,0,8,0" />
<Button Content="Move up" Click="AggregationMoveJobUp_Click" Width="80" Margin="0,0,8,0" />
<Button Content="Move down" Click="AggregationMoveJobDown_Click" Width="90" />
<Button Content="Add job" Command="{Binding AggregationAddJobCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Remove job" Command="{Binding AggregationRemoveJobCommand}" Width="100" Margin="0,0,8,0" />
<Button Content="Move up" Command="{Binding AggregationMoveJobUpCommand}" Width="80" Margin="0,0,8,0" />
<Button Content="Move down" Command="{Binding AggregationMoveJobDownCommand}" Width="90" />
</StackPanel>
<controls:GroupBox Header="Months (empty = all months)">
<WrapPanel Name="ScheduleMonthPanel">
<CheckBox Content="Jan" Tag="January" Margin="0,0,8,4" />
<CheckBox Content="Feb" Tag="February" Margin="0,0,8,4" />
<CheckBox Content="Mar" Tag="March" Margin="0,0,8,4" />
<CheckBox Content="Apr" Tag="April" Margin="0,0,8,4" />
<CheckBox Content="May" Tag="May" Margin="0,0,8,4" />
<CheckBox Content="Jun" Tag="June" Margin="0,0,8,4" />
<CheckBox Content="Jul" Tag="July" Margin="0,0,8,4" />
<CheckBox Content="Aug" Tag="August" Margin="0,0,8,4" />
<CheckBox Content="Sep" Tag="September" Margin="0,0,8,4" />
<CheckBox Content="Oct" Tag="October" Margin="0,0,8,4" />
<CheckBox Content="Nov" Tag="November" Margin="0,0,8,4" />
<CheckBox Content="Dec" Tag="December" Margin="0,0,8,4" />
</WrapPanel>
<ItemsControl ItemsSource="{Binding MonthOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Label}" IsChecked="{Binding IsChecked}" Margin="0,0,8,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</controls:GroupBox>
<controls:GroupBox Header="Weekdays (empty = all days)">
<WrapPanel Name="ScheduleWeekdayPanel">
<CheckBox Content="Mon" Tag="Monday" Margin="0,0,8,4" />
<CheckBox Content="Tue" Tag="Tuesday" Margin="0,0,8,4" />
<CheckBox Content="Wed" Tag="Wednesday" Margin="0,0,8,4" />
<CheckBox Content="Thu" Tag="Thursday" Margin="0,0,8,4" />
<CheckBox Content="Fri" Tag="Friday" Margin="0,0,8,4" />
<CheckBox Content="Sat" Tag="Saturday" Margin="0,0,8,4" />
<CheckBox Content="Sun" Tag="Sunday" Margin="0,0,8,4" />
</WrapPanel>
<ItemsControl ItemsSource="{Binding WeekdayOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Label}" IsChecked="{Binding IsChecked}" Margin="0,0,8,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</controls:GroupBox>
<controls:GroupBox Header="Run Times (UTC)">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBox Name="NewRunTimeText" Width="80" Text="00:00" Margin="0,0,8,0" />
<Button Content="Add" Click="AddRunTime_Click" Width="60" Margin="0,0,8,0" />
<Button Content="Remove" Click="RemoveRunTime_Click" Width="70" />
<TextBox Width="80" Text="{Binding NewRunTimeText}" Margin="0,0,8,0" />
<Button Content="Add" Command="{Binding AddRunTimeCommand}" Width="60" Margin="0,0,8,0" />
<Button Content="Remove" Command="{Binding RemoveRunTimeCommand}" Width="70" />
</StackPanel>
<ListBox Name="RunTimesList" Height="80" />
<ListBox Height="80"
ItemsSource="{Binding RunTimes}"
SelectedItem="{Binding SelectedRunTime}" />
</StackPanel>
</controls:GroupBox>
<controls:GroupBox Header="Minimum Interval">
<StackPanel Orientation="Horizontal">
<TextBox Name="MinIntervalMinutesText" Width="60" Text="10" Margin="0,0,8,0" />
<TextBox Width="60" Text="{Binding MinIntervalMinutesText}" Margin="0,0,8,0" />
<TextBlock Text="minutes between wave starts" VerticalAlignment="Center" />
</StackPanel>
</controls:GroupBox>
<TextBlock Name="LastRunUtcText" Classes="hint" Margin="0,6,0,0" Text="Last wave run (UTC): (never)" />
<TextBlock Classes="hint" Margin="0,6,0,0" Text="{Binding LastRunUtcText}" />
</StackPanel>
</controls:GroupBox>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Button Content="Save configuration" Click="SaveConfiguration_Click" Width="140" Margin="0,0,8,0" />
<Button Content="Reload from disk" Click="ReloadConfiguration_Click" Width="130" />
<Button Content="Save configuration" Command="{Binding SaveConfigurationCommand}" Width="140" Margin="0,0,8,0" />
<Button Content="Reload from disk" Command="{Binding ReloadConfigurationCommand}" Width="130" />
</StackPanel>
</StackPanel>
</ScrollViewer>
@ -608,14 +622,14 @@
<controls:GroupBox Header="Worker status">
<StackPanel>
<TextBlock Name="ServiceStatusText" FontWeight="SemiBold" Margin="0,0,0,6" Text="Status: Unknown" />
<TextBlock Name="ServiceExePathText" Classes="hint" TextWrapping="Wrap" Margin="0,0,0,8" Text="Executable: (not found)" />
<TextBlock FontWeight="SemiBold" Margin="0,0,0,6" Text="{Binding ServiceStatusText}" />
<TextBlock Classes="hint" TextWrapping="Wrap" Margin="0,0,0,8" Text="{Binding ServiceExePathText}" />
<StackPanel Orientation="Horizontal">
<Button Content="Refresh" Click="ServiceRefresh_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Install" Click="ServiceInstall_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Uninstall" Click="ServiceUninstall_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Start" Click="ServiceStart_Click" Width="90" Margin="0,0,8,0" />
<Button Content="Stop" Click="ServiceStop_Click" Width="90" />
<Button Content="Refresh" Command="{Binding ServiceRefreshCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Install" Command="{Binding ServiceInstallCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Uninstall" Command="{Binding ServiceUninstallCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Start" Command="{Binding ServiceStartCommand}" Width="90" Margin="0,0,8,0" />
<Button Content="Stop" Command="{Binding ServiceStopCommand}" Width="90" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
@ -633,13 +647,13 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Service name:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="0" Grid.Column="1" Name="ServiceNameText" Margin="0,0,0,6" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding ServiceNameText}" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Text="Description:" VerticalAlignment="Center" Margin="0,0,0,6" />
<TextBox Grid.Row="1" Grid.Column="1" Name="ServiceDescriptionText" Margin="0,0,0,6" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ServiceDescriptionText}" Margin="0,0,0,6" />
<TextBlock Grid.Row="2" Text="Executable path:" VerticalAlignment="Center" />
<TextBox Grid.Row="2" Grid.Column="1" Name="ServiceExePathSettingText"
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding ServiceExePathSettingText}"
PlaceholderText="Optional override; empty = resolve next to UI" />
</Grid>
</controls:GroupBox>
@ -650,6 +664,7 @@
<controls:GroupBox Grid.Row="3" Header="Log" Margin="0,8,0,0" Padding="6">
<TextBox Name="LogTextBox"
Text="{Binding LogText, Mode=OneWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
IsReadOnly="True"

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
@ -15,6 +16,7 @@
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
@ -27,6 +29,10 @@
<ProjectReference Include="..\MaksIT.LTO.Core\MaksIT.LTO.Core.csproj" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<None Include="..\MaksIT.LTO.Backup.Shared\configuration.json" Link="configuration.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

View File

@ -2,8 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:MaksIT.LTO.Backup.UI.Controls">
<Style Selector="Window">
<Setter Property="Background" Value="#1e2124" />
<Setter Property="Foreground" Value="#e6e6e6" />
<Setter Property="Background" Value="#0E1620" />
<Setter Property="Foreground" Value="#E8F4FC" />
<Setter Property="FontFamily" Value="Segoe UI, Ubuntu, sans-serif" />
<Setter Property="FontSize" Value="13" />
</Style>
@ -23,7 +23,7 @@
<Setter Property="MinHeight" Value="28" />
</Style>
<Style Selector="ListBoxItem:selected">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
</Style>
<Style Selector="DataGrid">
<Setter Property="Background" Value="#25282c" />
@ -69,7 +69,7 @@
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
TextElement.FontWeight="SemiBold"
TextElement.Foreground="#e6e6e6" />
TextElement.Foreground="#E8F4FC" />
</Border>
</Grid>
</ControlTemplate>

View File

@ -0,0 +1,129 @@
using MaksIT.LTO.Core.MassStorage;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public partial class MainViewModel {
private void RefreshMonitor(bool silent) {
if (_monitorRefreshInProgress)
return;
if (IsBusy) {
HeaderStatusText = "Monitoring paused while an operation is running.";
if (!silent)
Log("Monitor refresh skipped: an operation is running.");
return;
}
_monitorRefreshInProgress = true;
try {
var drive = _backupOrchestrator.GetDriveStatusSnapshot();
var library = _libraryOrchestrator.GetLibraryStatusSnapshot();
var available = library.Available;
UpdateTopologyUi();
MonitorDriveModeText = drive.DeviceMode;
MonitorDriveTopologyText = drive.Topology;
MonitorDriveStateText = drive.Error is null ? drive.State : drive.State + ": " + drive.Error;
MonitorDrivePathText = drive.DevicePath;
MonitorDriveStatusCodeText = drive.StatusCode.ToString();
MonitorDriveBlockText = drive.AbsoluteBlock?.ToString() ?? "n/a";
if (available) {
MonitorLibraryModeText = library.DeviceMode;
MonitorLibraryPathText = library.DevicePath;
MonitorLibrarySummaryText = library.Error is null
? $"{library.OccupiedCount}/{library.TotalElements} occupied"
: "Unavailable";
MonitorLibraryDrivesText = library.Error is null
? $"{library.OccupiedDrives}/{library.DriveCount}"
: "n/a";
MonitorUpdatedText = DateTime.Now.ToString("HH:mm:ss");
MonitorLibraryErrorText = library.Error ?? string.Empty;
Replace(MonitorInventory, library.Elements.Select(MonitorElementRow.From));
Replace(LibraryInventory, library.Elements);
_libraryElements = library.Elements;
PopulateLibrarySlotCombos(library.Elements);
}
else {
MonitorInventory.Clear();
LibraryInventory.Clear();
_libraryElements = [];
ClearLibrarySlotCombos();
MonitorLibraryErrorText = library.Error ?? string.Empty;
}
var driveLabel = drive.Error is null ? drive.State : "Unavailable";
HeaderStatusText = available
? $"Drive: {driveLabel} · Library: {(library.Error is null ? $"{library.OccupiedCount}/{library.TotalElements} media, drives {library.OccupiedDrives}/{library.DriveCount}" : "Unavailable")} · Updated {DateTime.Now:HH:mm:ss}"
: $"Drive: {driveLabel} · Topology: StandaloneDrive · Updated {DateTime.Now:HH:mm:ss}";
UpdateLoadedCartridgeUi();
if (!silent)
Log("Monitor refreshed.");
}
catch (Exception ex) {
HeaderStatusText = "Monitor failed: " + ex.Message;
if (!silent)
Log("Monitor refresh failed: " + ex.Message);
}
finally {
_monitorRefreshInProgress = false;
}
}
private void ClearLibrarySlotCombos() {
LoadSources.Clear();
LoadDrives.Clear();
UnloadDrives.Clear();
SourceSlots.Clear();
DestinationSlots.Clear();
SelectedLoadSource = null;
SelectedLoadDrive = null;
SelectedUnloadDrive = null;
SelectedSourceSlot = null;
SelectedDestinationSlot = null;
}
private void PopulateLibrarySlotCombos(IReadOnlyList<LibraryElementStatus> elements) {
var all = elements.Select(SlotOption.From).ToList();
var occupied = all.Where(option => option.IsOccupied).ToList();
var emptyDrives = all.Where(option => option.IsDrive && !option.IsOccupied).ToList();
var occupiedDrives = all.Where(option => option.IsDrive && option.IsOccupied).ToList();
var emptyStorage = all.Where(option => !option.IsDrive && !option.IsOccupied).ToList();
var empty = all.Where(option => !option.IsOccupied).ToList();
var previousLoadSource = SelectedLoadSource?.Slot;
var previousLoadDrive = SelectedLoadDrive?.Slot;
var previousUnload = SelectedUnloadDrive?.Slot;
var previousSource = SelectedSourceSlot?.Slot;
var previousDestination = SelectedDestinationSlot?.Slot;
Replace(LoadSources, occupied);
Replace(LoadDrives, emptyDrives);
Replace(UnloadDrives, occupiedDrives);
Replace(SourceSlots, all);
Replace(DestinationSlots, empty);
SelectedLoadSource = SelectSlot(LoadSources, previousLoadSource)
?? occupied.FirstOrDefault(option => !option.IsDrive)
?? occupied.FirstOrDefault();
SelectedLoadDrive = SelectSlot(LoadDrives, previousLoadDrive) ?? emptyDrives.FirstOrDefault();
SelectedUnloadDrive = SelectSlot(UnloadDrives, previousUnload) ?? occupiedDrives.FirstOrDefault();
SelectedSourceSlot = SelectSlot(SourceSlots, previousSource) ?? occupied.FirstOrDefault();
SelectedDestinationSlot = SelectSlot(DestinationSlots, previousDestination)
?? emptyDrives.FirstOrDefault()
?? emptyStorage.FirstOrDefault()
?? empty.FirstOrDefault();
}
private static SlotOption? SelectSlot(IEnumerable<SlotOption> options, int? slot) {
if (!slot.HasValue)
return null;
return options.FirstOrDefault(option => option.Slot == slot.Value);
}
private static void Replace<T>(System.Collections.ObjectModel.ObservableCollection<T> target, IEnumerable<T> items) {
target.Clear();
foreach (var item in items)
target.Add(item);
}
}

View File

@ -0,0 +1,74 @@
using MaksIT.LTO.Backup.Shared;
using CommunityToolkit.Mvvm.Input;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public partial class MainViewModel {
private string? ResolvedServiceExecutable() {
var current = _configurationFileService.Current;
var overridePath = ServiceExePathSettingText?.Trim();
if (!string.IsNullOrWhiteSpace(overridePath))
current.Service.ExecutablePath = overridePath;
return HostServiceManager.ResolveExecutablePath(current.Service);
}
private void RefreshServiceStatus() {
var path = ResolvedServiceExecutable();
ServiceExePathText = path is null
? "Executable: (not found — build/publish MaksIT.LTO.Backup.Service next to the UI, or set path in Service settings)"
: "Executable: " + path;
var status = _serviceManager.GetStatus(path);
ServiceStatusText = $"Status: {status}";
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ServiceRefresh() =>
Execute("Refresh service status", RefreshServiceStatus);
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ServiceInstall() =>
Execute("Install service", () => {
var executablePath = ResolvedServiceExecutable() ?? throw new InvalidOperationException("Worker executable not found.");
var result = _serviceManager.Register(executablePath);
Log(result.Message);
if (!result.Success)
throw new InvalidOperationException(result.Message);
RefreshServiceStatus();
});
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ServiceUninstall() =>
Execute("Uninstall service", () => {
var result = _serviceManager.Unregister(ResolvedServiceExecutable());
Log(result.Message);
if (!result.Success)
throw new InvalidOperationException(result.Message);
RefreshServiceStatus();
});
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ServiceStart() =>
Execute("Start service", () => {
var result = _serviceManager.Start(ResolvedServiceExecutable());
Log(result.Message);
if (!result.Success)
throw new InvalidOperationException(result.Message);
RefreshServiceStatus();
});
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ServiceStop() =>
Execute("Stop service", () => {
var result = _serviceManager.Stop(ResolvedServiceExecutable());
Log(result.Message);
if (!result.Success)
throw new InvalidOperationException(result.Message);
RefreshServiceStatus();
});
}

View File

@ -0,0 +1,558 @@
using CommunityToolkit.Mvvm.Input;
using MaksIT.LTO.Backup.Shared;
using MaksIT.LTO.Backup.Shared.Models;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public partial class MainViewModel {
private void RefreshBackupLists() {
var selectedName = SelectedBackup?.Name ?? SelectedSettingsBackup?.Name;
BackupJobs.Clear();
foreach (var backup in _backupOrchestrator.Backups)
BackupJobs.Add(backup);
if (BackupJobs.Count == 0) {
SelectedBackup = null;
SelectedSettingsBackup = null;
return;
}
var match = BackupJobs.FirstOrDefault(item => string.Equals(item.Name, selectedName, StringComparison.OrdinalIgnoreCase));
SelectedBackup = match ?? BackupJobs[0];
SelectedSettingsBackup = SelectedBackup;
UpdateJobSummary(SelectedBackup);
}
private void UpdateJobSummary(BackupItem? backup) {
if (backup is null) {
JobSummaryText = "Select a backup job to see source, destination, LTO generation, and barcode.";
return;
}
JobSummaryText = $"{backup.LTOGen} · barcode {backup.Barcode} · source {GetFolderPath(backup.Source)} → destination {GetFolderPath(backup.Destination)}";
}
private void ApplySelectedLibraryDrive(LoadedCartridgeSnapshot selected) {
_backupOrchestrator.SelectedLibraryDriveSlot = selected.DriveSlot;
_loadingDriveSelection = true;
try {
SelectedOccupiedDrive = OccupiedLibraryDrives.FirstOrDefault(item => item.DriveSlot == selected.DriveSlot) ?? selected;
}
finally {
_loadingDriveSelection = false;
}
RefreshLoadedCartridgeSummaries();
}
private void RefreshLoadedCartridgeSummaries() {
var loaded = _backupOrchestrator.GetLoadedCartridge();
var isTapeLibrary = _configurationFileService.Current.IsTapeLibrary;
IsCartridgeLoaded = loaded.IsLoaded;
BackupLoadedSummaryText = loaded.IsLoaded ? (loaded.Summary ?? "Cartridge loaded.") : "No cartridge loaded.";
BackupLoadedHintText = loaded.Hint ?? (loaded.IsLoaded
? "Ready for backup/restore/erase on the selected job."
: "Load media first (Drive tab for standalone, or Library tab move into a drive bay).");
if (!isTapeLibrary)
return;
LibraryLoadedSummaryText = loaded.IsLoaded ? (loaded.Summary ?? "Cartridge in drive.") : "No cartridge in a drive bay.";
LibraryLoadedHintText = loaded.Hint ?? "Move a cartridge from a storage slot into a drive bay, then select the drive if more than one is loaded.";
if (loaded.IsLoaded && !string.IsNullOrWhiteSpace(loaded.Barcode))
LibraryBarcodeText = loaded.Barcode;
}
private void UpdateLoadedCartridgeUi() {
PopulateActiveDriveCombos();
RefreshLoadedCartridgeSummaries();
}
private void PopulateActiveDriveCombos() {
if (!_configurationFileService.Current.IsTapeLibrary) {
OccupiedLibraryDrives.Clear();
SelectedOccupiedDrive = null;
return;
}
var occupied = _backupOrchestrator.GetOccupiedLibraryDrives();
var previousSlot = _backupOrchestrator.SelectedLibraryDriveSlot ?? SelectedOccupiedDrive?.DriveSlot;
_loadingDriveSelection = true;
try {
OccupiedLibraryDrives.Clear();
foreach (var drive in occupied)
OccupiedLibraryDrives.Add(drive);
LoadedCartridgeSnapshot? selected = null;
if (previousSlot.HasValue)
selected = occupied.FirstOrDefault(drive => drive.DriveSlot == previousSlot.Value);
selected ??= occupied.Count == 1 ? occupied[0] : null;
SelectedOccupiedDrive = selected;
_backupOrchestrator.SelectedLibraryDriveSlot = selected?.DriveSlot;
}
finally {
_loadingDriveSelection = false;
}
}
private void LoadSettingsIntoUi() {
_loadingSettings = true;
try {
var current = _configurationFileService.Current;
DeviceMode = MatchOption(DeviceModeOptions, current.DeviceMode) ?? current.DeviceMode;
SelectedTopology = MatchOption(TopologyOptions, current.Topology) ?? current.Topology;
TapePathText = current.TapePath;
LibraryPathText = current.LibraryPath;
WriteDelayText = current.WriteDelay.ToString();
FailFastChecksum = current.FailFastOnChecksumMismatch;
EmulatorDataDirText = current.Emulator.DataDirectory;
EmulatorSlotCountText = current.Emulator.Library.SlotCount.ToString();
EmulatorDriveCountText = current.Emulator.Library.DriveCount.ToString();
EmulatorIePortCountText = current.Emulator.Library.IePortCount.ToString();
ServiceNameText = current.Service.ServiceName;
ServiceDescriptionText = current.Service.Description;
ServiceExePathSettingText = current.Service.ExecutablePath;
LoadSelectedBackupIntoUi(SelectedSettingsBackup);
RefreshAggregationLists();
UpdateTopologyUi();
}
finally {
_loadingSettings = false;
}
}
private void UpdateTopologyUi() {
OnPropertyChanged(nameof(IsTapeLibraryUi));
OnPropertyChanged(nameof(IsStandaloneDriveUi));
OnPropertyChanged(nameof(IsSavedTapeLibrary));
if (IsTapeLibraryUi) {
RefreshAvailableTapeBarcodes();
RefreshAggregationLists();
}
UpdateLoadedCartridgeUi();
}
private void LoadSelectedBackupIntoUi(BackupItem? backup) {
if (backup is null) {
BackupNameText = string.Empty;
BackupBarcodeText = string.Empty;
BackupDisabled = false;
BackupLtoGen = "LTO5";
BackupWriteMode = BackupWriteModes.Overwrite.Name;
BackupSourcePathText = string.Empty;
BackupDestinationPathText = string.Empty;
TapeRows.Clear();
RefreshAvailableTapeBarcodes();
return;
}
BackupNameText = backup.Name;
BackupBarcodeText = backup.Barcode;
BackupDisabled = backup.Disabled;
BackupLtoGen = MatchOption(LtoGenerationOptions, backup.LTOGen) ?? backup.LTOGen;
BackupWriteMode = MatchOption(WriteModeOptions, string.IsNullOrWhiteSpace(backup.WriteMode) ? BackupWriteModes.Overwrite.Name : backup.WriteMode)
?? BackupWriteModes.Overwrite.Name;
BackupSourcePathText = GetFolderPath(backup.Source);
BackupDestinationPathText = GetFolderPath(backup.Destination);
TapeRows.Clear();
foreach (var tape in backup.Tapes)
TapeRows.Add(TapeRow.FromModel(tape));
RefreshAvailableTapeBarcodes();
}
private void ClearScheduleUi() {
foreach (var month in MonthOptions)
month.IsChecked = false;
foreach (var weekday in WeekdayOptions)
weekday.IsChecked = false;
RunTimes.Clear();
MinIntervalMinutesText = "10";
NewRunTimeText = "00:00";
}
private void LoadScheduleIntoUi(BackupSchedule? schedule) {
ClearScheduleUi();
if (schedule is null)
return;
foreach (var month in MonthOptions)
month.IsChecked = schedule.RunMonth.Any(name => string.Equals(name, month.Name, StringComparison.OrdinalIgnoreCase));
foreach (var weekday in WeekdayOptions)
weekday.IsChecked = schedule.RunWeekday.Any(name => string.Equals(name, weekday.Name, StringComparison.OrdinalIgnoreCase));
foreach (var time in schedule.RunTime)
RunTimes.Add(time);
MinIntervalMinutesText = schedule.MinIntervalMinutes.ToString();
}
private BackupSchedule ReadScheduleFromUi() {
var schedule = new BackupSchedule {
RunMonth = MonthOptions.Where(option => option.IsChecked).Select(option => option.Name).ToList(),
RunWeekday = WeekdayOptions.Where(option => option.IsChecked).Select(option => option.Name).ToList(),
RunTime = RunTimes.ToList()
};
if (!int.TryParse(MinIntervalMinutesText?.Trim(), out var minutes) || minutes < 0)
throw new InvalidOperationException("Minimum interval must be a non-negative integer.");
schedule.MinIntervalMinutes = minutes;
return schedule;
}
[RelayCommand]
private void AddTape() {
if (SelectedAvailableTape is null) {
Log("Select an available library barcode first.");
return;
}
var barcode = SelectedAvailableTape.Barcode;
if (TapeRows.Any(row => string.Equals(row.Barcode, barcode, StringComparison.OrdinalIgnoreCase))) {
Log("Tape '" + barcode + "' is already in the list.");
return;
}
TapeRows.Add(new TapeRow { Barcode = barcode });
RefreshAvailableTapeBarcodes();
}
[RelayCommand]
private void RemoveTape() {
if (SelectedTapeRow is null)
return;
TapeRows.Remove(SelectedTapeRow);
RefreshAvailableTapeBarcodes();
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void RefreshAvailableTapes() =>
Execute("Refresh available tapes", RefreshAvailableTapeBarcodes);
private void RefreshAvailableTapeBarcodes() {
if (!_configurationFileService.Current.IsTapeLibrary && !IsTapeLibraryUi) {
AvailableTapes.Clear();
return;
}
HashSet<string> associated;
try {
associated = CollectAssociatedBarcodes();
}
catch {
associated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
List<AvailableTapeOption> options;
try {
options = _libraryOrchestrator.Inventory()
.Where(element => !string.IsNullOrWhiteSpace(element.Barcode))
.GroupBy(element => element.Barcode!.Trim(), StringComparer.OrdinalIgnoreCase)
.Where(group => !associated.Contains(group.Key))
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => {
var element = group.First();
return new AvailableTapeOption {
Barcode = element.Barcode!.Trim(),
Display = $"{element.Barcode.Trim()} · {element.ElementType} {element.Slot}"
+ (string.IsNullOrWhiteSpace(element.CartridgeId) ? "" : (" · " + element.CartridgeId))
};
})
.ToList();
}
catch (Exception ex) {
AvailableTapes.Clear();
Log("Could not load library inventory for tape picker: " + ex.Message);
return;
}
AvailableTapes.Clear();
foreach (var option in options)
AvailableTapes.Add(option);
SelectedAvailableTape = null;
}
private HashSet<string> CollectAssociatedBarcodes() {
var associated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var editingName = SelectedSettingsBackup?.Name;
foreach (var backup in _configurationFileService.Current.Backups) {
var barcodes = string.IsNullOrWhiteSpace(editingName) || !string.Equals(backup.Name, editingName, StringComparison.OrdinalIgnoreCase)
? backup.Tapes.Select(tape => tape.Barcode)
: TapeRows.Select(row => row.Barcode);
foreach (var barcode in barcodes) {
if (!string.IsNullOrWhiteSpace(barcode))
associated.Add(barcode.Trim());
}
}
foreach (var row in TapeRows) {
if (!string.IsNullOrWhiteSpace(row.Barcode))
associated.Add(row.Barcode.Trim());
}
return associated;
}
[RelayCommand]
private void AddRunTime() {
var raw = NewRunTimeText?.Trim() ?? string.Empty;
if (!TimeOnly.TryParse(raw, out var parsed)) {
Log("Run time must be HH:mm (UTC).");
return;
}
var value = parsed.ToString("HH:mm");
if (!RunTimes.Contains(value))
RunTimes.Add(value);
NewRunTimeText = "00:00";
}
[RelayCommand]
private void RemoveRunTime() {
if (SelectedRunTime is null)
return;
RunTimes.Remove(SelectedRunTime);
}
private static string? MatchOption(IReadOnlyList<string> options, string? value) =>
options.FirstOrDefault(option => string.Equals(option, value, StringComparison.OrdinalIgnoreCase));
private static string GetFolderPath(WorkingFolder folder) {
if (folder.LocalPath is not null)
return folder.LocalPath.Path;
return folder.RemotePath?.Path ?? string.Empty;
}
private void ApplyFolderPath(WorkingFolder folder, string path) {
if (folder.RemotePath is not null) {
folder.RemotePath.Path = path;
return;
}
folder.LocalPath ??= new LocalPath { Path = path };
folder.LocalPath.Path = path;
folder.RemotePath = null;
}
private Configuration BuildConfigurationFromUi() {
var current = _configurationFileService.Current;
current.DeviceMode = DeviceMode;
current.Topology = SelectedTopology;
current.TapePath = TapePathText?.Trim() ?? string.Empty;
current.LibraryPath = LibraryPathText?.Trim() ?? string.Empty;
if (!int.TryParse(WriteDelayText?.Trim(), out var writeDelay) || writeDelay < 0)
throw new InvalidOperationException("Write delay must be a non-negative integer.");
current.WriteDelay = writeDelay;
current.FailFastOnChecksumMismatch = FailFastChecksum;
current.Emulator.DataDirectory = EmulatorDataDirText?.Trim() ?? string.Empty;
if (!int.TryParse(EmulatorSlotCountText?.Trim(), out var slots) || slots < 1)
throw new InvalidOperationException("Emulator slots must be a positive integer.");
if (!int.TryParse(EmulatorDriveCountText?.Trim(), out var drives) || drives < 1)
throw new InvalidOperationException("Emulator drives must be a positive integer.");
if (!int.TryParse(EmulatorIePortCountText?.Trim(), out var iePorts) || iePorts < 0)
throw new InvalidOperationException("Emulator I/E ports must be a non-negative integer.");
current.Emulator.Library.SlotCount = slots;
current.Emulator.Library.DriveCount = drives;
current.Emulator.Library.IePortCount = iePorts;
current.Service.ServiceName = ServiceNameText?.Trim() ?? "MaksIT.LTO.Backup";
current.Service.Description = ServiceDescriptionText?.Trim() ?? string.Empty;
current.Service.ExecutablePath = ServiceExePathSettingText?.Trim() ?? string.Empty;
_serviceManager = new HostServiceManager(current.Service.ServiceName);
if (SelectedSettingsBackup is BackupItem backup) {
backup.Name = BackupNameText?.Trim() ?? string.Empty;
backup.Barcode = BackupBarcodeText?.Trim() ?? string.Empty;
backup.Disabled = BackupDisabled;
backup.LTOGen = BackupLtoGen;
backup.WriteMode = string.IsNullOrWhiteSpace(BackupWriteMode) ? BackupWriteModes.Overwrite.Name : BackupWriteMode;
backup.Tapes = TapeRows
.Where(row => !string.IsNullOrWhiteSpace(row.Barcode))
.Select(row => row.ToModel())
.ToList();
ApplyFolderPath(backup.Source, BackupSourcePathText?.Trim() ?? string.Empty);
ApplyFolderPath(backup.Destination, BackupDestinationPathText?.Trim() ?? string.Empty);
}
ApplyAggregationFromUi(current);
return current;
}
private void RefreshAggregationLists() {
var selectedName = SelectedAggregation?.Name ?? _configurationFileService.Current.Aggregations.FirstOrDefault()?.Name;
Aggregations.Clear();
foreach (var aggregation in _configurationFileService.Current.Aggregations)
Aggregations.Add(aggregation);
SelectedAggregationAddJob = null;
if (Aggregations.Count == 0) {
_lastAggregationSelection = null;
LoadSelectedAggregationIntoUi(null);
return;
}
var selected = Aggregations.FirstOrDefault(item => string.Equals(item.Name, selectedName, StringComparison.OrdinalIgnoreCase))
?? Aggregations[0];
var restoreLoading = _loadingSettings;
_loadingSettings = true;
try {
SelectedAggregation = selected;
}
finally {
_loadingSettings = restoreLoading;
}
_lastAggregationSelection = selected;
LoadSelectedAggregationIntoUi(selected);
}
private void LoadSelectedAggregationIntoUi(BackupAggregation? aggregation) {
if (aggregation is null) {
AggregationNameText = string.Empty;
AggregationDisabled = false;
AggregationJobNames.Clear();
ClearScheduleUi();
LastRunUtcText = "Last wave run (UTC): (never)";
return;
}
AggregationNameText = aggregation.Name;
AggregationDisabled = aggregation.Disabled;
AggregationJobNames.Clear();
foreach (var jobName in aggregation.JobNames)
AggregationJobNames.Add(jobName);
LoadScheduleIntoUi(aggregation.Schedule ?? new BackupSchedule());
LastRunUtcText = aggregation.LastRunUtc.HasValue
? $"Last wave run (UTC): {aggregation.LastRunUtc:u}"
: "Last wave run (UTC): (never)";
}
private void ApplyAggregationFromUi(Configuration config, BackupAggregation? previous = null) {
var aggregation = previous ?? SelectedAggregation;
if (aggregation is null)
return;
aggregation.Name = AggregationNameText?.Trim() ?? aggregation.Name;
aggregation.Disabled = AggregationDisabled;
aggregation.JobNames = AggregationJobNames.ToList();
aggregation.Schedule = ReadScheduleFromUi();
}
[RelayCommand]
private void AddAggregation() {
ApplyAggregationFromUi(_configurationFileService.Current);
var aggregation = new BackupAggregation {
Name = $"Wave {_configurationFileService.Current.Aggregations.Count + 1}",
Schedule = new BackupSchedule {
MinIntervalMinutes = 60,
RunTime = ["00:00"]
}
};
_configurationFileService.Current.Aggregations.Add(aggregation);
RefreshAggregationLists();
SelectedAggregation = aggregation;
}
[RelayCommand]
private void RemoveAggregation() {
if (SelectedAggregation is null)
return;
_configurationFileService.Current.Aggregations.Remove(SelectedAggregation);
_lastAggregationSelection = null;
RefreshAggregationLists();
}
[RelayCommand]
private void AggregationAddJob() {
if (SelectedAggregationAddJob is null)
return;
var name = SelectedAggregationAddJob.Name;
if (AggregationJobNames.Any(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))) {
Log("Job '" + name + "' is already in this wave.");
return;
}
AggregationJobNames.Add(name);
}
[RelayCommand]
private void AggregationRemoveJob() {
if (SelectedAggregationJobName is null)
return;
AggregationJobNames.Remove(SelectedAggregationJobName);
}
[RelayCommand]
private void AggregationMoveJobUp() {
if (SelectedAggregationJobName is null)
return;
var index = AggregationJobNames.IndexOf(SelectedAggregationJobName);
if (index <= 0)
return;
var name = SelectedAggregationJobName;
AggregationJobNames.RemoveAt(index);
AggregationJobNames.Insert(index - 1, name);
SelectedAggregationJobName = name;
}
[RelayCommand]
private void AggregationMoveJobDown() {
if (SelectedAggregationJobName is null)
return;
var index = AggregationJobNames.IndexOf(SelectedAggregationJobName);
if (index < 0 || index >= AggregationJobNames.Count - 1)
return;
var name = SelectedAggregationJobName;
AggregationJobNames.RemoveAt(index);
AggregationJobNames.Insert(index + 1, name);
SelectedAggregationJobName = name;
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void SaveConfiguration() =>
Execute("Save configuration", SaveConfigurationFromUi);
private void SaveConfigurationFromUi() {
var configuration = BuildConfigurationFromUi();
_configurationFileService.Save(configuration);
RefreshBackupLists();
LoadSettingsIntoUi();
UpdateJobSummary(SelectedBackup);
RefreshMonitor(silent: true);
RefreshAvailableTapeBarcodes();
RefreshAggregationLists();
Log("Configuration saved to " + _configurationFileService.FilePath);
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ReloadConfiguration() =>
Execute("Reload configuration", () => {
_configurationFileService.Reload();
RefreshBackupLists();
LoadSettingsIntoUi();
UpdateJobSummary(SelectedBackup);
RefreshMonitor(silent: true);
RefreshAvailableTapeBarcodes();
RefreshAggregationLists();
Log("Configuration reloaded from " + _configurationFileService.FilePath);
});
}

View File

@ -0,0 +1,676 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MaksIT.LTO.Backup.Shared;
using MaksIT.LTO.Backup.Shared.Models;
using MaksIT.LTO.Core.MassStorage;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public partial class MainViewModel : ObservableObject, IDisposable {
private readonly BackupOrchestrator _backupOrchestrator;
private readonly LibraryOrchestrator _libraryOrchestrator;
private readonly ConfigurationFileService _configurationFileService;
private readonly IDialogService _dialogs;
private readonly DispatcherTimer _monitorTimer = new();
private HostServiceManager _serviceManager;
private bool _loadingSettings;
private bool _loadingDriveSelection;
private bool _monitorRefreshInProgress;
private bool _handlingTopologyChange;
private BackupAggregation? _lastAggregationSelection;
private IReadOnlyList<LibraryElementStatus> _libraryElements = [];
public IReadOnlyList<string> DeviceModeOptions { get; } = [
DeviceModes.Emulated.Name,
DeviceModes.Physical.Name
];
public IReadOnlyList<string> TopologyOptions { get; } = [
DeviceTopologies.StandaloneDrive.Name,
DeviceTopologies.TapeLibrary.Name
];
public IReadOnlyList<string> LtoGenerationOptions { get; } = [
"LTO1", "LTO2", "LTO3", "LTO4", "LTO5", "LTO6", "LTO7", "LTO8", "LTO9"
];
public IReadOnlyList<string> WriteModeOptions { get; } = [
BackupWriteModes.Overwrite.Name,
BackupWriteModes.Append.Name
];
public IReadOnlyList<MonitorIntervalOption> MonitorIntervalOptions { get; } = [
new(2), new(5), new(10), new(30)
];
public IReadOnlyList<ScheduleToggleOption> MonthOptions { get; }
public IReadOnlyList<ScheduleToggleOption> WeekdayOptions { get; }
public ObservableCollection<BackupItem> BackupJobs { get; } = [];
public ObservableCollection<BackupAggregation> Aggregations { get; } = [];
public ObservableCollection<TapeRow> TapeRows { get; } = [];
public ObservableCollection<AvailableTapeOption> AvailableTapes { get; } = [];
public ObservableCollection<string> AggregationJobNames { get; } = [];
public ObservableCollection<string> RunTimes { get; } = [];
public ObservableCollection<MonitorElementRow> MonitorInventory { get; } = [];
public ObservableCollection<LibraryElementStatus> LibraryInventory { get; } = [];
public ObservableCollection<LoadedCartridgeSnapshot> OccupiedLibraryDrives { get; } = [];
public ObservableCollection<SlotOption> LoadSources { get; } = [];
public ObservableCollection<SlotOption> LoadDrives { get; } = [];
public ObservableCollection<SlotOption> UnloadDrives { get; } = [];
public ObservableCollection<SlotOption> SourceSlots { get; } = [];
public ObservableCollection<SlotOption> DestinationSlots { get; } = [];
[ObservableProperty]
private string _headerStatusText = "Drive and library status will appear here.";
[ObservableProperty]
private bool _liveMonitorEnabled;
[ObservableProperty]
private MonitorIntervalOption? _selectedMonitorInterval;
[ObservableProperty]
private string _monitorDriveModeText = string.Empty;
[ObservableProperty]
private string _monitorDriveTopologyText = string.Empty;
[ObservableProperty]
private string _monitorDriveStateText = string.Empty;
[ObservableProperty]
private string _monitorDrivePathText = string.Empty;
[ObservableProperty]
private string _monitorDriveStatusCodeText = string.Empty;
[ObservableProperty]
private string _monitorDriveBlockText = string.Empty;
[ObservableProperty]
private string _monitorLibraryModeText = string.Empty;
[ObservableProperty]
private string _monitorLibrarySummaryText = string.Empty;
[ObservableProperty]
private string _monitorLibraryPathText = string.Empty;
[ObservableProperty]
private string _monitorLibraryDrivesText = string.Empty;
[ObservableProperty]
private string _monitorUpdatedText = string.Empty;
[ObservableProperty]
private string _monitorLibraryErrorText = string.Empty;
[ObservableProperty]
private string _standaloneBarcodeText = string.Empty;
[ObservableProperty]
private string _libraryBarcodeText = string.Empty;
[ObservableProperty]
private string _libraryLoadedSummaryText = "No cartridge in a drive bay.";
[ObservableProperty]
private string _libraryLoadedHintText = "Move a cartridge from a storage slot into a drive bay (destination 1000+).";
[ObservableProperty]
private LoadedCartridgeSnapshot? _selectedOccupiedDrive;
[ObservableProperty]
private SlotOption? _selectedLoadSource;
[ObservableProperty]
private SlotOption? _selectedLoadDrive;
[ObservableProperty]
private SlotOption? _selectedUnloadDrive;
[ObservableProperty]
private SlotOption? _selectedSourceSlot;
[ObservableProperty]
private SlotOption? _selectedDestinationSlot;
[ObservableProperty]
private BackupItem? _selectedBackup;
[ObservableProperty]
private string _jobSummaryText = "Select a backup job to see source, destination, LTO generation, and barcode.";
[ObservableProperty]
private string _backupLoadedSummaryText = "Checking loaded cartridge…";
[ObservableProperty]
private string _backupLoadedHintText = "Backup/restore require a loaded cartridge (standalone drive tape, or library media in a drive bay).";
[ObservableProperty]
private bool _isCartridgeLoaded;
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private string _deviceMode = DeviceModes.Emulated.Name;
[ObservableProperty]
private string _selectedTopology = DeviceTopologies.StandaloneDrive.Name;
[ObservableProperty]
private string _tapePathText = string.Empty;
[ObservableProperty]
private string _libraryPathText = string.Empty;
[ObservableProperty]
private string _writeDelayText = "100";
[ObservableProperty]
private bool _failFastChecksum;
[ObservableProperty]
private string _emulatorDataDirText = string.Empty;
[ObservableProperty]
private string _emulatorSlotCountText = "24";
[ObservableProperty]
private string _emulatorDriveCountText = "2";
[ObservableProperty]
private string _emulatorIePortCountText = "1";
[ObservableProperty]
private BackupItem? _selectedSettingsBackup;
[ObservableProperty]
private bool _backupDisabled;
[ObservableProperty]
private string _backupNameText = string.Empty;
[ObservableProperty]
private string _backupBarcodeText = string.Empty;
[ObservableProperty]
private string _backupLtoGen = "LTO5";
[ObservableProperty]
private string _backupWriteMode = BackupWriteModes.Overwrite.Name;
[ObservableProperty]
private string _backupSourcePathText = string.Empty;
[ObservableProperty]
private string _backupDestinationPathText = string.Empty;
[ObservableProperty]
private AvailableTapeOption? _selectedAvailableTape;
[ObservableProperty]
private TapeRow? _selectedTapeRow;
[ObservableProperty]
private BackupAggregation? _selectedAggregation;
[ObservableProperty]
private bool _aggregationDisabled;
[ObservableProperty]
private string _aggregationNameText = string.Empty;
[ObservableProperty]
private BackupItem? _selectedAggregationAddJob;
[ObservableProperty]
private string? _selectedAggregationJobName;
[ObservableProperty]
private string _newRunTimeText = "00:00";
[ObservableProperty]
private string? _selectedRunTime;
[ObservableProperty]
private string _minIntervalMinutesText = "10";
[ObservableProperty]
private string _lastRunUtcText = "Last wave run (UTC): (never)";
[ObservableProperty]
private string _serviceStatusText = "Status: Unknown";
[ObservableProperty]
private string _serviceExePathText = "Executable: (not found)";
[ObservableProperty]
private string _serviceNameText = string.Empty;
[ObservableProperty]
private string _serviceDescriptionText = string.Empty;
[ObservableProperty]
private string _serviceExePathSettingText = string.Empty;
[ObservableProperty]
private string _logText = string.Empty;
public bool IsTapeLibraryUi =>
DeviceTopologies.Matches(SelectedTopology, DeviceTopologies.TapeLibrary);
public bool IsStandaloneDriveUi =>
!IsTapeLibraryUi;
public bool IsSavedTapeLibrary =>
_configurationFileService.Current.IsTapeLibrary;
public bool OperationsEnabled =>
IsCartridgeLoaded && !IsBusy;
public MainViewModel(
BackupOrchestrator backupOrchestrator,
LibraryOrchestrator libraryOrchestrator,
ConfigurationFileService configurationFileService,
IDialogService dialogs
) {
_backupOrchestrator = backupOrchestrator;
_libraryOrchestrator = libraryOrchestrator;
_configurationFileService = configurationFileService;
_dialogs = dialogs;
_serviceManager = new HostServiceManager(_configurationFileService.Current.Service.ServiceName);
MonthOptions = [
new("January", "Jan"), new("February", "Feb"), new("March", "Mar"),
new("April", "Apr"), new("May", "May"), new("June", "Jun"),
new("July", "Jul"), new("August", "Aug"), new("September", "Sep"),
new("October", "Oct"), new("November", "Nov"), new("December", "Dec")
];
WeekdayOptions = [
new("Monday", "Mon"), new("Tuesday", "Tue"), new("Wednesday", "Wed"),
new("Thursday", "Thu"), new("Friday", "Fri"), new("Saturday", "Sat"),
new("Sunday", "Sun")
];
SelectedMonitorInterval = MonitorIntervalOptions.First(option => option.Seconds == 5);
_monitorTimer.Tick += (_, _) => RefreshMonitor(silent: true);
ApplyMonitorInterval();
RefreshBackupLists();
LoadSettingsIntoUi();
RefreshServiceStatus();
RefreshMonitor(silent: true);
}
private bool _disposed;
public void Dispose() {
if (_disposed)
return;
_disposed = true;
_monitorTimer.Stop();
GC.SuppressFinalize(this);
}
partial void OnLiveMonitorEnabledChanged(bool value) {
ApplyMonitorInterval();
if (value) {
_monitorTimer.Start();
RefreshMonitor(silent: true);
return;
}
_monitorTimer.Stop();
}
partial void OnSelectedMonitorIntervalChanged(MonitorIntervalOption? value) =>
ApplyMonitorInterval();
partial void OnIsBusyChanged(bool value) {
OnPropertyChanged(nameof(OperationsEnabled));
NotifyIdleCommands();
}
partial void OnIsCartridgeLoadedChanged(bool value) {
OnPropertyChanged(nameof(OperationsEnabled));
RunBackupCommand.NotifyCanExecuteChanged();
RunRestoreCommand.NotifyCanExecuteChanged();
EraseCommand.NotifyCanExecuteChanged();
}
partial void OnSelectedTopologyChanged(string value) {
OnPropertyChanged(nameof(IsTapeLibraryUi));
OnPropertyChanged(nameof(IsStandaloneDriveUi));
if (_loadingSettings || _handlingTopologyChange)
return;
_ = HandleTopologyChangedAsync();
}
partial void OnSelectedBackupChanged(BackupItem? value) {
if (!_loadingSettings)
UpdateJobSummary(value);
}
partial void OnSelectedSettingsBackupChanged(BackupItem? value) {
if (!_loadingSettings)
LoadSelectedBackupIntoUi(value);
}
partial void OnSelectedOccupiedDriveChanged(LoadedCartridgeSnapshot? value) {
if (_loadingDriveSelection || value is null)
return;
ApplySelectedLibraryDrive(value);
}
partial void OnSelectedAggregationChanged(BackupAggregation? value) {
if (_loadingSettings)
return;
if (value is not null)
ApplyAggregationFromUi(_configurationFileService.Current, _lastAggregationSelection);
_lastAggregationSelection = value;
LoadSelectedAggregationIntoUi(value);
}
private async Task HandleTopologyChangedAsync() {
var wasStandalone = _configurationFileService.Current.IsStandaloneDrive;
var nowLibrary = IsTapeLibraryUi;
if (wasStandalone && nowLibrary) {
var confirmed = await _dialogs.ConfirmAsync(
"Switch to TapeLibrary?",
"Switching to TapeLibrary enables library inventory, tape pools, and scheduled aggregations. Current settings will be saved now. Continue?");
if (!confirmed) {
RevertTopologyToStandalone();
return;
}
Execute("Save configuration", SaveConfigurationFromUi);
if (_configurationFileService.Current.IsStandaloneDrive)
RevertTopologyToStandalone();
return;
}
UpdateTopologyUi();
}
private void RevertTopologyToStandalone() {
_handlingTopologyChange = true;
try {
SelectedTopology = DeviceTopologies.StandaloneDrive.Name;
}
finally {
_handlingTopologyChange = false;
}
UpdateTopologyUi();
}
private void ApplyMonitorInterval() {
var seconds = SelectedMonitorInterval?.Seconds ?? 5;
if (seconds <= 0)
seconds = 5;
_monitorTimer.Interval = TimeSpan.FromSeconds(seconds);
}
private void Log(string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
Dispatcher.UIThread.Post(() => Log(message));
return;
}
LogText += $"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}";
}
private bool CanRunWhenIdle() =>
!IsBusy;
private bool CanRunCartridgeOperation() =>
IsCartridgeLoaded && !IsBusy;
private void NotifyIdleCommands() {
RefreshMonitorNowCommand.NotifyCanExecuteChanged();
LoadTapeCommand.NotifyCanExecuteChanged();
EjectTapeCommand.NotifyCanExecuteChanged();
GetStatusCommand.NotifyCanExecuteChanged();
ReadAttributesCommand.NotifyCanExecuteChanged();
WriteStandaloneBarcodeCommand.NotifyCanExecuteChanged();
WriteLibraryBarcodeCommand.NotifyCanExecuteChanged();
RefreshInventoryCommand.NotifyCanExecuteChanged();
LoadIntoDriveCommand.NotifyCanExecuteChanged();
UnloadFromDriveCommand.NotifyCanExecuteChanged();
MoveMediumCommand.NotifyCanExecuteChanged();
RunBackupCommand.NotifyCanExecuteChanged();
RunRestoreCommand.NotifyCanExecuteChanged();
EraseCommand.NotifyCanExecuteChanged();
SaveConfigurationCommand.NotifyCanExecuteChanged();
ReloadConfigurationCommand.NotifyCanExecuteChanged();
ServiceRefreshCommand.NotifyCanExecuteChanged();
ServiceInstallCommand.NotifyCanExecuteChanged();
ServiceUninstallCommand.NotifyCanExecuteChanged();
ServiceStartCommand.NotifyCanExecuteChanged();
ServiceStopCommand.NotifyCanExecuteChanged();
RefreshAvailableTapesCommand.NotifyCanExecuteChanged();
}
private void Execute(string action, Action work) {
if (IsBusy) {
Log(action + " skipped: another operation is already running.");
return;
}
IsBusy = true;
try {
work();
if (!action.StartsWith("Save", StringComparison.Ordinal) && !action.StartsWith("Reload", StringComparison.Ordinal))
Log(action + " completed.");
}
catch (Exception ex) {
Log(action + " failed: " + ex.Message);
}
finally {
IsBusy = false;
RefreshMonitor(silent: true);
}
}
private async Task ExecuteBackgroundAsync(string action, Action work) {
if (IsBusy) {
Log(action + " skipped: another operation is already running.");
return;
}
IsBusy = true;
HeaderStatusText = action + " in progress…";
Log(action + " started…");
try {
await Task.Run(work).ConfigureAwait(true);
await Dispatcher.UIThread.InvokeAsync(() => Log(action + " completed."));
}
catch (Exception ex) {
await Dispatcher.UIThread.InvokeAsync(() => Log(action + " failed: " + ex.Message));
}
finally {
await Dispatcher.UIThread.InvokeAsync(() => {
IsBusy = false;
RefreshMonitor(silent: true);
});
}
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void RefreshMonitorNow() =>
RefreshMonitor(silent: false);
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task LoadTapeAsync() =>
ExecuteBackgroundAsync("Load tape", _backupOrchestrator.LoadTape);
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task EjectTapeAsync() =>
ExecuteBackgroundAsync("Eject tape", _backupOrchestrator.EjectTape);
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task GetStatusAsync() =>
ExecuteBackgroundAsync("Get status", () => {
var status = _backupOrchestrator.GetDeviceStatus();
Dispatcher.UIThread.Post(() => Log($"Status code: {status}"));
});
[RelayCommand(CanExecute = nameof(CanRunCartridgeOperation))]
private Task EraseAsync() {
var backup = SelectedBackup;
return ExecuteBackgroundAsync("Tape erase", () => {
_backupOrchestrator.EnsureCartridgeLoaded();
if (backup is null)
throw new InvalidOperationException("Select backup job first.");
_backupOrchestrator.TapeErase(backup.LTOGen);
});
}
[RelayCommand(CanExecute = nameof(CanRunCartridgeOperation))]
private Task RunBackupAsync() {
var backup = SelectedBackup;
return ExecuteBackgroundAsync("Backup", () => {
_backupOrchestrator.EnsureCartridgeLoaded();
if (backup is null)
throw new InvalidOperationException("Select backup job first.");
_backupOrchestrator.Backup(backup);
});
}
[RelayCommand(CanExecute = nameof(CanRunCartridgeOperation))]
private Task RunRestoreAsync() {
var backup = SelectedBackup;
return ExecuteBackgroundAsync("Restore", () => {
_backupOrchestrator.EnsureCartridgeLoaded();
if (backup is null)
throw new InvalidOperationException("Select backup job first.");
_backupOrchestrator.Restore(backup);
});
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task RefreshInventoryAsync() =>
ExecuteBackgroundAsync("Inventory", () => Dispatcher.UIThread.Post(() => RefreshMonitor(silent: true)));
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task LoadIntoDriveAsync() {
var source = SelectedLoadSource;
var drive = SelectedLoadDrive;
return ExecuteBackgroundAsync("Load into drive", () => {
if (source is null)
throw new InvalidOperationException("Select a cartridge (occupied slot) to load.");
if (drive is null)
throw new InvalidOperationException("Select an empty drive bay.");
if (!source.IsOccupied)
throw new InvalidOperationException("Source must contain a cartridge.");
if (!drive.IsDrive || drive.IsOccupied)
throw new InvalidOperationException("Destination must be an empty drive bay.");
_libraryOrchestrator.MoveMedium(source.Slot, drive.Slot);
});
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task UnloadFromDriveAsync() {
var drive = SelectedUnloadDrive;
var elements = _libraryElements.ToList();
return ExecuteBackgroundAsync("Unload from drive", () => {
if (drive is null)
throw new InvalidOperationException("Select a drive bay that has a cartridge.");
if (!drive.IsDrive || !drive.IsOccupied)
throw new InvalidOperationException("Selected unload source must be an occupied drive bay.");
if (elements.Count == 0)
throw new InvalidOperationException("Refresh inventory first.");
var emptyStorage = elements.FirstOrDefault(element =>
!string.Equals(element.ElementType, "Drive", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(element.CartridgeId))
?? throw new InvalidOperationException("No empty storage slot available for unload.");
_libraryOrchestrator.MoveMedium(drive.Slot, emptyStorage.Slot);
});
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task MoveMediumAsync() {
var source = SelectedSourceSlot;
var destination = SelectedDestinationSlot;
return ExecuteBackgroundAsync("Move medium", () => {
if (source is null)
throw new InvalidOperationException("Select a source slot.");
if (destination is null)
throw new InvalidOperationException("Select a destination slot.");
_libraryOrchestrator.MoveMedium(source.Slot, destination.Slot);
});
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task ReadAttributesAsync() =>
ExecuteBackgroundAsync("Read attributes", () => {
_backupOrchestrator.EnsureCartridgeLoaded();
var lines = _backupOrchestrator.ReadCartridgeMemory()
.Select(attribute => $"(0x{attribute.Address:X4}) {attribute.Name}: {attribute.GetValueAsString()}")
.ToList();
Dispatcher.UIThread.Post(() => {
foreach (var line in lines)
Log(line);
});
});
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task WriteStandaloneBarcodeAsync() {
var barcode = StandaloneBarcodeText ?? string.Empty;
var isLibrary = _configurationFileService.Current.IsTapeLibrary;
return ExecuteBackgroundAsync("Write barcode", () => {
if (isLibrary)
throw new InvalidOperationException("Use the Library tab to assign barcode for a loaded library cartridge.");
_backupOrchestrator.WriteBarcode(barcode);
});
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private Task WriteLibraryBarcodeAsync() {
var barcode = LibraryBarcodeText ?? string.Empty;
var isLibrary = _configurationFileService.Current.IsTapeLibrary;
return ExecuteBackgroundAsync("Assign barcode", () => {
if (!isLibrary)
throw new InvalidOperationException("Library barcode assignment requires Topology = TapeLibrary.");
_backupOrchestrator.EnsureCartridgeLoaded();
_backupOrchestrator.WriteBarcode(barcode);
});
}
}

View File

@ -0,0 +1,101 @@
using CommunityToolkit.Mvvm.ComponentModel;
using MaksIT.LTO.Backup.Shared.Models;
using MaksIT.LTO.Core.MassStorage;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public sealed class TapeRow {
public string Barcode { get; set; } = string.Empty;
public DateTimeOffset? LastUsedUtc { get; set; }
public string LastUsedUtcDisplay => LastUsedUtc?.ToString("u") ?? "(never)";
public static TapeRow FromModel(BackupTape tape) => new TapeRow {
Barcode = tape.Barcode,
LastUsedUtc = tape.LastUsedUtc
};
public BackupTape ToModel() => new BackupTape {
Barcode = Barcode.Trim(),
LastUsedUtc = LastUsedUtc
};
}
public sealed class AvailableTapeOption {
public required string Barcode { get; init; }
public required string Display { get; init; }
}
public sealed class MonitorElementRow {
public int Slot { get; init; }
public required string ElementType { get; init; }
public required string Occupied { get; init; }
public string? Barcode { get; init; }
public string? CartridgeId { get; init; }
public static MonitorElementRow From(LibraryElementStatus element) => new MonitorElementRow {
Slot = element.Slot,
ElementType = element.ElementType,
Occupied = string.IsNullOrWhiteSpace(element.CartridgeId) ? "No" : "Yes",
Barcode = element.Barcode,
CartridgeId = element.CartridgeId
};
}
public sealed class SlotOption {
public int Slot { get; init; }
public required string ElementType { get; init; }
public bool IsOccupied { get; init; }
public bool IsDrive { get; init; }
public string? Barcode { get; init; }
public string? CartridgeId { get; init; }
public required string Display { get; init; }
public static SlotOption From(LibraryElementStatus element) {
var isOccupied = !string.IsNullOrWhiteSpace(element.CartridgeId);
var isDrive = string.Equals(element.ElementType, "Drive", StringComparison.OrdinalIgnoreCase);
var display = isOccupied
? $"{element.ElementType} {element.Slot} · {element.Barcode ?? "no barcode"} · {element.CartridgeId}"
: $"{element.ElementType} {element.Slot} · empty";
return new SlotOption {
Slot = element.Slot,
ElementType = element.ElementType,
IsOccupied = isOccupied,
IsDrive = isDrive,
Barcode = element.Barcode,
CartridgeId = element.CartridgeId,
Display = display
};
}
}
public sealed record MonitorIntervalOption(int Seconds) {
public string Display => $"{Seconds} s";
}
public partial class ScheduleToggleOption : ObservableObject {
public string Name { get; }
public string Label { get; }
[ObservableProperty]
private bool _isChecked;
public ScheduleToggleOption(string name, string label) {
Name = name;
Label = label;
}
}

View File

@ -10,15 +10,17 @@ namespace MaksIT.LTO.Backup;
public static class Program {
public static int Main(string[] args) {
var basePath = AppContext.BaseDirectory;
var configurationPath = Path.Combine(basePath, "configuration.json");
var userSettings = UserSettingsPath.Get(ConfigurationFileService.ProductFolder);
var 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<BackupOrchestrator>();
services.AddSingleton<LibraryOrchestrator>();
services.AddSingleton<ScheduledBackupRunner>();

View File

@ -35,6 +35,7 @@ else {
}
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
Initialize-RepoUtilsVaultSecrets -Settings $settings -Plugins $configuredPlugins
$releaseBanner = if ($null -ne $releaseExtension) {
$releaseExtension.StepBanner

View File

@ -2,6 +2,8 @@
"$schema": "https://json-schema.org/draft-07/schema",
"title": "LTO Backup Release Script Settings",
"description": "RepoUtils release pipeline for maksit-lto-backup application binaries.",
"repoUtilsSecretsShared": "RepoUtilsSecretsShared",
"repoUtilsSecrets": "RepoUtilsSecrets",
"plugins": [
{
"name": "DotNetReleaseVersion",
@ -67,7 +69,7 @@
"name": "GitHub",
"stageLabel": "release",
"enabled": true,
"githubSecret": "GitHub",
"githubSecret": "GitClone",
"repository": "https://github.com/MAKS-IT-COM/maksit-lto-backup",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"

View File

@ -321,6 +321,37 @@ function Get-EngineState {
return $null
}
function Add-ReleaseAssetPaths {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[string[]]$Path
)
$list = [System.Collections.Generic.List[string]]::new()
$existing = Get-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Default @() -LegacyProperty @('releaseAssetPaths')
foreach ($item in @($existing)) {
$value = [string]$item
if (-not [string]::IsNullOrWhiteSpace($value) -and -not $list.Contains($value)) {
$list.Add($value)
}
}
foreach ($item in @($Path)) {
$value = [string]$item
if ([string]::IsNullOrWhiteSpace($value) -or $list.Contains($value)) {
continue
}
$list.Add($value)
}
Set-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Value @($list) -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
}
Export-ModuleMember -Function `
Resolve-RelativePaths, `
Initialize-EngineFactsBag, `
@ -329,4 +360,5 @@ Export-ModuleMember -Function `
Test-EngineFact, `
Set-EngineState, `
Add-EnginePublishCompletion, `
Add-ReleaseAssetPaths, `
Get-EngineState

View File

@ -15,6 +15,7 @@ function Import-EngineModules {
(Join-Path $modulesDir 'ScriptConfig.psm1'),
(Join-Path $modulesDir 'Logging.psm1'),
(Join-Path $engineModuleDir 'PluginSupport.psm1'),
(Join-Path $engineModuleDir 'VaultSupport.psm1'),
(Join-Path $engineModuleDir 'EngineContext.psm1')
)

View File

@ -33,6 +33,7 @@ function Test-IsEngineRuntimeModuleName {
'TestRunner',
'EngineContext',
'PluginSupport',
'VaultSupport',
'ReleaseSupport',
'TestSupport'
),
@ -53,7 +54,7 @@ function Get-PluginDependencyGroupDirectories {
}
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
$preferred = @('Shared', 'Platform', 'DotNet', 'Npm')
$preferred = @('Shared', 'Platform', 'DotNet', 'Npm', 'Desktop')
$dirs = [System.Collections.Generic.List[string]]::new()
foreach ($name in $preferred) {
$path = Join-Path $PluginsRoot $name
@ -293,129 +294,586 @@ function Test-PluginMutatesRemote {
return $false
}
function Get-SecretEnvironmentValue {
function Get-RepoUtilsEnvironmentVariable {
<#
.SYNOPSIS
Reads a secret value from an environment variable by logical name.
Reads a named environment variable from Process, then Windows User, then Machine.
.DESCRIPTION
Plugins never store secret material in scriptSettings.json. Settings hold a
logical name (e.g. "GitHub", "NuGet"); the process environment variable with
that same name must be set before the engine runs.
.PARAMETER Name
Logical secret name — also the environment variable name to read.
.OUTPUTS
System.String. The environment variable value, or $null when unset.
.EXAMPLE
$token = Get-SecretEnvironmentValue -Name 'GitHub'
Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the
current process was started before a User-level pack was set, User/Machine still
apply so laptop releases do not require a new shell. An explicit process value —
including empty JSON `{}` — shadows User/Machine.
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
return [Environment]::GetEnvironmentVariable($Name)
}
if ([string]::IsNullOrWhiteSpace($Name)) {
throw "Environment variable name is required."
}
function Resolve-PluginSecretName {
<#
.SYNOPSIS
Resolves a logical secret name from a plugin's scriptSettings entry.
$processValue = [Environment]::GetEnvironmentVariable($Name, 'Process')
# Empty string is what SetEnvironmentVariable($null, Process) leaves behind;
# treat it as unset so Windows User packs still apply. Explicit '{}' shadows User.
if (-not [string]::IsNullOrWhiteSpace($processValue)) {
return $processValue
}
.DESCRIPTION
Reads a string property such as githubSecret / nugetSecret / npmSecret /
containerRegistrySecret from the plugin settings object. Returns $null when
the property is missing or blank.
foreach ($target in @('User', 'Machine')) {
try {
$value = [Environment]::GetEnvironmentVariable($Name, $target)
}
catch {
continue
}
.PARAMETER PluginSettings
Plugin settings object from scriptSettings.json (the enabled plugin entry).
.PARAMETER PropertyName
Settings property that holds the logical secret name (e.g. 'githubSecret').
.OUTPUTS
System.String. Trimmed logical secret name, or $null.
.EXAMPLE
$name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret'
$key = Get-SecretEnvironmentValue -Name $name
#>
param(
[Parameter(Mandatory = $true)]
$PluginSettings,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
$value = [string]$PluginSettings.$PropertyName
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
return $value
}
}
return $null
}
function Get-RepoUtilsSecretsEnvNames {
<#
.SYNOPSIS
Reads declared pack environment variable names from scriptSettings / engine context.
#>
param(
[Parameter(Mandatory = $false)]
$Settings
)
$sharedName = $null
$packName = $null
if ($null -ne $Settings) {
if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecretsShared') {
$sharedName = [string]$Settings.repoUtilsSecretsShared
}
if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecrets') {
$packName = [string]$Settings.repoUtilsSecrets
}
}
$sharedName = if ($null -eq $sharedName) { '' } else { $sharedName.Trim() }
$packName = if ($null -eq $packName) { '' } else { $packName.Trim() }
if ([string]::IsNullOrWhiteSpace($sharedName) -or [string]::IsNullOrWhiteSpace($packName)) {
throw "scriptSettings.json must declare repoUtilsSecretsShared and repoUtilsSecrets (environment variable names, e.g. RepoUtilsSecretsShared / RepoUtilsSecrets)."
}
return [pscustomobject]@{
SharedEnv = $sharedName
PackEnv = $packName
}
}
function ConvertFrom-RepoUtilsSecretsPackJson {
<#
.SYNOPSIS
Parses a RepoUtilsSecrets JSON object. Empty input is {}. Bare non-JSON throws.
#>
param(
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$Raw,
[Parameter(Mandatory = $true)]
[string]$SourceName
)
if ([string]::IsNullOrWhiteSpace($Raw)) {
return [pscustomobject]@{}
}
$trimmed = $Raw.Trim()
if (-not $trimmed.StartsWith('{')) {
throw "${SourceName} must be a JSON object (RepoUtilsSecrets pack), not a bare string."
}
try {
$parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "${SourceName} is not valid JSON: $($_.Exception.Message)"
}
if ($null -eq $parsed -or $parsed -is [System.Collections.IEnumerable]) {
throw "${SourceName} JSON must be an object."
}
return $parsed
}
function ConvertTo-OrdinalPropertyMap {
param($Object)
$map = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
if ($null -eq $Object) {
return $map
}
if ($Object -is [System.Collections.IDictionary]) {
foreach ($key in @($Object.Keys)) {
$name = [string]$key
if ([string]::IsNullOrWhiteSpace($name)) {
continue
}
$map[$name] = $Object[$key]
}
return $map
}
foreach ($property in $Object.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$map[[string]$property.Name] = $property.Value
}
return $map
}
function ConvertFrom-OrdinalPropertyMap {
param(
[Parameter(Mandatory = $true)]
[System.Collections.Generic.Dictionary[string, object]]$Map
)
$properties = [ordered]@{}
foreach ($key in $Map.Keys) {
$properties[$key] = $Map[$key]
}
return [pscustomobject]$properties
}
function Merge-RepoUtilsSecretsPackObjects {
<#
.SYNOPSIS
Appsettings-style merge: org-pack first, slug overlay. Nested merge for any object-valued slot (typically ContainerRegistry).
#>
param(
$Base,
$Overlay
)
$result = ConvertTo-OrdinalPropertyMap -Object $Base
$overlayMap = ConvertTo-OrdinalPropertyMap -Object $Overlay
foreach ($key in @($overlayMap.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
}
$overlayValue = $overlayMap[$key]
$overlayIsObject = ($null -ne $overlayValue) -and -not ($overlayValue -is [string]) -and -not ($overlayValue -is [ValueType]) -and -not ($overlayValue -is [System.Collections.IEnumerable])
if ($overlayIsObject) {
$baseCatalog = $null
if ($result.ContainsKey($key)) {
$baseCatalog = $result[$key]
}
$mergedCatalog = ConvertTo-OrdinalPropertyMap -Object $baseCatalog
$overlayCatalogMap = ConvertTo-OrdinalPropertyMap -Object $overlayValue
foreach ($catalogKey in @($overlayCatalogMap.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $catalogKey)) {
throw "Catalog key '$catalogKey' in pack slot '$key' must be PascalCase (e.g. Harbor, InCluster)."
}
$mergedCatalog[$catalogKey] = $overlayCatalogMap[$catalogKey]
}
$result[$key] = ConvertFrom-OrdinalPropertyMap -Map $mergedCatalog
continue
}
$result[$key] = $overlayValue
}
foreach ($key in @($result.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
}
}
return ConvertFrom-OrdinalPropertyMap -Map $result
}
function Get-MergedRepoUtilsSecretsPack {
<#
.SYNOPSIS
Merges $env:RepoUtilsSecretsShared then $env:RepoUtilsSecrets (names from settings).
#>
param(
[Parameter(Mandatory = $false)]
$Settings
)
$names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
$sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
$packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
$sharedObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv
$packObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv
return Merge-RepoUtilsSecretsPackObjects -Base $sharedObject -Overlay $packObject
}
function Get-RepoUtilsSecretSlot {
<#
.SYNOPSIS
Reads a scalar pack slot (GitClone, NuGet, Npm, CosignKey, …) after merge.
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
$Settings,
[switch]$AllowMissing
)
if ([string]::IsNullOrWhiteSpace($Name) -or -not (Test-ContainerRegistryCatalogKey -Key $Name)) {
throw "RepoUtilsSecrets slot '$Name' must be PascalCase (e.g. GitClone, NuGet)."
}
$merged = Get-MergedRepoUtilsSecretsPack -Settings $Settings
$match = $null
foreach ($property in $merged.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Name, [System.StringComparison]::Ordinal)) {
$match = $property
break
}
}
if ($null -eq $match) {
if ($AllowMissing) {
return $null
}
throw "RepoUtilsSecrets slot '$Name' is missing after merging org-pack and slug-pack."
}
$value = $match.Value
if ($value -is [string] -or $null -eq $value -or $value -is [ValueType]) {
$text = if ($null -eq $value) { '' } else { [string]$value }
if ([string]::IsNullOrWhiteSpace($text) -and -not $AllowMissing) {
throw "RepoUtilsSecrets slot '$Name' is empty."
}
if ([string]::IsNullOrWhiteSpace($text)) {
return $null
}
return $text
}
throw "RepoUtilsSecrets slot '$Name' must be a string (nested maps are only allowed for ContainerRegistry)."
}
function Copy-RepoUtilsSecretsEnvNamesToContext {
param(
[Parameter(Mandatory = $true)]
$Context,
[Parameter(Mandatory = $false)]
$Settings
)
if ($null -eq $Settings) {
return $Context
}
foreach ($name in @('repoUtilsSecretsShared', 'repoUtilsSecrets', 'vaultRepoUtilsApplication')) {
if ($Settings.PSObject.Properties.Name -contains $name -and -not [string]::IsNullOrWhiteSpace([string]$Settings.$name)) {
$Context | Add-Member -NotePropertyName $name -NotePropertyValue ([string]$Settings.$name).Trim() -Force
}
}
return $Context
}
function Test-ContainerRegistryCatalogKey {
<#
.SYNOPSIS
True when Key is PascalCase (Harbor, InCluster), matching env-slot names.
#>
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Key
)
return $Key -cmatch '^[A-Z][A-Za-z0-9]*$'
}
function Resolve-PluginSecretName {
<#
.SYNOPSIS
Reads a plugin setting that names a RepoUtilsSecrets pack subkey.
.DESCRIPTION
Settings such as githubSecret / nugetSecret / npmSecret / containerRegistrySecret /
cosignKeySecret hold the PascalCase pack slot to read (e.g. GitClone, NuGet).
They are not environment variable names.
.PARAMETER PluginSettings
Plugin settings object from scriptSettings.json.
.PARAMETER PropertyName
Settings property that holds the pack subkey name (e.g. 'githubSecret').
.PARAMETER PluginDisplayName
Plugin name used in required/validation errors.
.PARAMETER Required
Throw when the property is missing or blank.
#>
param(
[Parameter(Mandatory = $true)]
$PluginSettings,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $false)]
[string]$PluginDisplayName,
[switch]$Required
)
$display = if ([string]::IsNullOrWhiteSpace($PluginDisplayName)) { 'Plugin' } else { $PluginDisplayName }
$value = $null
if ($null -ne $PluginSettings -and $PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
$raw = [string]$PluginSettings.$PropertyName
if (-not [string]::IsNullOrWhiteSpace($raw)) {
$value = $raw.Trim()
}
}
if ([string]::IsNullOrWhiteSpace($value)) {
if ($Required) {
throw "$display requires '$PropertyName' (PascalCase pack subkey, e.g. GitClone, NuGet, ContainerRegistry)."
}
return $null
}
if (-not (Test-ContainerRegistryCatalogKey -Key $value)) {
throw "$display '$PropertyName' '$value' must be PascalCase (e.g. GitClone, NuGet, ContainerRegistry)."
}
return $value
}
function Assert-RetiredContainerRegistrySettingsAbsent {
<#
.SYNOPSIS
Throws when obsolete per-registry secret settings are present.
#>
param(
$Object,
[Parameter(Mandatory = $true)]
[string]$Context
)
if ($null -eq $Object) {
return
}
$retired = @(
'imagesCredentialsSecret',
'additionalImageRegistries',
'additionalImageRegistryUrls',
'additionalImagesCredentialsSecret',
'helmOciCredentialsSecret'
)
foreach ($name in $retired) {
$present = $false
if ($Object -is [System.Collections.IDictionary]) {
$present = $Object.Contains($name)
}
elseif ($Object.PSObject.Properties.Name -contains $name) {
$present = $true
}
if ($present) {
throw "${Context}: '$name' is not supported. Use the ContainerRegistry JSON catalog with PascalCase containerRegistryKey / helmRegistryKey."
}
}
}
function ConvertFrom-RegistryCredentialPayload {
param(
[Parameter(Mandatory = $true)]
[string]$Payload,
[Parameter(Mandatory = $true)]
[string]$ContextName
)
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload.Trim()))
}
catch {
throw "Failed to decode '$ContextName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$ContextName' must be in the form 'username:password'."
}
return @{ User = $parts[0]; Password = $parts[1] }
}
function Get-ContainerRegistryCatalogObject {
<#
.SYNOPSIS
Validates a PascalCase JSON map of Base64(username:password) values.
#>
param(
[Parameter(Mandatory = $true)]
$Catalog,
[Parameter(Mandatory = $false)]
[string]$SourceName = 'ContainerRegistry'
)
if ($Catalog -is [string]) {
$raw = [string]$Catalog
$trimmed = $raw.Trim()
if (-not $trimmed.StartsWith('{')) {
throw "$SourceName must be a JSON catalog object { `"Harbor`": `"<Base64>`", `"InCluster`": `"<Base64>`" }."
}
try {
$Catalog = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "$SourceName is not valid JSON: $($_.Exception.Message)"
}
}
if ($null -eq $Catalog -or $Catalog -is [string] -or $Catalog -is [ValueType] -or $Catalog -is [System.Collections.IEnumerable]) {
throw "$SourceName JSON catalog must be an object of PascalCase keys to Base64(username:password)."
}
$keyCount = 0
foreach ($property in $Catalog.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$keyCount++
$keyName = [string]$property.Name
if (-not (Test-ContainerRegistryCatalogKey -Key $keyName)) {
throw "Catalog key '$keyName' in '$SourceName' must be PascalCase (e.g. Harbor, InCluster)."
}
}
if ($keyCount -eq 0) {
throw "$SourceName JSON catalog has no PascalCase keys."
}
return $Catalog
}
function Get-RegistryCredentialsFromRuntime {
<#
.SYNOPSIS
Loads container-registry username/password from a logical secret name.
Loads container-registry username/password from the merged RepoUtilsSecrets pack.
.DESCRIPTION
Looks up the environment variable named by SecretName. The value must be
Base64(UTF8('username:password')). Used by registry login and image-pull
secret creation — never pass the password itself as a parameter.
Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after
org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry.
.PARAMETER SecretName
Logical secret name (environment variable name), not a password or token.
.PARAMETER Key
PascalCase catalog key (e.g. Harbor). Required.
.PARAMETER Slot
PascalCase pack subkey that holds the catalog object (from containerRegistrySecret).
.PARAMETER SharedSettings
Optional engine shared context (reserved for callers that thread context).
Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets).
.OUTPUTS
Hashtable with User and Password keys (decoded credential material).
.EXAMPLE
$creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry'
# $creds.User / $creds.Password
#>
# SecretName is a logical env-var name from scriptSettings, not a password value.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSAvoidUsingPlainTextForPassword',
'SecretName',
Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.'
)]
param(
[Parameter(Mandatory = $true)]
[string]$SecretName,
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$Slot,
[Parameter(Mandatory = $false)]
[psobject]$SharedSettings
)
$raw = Get-SecretEnvironmentValue -Name $SecretName
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "Environment variable '$SecretName' is not set."
if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) {
throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)."
}
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
}
catch {
throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
if ([string]::IsNullOrWhiteSpace($Key)) {
throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'."
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$SecretName' must be in the form 'username:password'."
if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) {
throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)."
}
return @{ User = $parts[0]; Password = $parts[1] }
$merged = Get-MergedRepoUtilsSecretsPack -Settings $SharedSettings
$catalogProperty = $null
foreach ($property in $merged.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Slot, [System.StringComparison]::Ordinal)) {
$catalogProperty = $property
break
}
}
if ($null -eq $catalogProperty -or $null -eq $catalogProperty.Value) {
throw "RepoUtilsSecrets slot '$Slot' is missing after merging org-pack and slug-pack."
}
$catalog = Get-ContainerRegistryCatalogObject -Catalog $catalogProperty.Value -SourceName $Slot
$match = $null
foreach ($property in $catalog.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Key, [System.StringComparison]::Ordinal)) {
$match = $property
break
}
}
if ($null -eq $match) {
throw "Catalog key '$Key' was not found in '$Slot' (ordinal PascalCase match)."
}
$payload = [string]$match.Value
if ([string]::IsNullOrWhiteSpace($payload)) {
throw "Catalog key '$Key' in '$Slot' is empty."
}
return ConvertFrom-RegistryCredentialPayload -Payload $payload -ContextName "$Slot.$Key"
}
function Resolve-EngineDirectoryFromSharedSettings {
@ -596,7 +1054,7 @@ function Resolve-PluginModulePath {
$candidatePaths = [System.Collections.Generic.List[string]]::new()
$candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName))
$preferredGroups = @('Platform', 'DotNet', 'Npm')
$preferredGroups = @('Platform', 'DotNet', 'Npm', 'Desktop')
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName))
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
@ -756,4 +1214,4 @@ function Invoke-ConfiguredPlugin {
}
}
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Get-RepoUtilsEnvironmentVariable, Get-RepoUtilsSecretsEnvNames, ConvertFrom-RepoUtilsSecretsPackJson, Merge-RepoUtilsSecretsPackObjects, Get-MergedRepoUtilsSecretsPack, Get-RepoUtilsSecretSlot, Copy-RepoUtilsSecretsEnvNamesToContext, Resolve-PluginSecretName, Test-ContainerRegistryCatalogKey, Assert-RetiredContainerRegistrySettingsAbsent, Get-ContainerRegistryCatalogObject, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -213,6 +213,7 @@ function New-EngineContext {
skipPublishPlugins = $false
facts = [ordered]@{}
}
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir
$version = [string](Get-EngineState -Context $context -Name 'version' -Required)

View File

@ -38,6 +38,7 @@ function New-EngineContext {
utilsDir = $SrcDir
facts = [ordered]@{}
}
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) {

View File

@ -0,0 +1,373 @@
#requires -Version 7.0
#requires -PSEdition Core
function Test-RepoUtilsUseVaultEnabled {
param(
[Parameter(Mandatory = $true)]
$Settings
)
if ($null -eq $Settings -or -not ($Settings.PSObject.Properties.Name -contains 'useVault')) {
return $false
}
$value = $Settings.useVault
if ($value -is [bool]) {
return $value
}
return [string]$value -eq 'true'
}
function ConvertFrom-VaultConnectionSecret {
param(
[Parameter(Mandatory = $true)]
[string]$Raw
)
$trimmed = $Raw.Trim()
$separator = $trimmed.IndexOf('|')
if ($separator -lt 1 -or $separator -ge ($trimmed.Length - 1)) {
throw "Vault connection must be 'baseAddress|apiKey' (pipe separator). Example: http://172.16.0.14|your-key"
}
$baseAddress = $trimmed.Substring(0, $separator).Trim().TrimEnd('/')
$apiKey = $trimmed.Substring($separator + 1).Trim()
if ([string]::IsNullOrWhiteSpace($baseAddress) -or [string]::IsNullOrWhiteSpace($apiKey)) {
throw "Vault connection is missing base address or API key."
}
return [pscustomobject]@{
BaseAddress = $baseAddress
ApiKey = $apiKey
}
}
function Get-RepoUtilsVaultScope {
param(
[Parameter(Mandatory = $true)]
$Settings
)
$organization = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_ORGANIZATION')
if ([string]::IsNullOrWhiteSpace($organization) -and ($Settings.PSObject.Properties.Name -contains 'vaultOrganization')) {
$organization = [string]$Settings.vaultOrganization
}
$application = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_APPLICATION')
if ([string]::IsNullOrWhiteSpace($application) -and ($Settings.PSObject.Properties.Name -contains 'vaultApplication')) {
$application = [string]$Settings.vaultApplication
}
$organization = if ($null -eq $organization) { '' } else { $organization.Trim() }
$application = if ($null -eq $application) { '' } else { $application.Trim() }
if ([string]::IsNullOrWhiteSpace($organization) -or [string]::IsNullOrWhiteSpace($application)) {
throw "useVault is true but vault organization/application are not set. Set vaultOrganization/vaultApplication in scriptSettings.json or MAKSIT_VAULT_ORGANIZATION / MAKSIT_VAULT_APPLICATION."
}
return [pscustomobject]@{
Organization = $organization
Application = $application
}
}
function Get-RepoUtilsVaultConnectionSecretName {
param(
[Parameter(Mandatory = $true)]
$Settings
)
if (($Settings.PSObject.Properties.Name -contains 'vaultConnectionSecret') -and
-not [string]::IsNullOrWhiteSpace([string]$Settings.vaultConnectionSecret)) {
return ([string]$Settings.vaultConnectionSecret).Trim()
}
return 'MAKSIT_VAULT'
}
function Get-RepoUtilsVaultOrgPackApplicationName {
param(
[Parameter(Mandatory = $true)]
$Settings
)
$application = $null
if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') {
$application = [string]$Settings.vaultRepoUtilsApplication
}
$application = if ($null -eq $application) { '' } else { $application.Trim() }
if ([string]::IsNullOrWhiteSpace($application)) {
throw "useVault is true but vaultRepoUtilsApplication is not set in scriptSettings.json (Vault application for the org-pack, e.g. Shared)."
}
return $application
}
function Get-VaultNameFilterExpression {
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
$escaped = $Name.Replace('\', '\\').Replace('"', '\"')
return "Name == `"$escaped`""
}
function Import-RepoUtilsVaultClientModule {
$modulePath = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_MODULE_PATH')
if (-not [string]::IsNullOrWhiteSpace($modulePath)) {
Import-Module $modulePath -Force -ErrorAction Stop
return
}
if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) {
return
}
Import-Module 'MaksIT.Vault.Client.PowerShell' -ErrorAction SilentlyContinue
}
function Find-RepoUtilsVaultSecretMatch {
param(
[Parameter(Mandatory = $true)]
[string]$OrganizationName,
[Parameter(Mandatory = $true)]
[string]$ApplicationName,
[Parameter(Mandatory = $true)]
[string]$SecretName,
[Parameter(Mandatory = $true)]
[pscustomobject]$Connection,
[Parameter(Mandatory = $true)]
[ValidateSet('Cmdlet', 'Rest')]
[string]$Mode
)
$orgFilter = Get-VaultNameFilterExpression -Name $OrganizationName
$appFilter = Get-VaultNameFilterExpression -Name $ApplicationName
$pageNumber = 1
while ($true) {
$items = @()
$hasNext = $false
if ($Mode -eq 'Cmdlet') {
$response = Search-VaultSecrets -PageNumber $pageNumber -PageSize 100 `
-OrganizationFilters $orgFilter -ApplicationFilters $appFilter
if ($null -ne $response -and $null -ne $response.Items) {
$items = @($response.Items)
}
if ($null -ne $response) {
$hasNext = [bool]$response.HasNextPage
}
}
else {
$body = @{
pageNumber = $pageNumber
pageSize = 100
organizationFilters = $orgFilter
applicationFilters = $appFilter
} | ConvertTo-Json -Compress
$uri = "$($Connection.BaseAddress)/api/vault/secrets"
$headers = @{ 'X-API-KEY' = $Connection.ApiKey }
$response = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json' -Body $body
if ($null -ne $response.items) {
$items = @($response.items)
}
elseif ($null -ne $response.Items) {
$items = @($response.Items)
}
$hasNext = [bool]($response.hasNextPage)
if (-not $hasNext) {
$hasNext = [bool]($response.HasNextPage)
}
}
foreach ($item in $items) {
$itemName = [string]$(if ($item.PSObject.Properties.Name -contains 'Name') { $item.Name } else { $item.name })
if ([string]::Equals($itemName, $SecretName, [System.StringComparison]::Ordinal)) {
return $item
}
}
if (-not $hasNext) {
break
}
$pageNumber++
}
return $null
}
function Get-RepoUtilsVaultSecretValue {
param(
[Parameter(Mandatory = $true)]
$Match,
[Parameter(Mandatory = $true)]
[pscustomobject]$Connection,
[Parameter(Mandatory = $true)]
[ValidateSet('Cmdlet', 'Rest')]
[string]$Mode
)
$organizationId = if ($Match.PSObject.Properties.Name -contains 'OrganizationId') { $Match.OrganizationId } else { $Match.organizationId }
$applicationId = if ($Match.PSObject.Properties.Name -contains 'ApplicationId') { $Match.ApplicationId } else { $Match.applicationId }
$secretId = if ($Match.PSObject.Properties.Name -contains 'Id') { $Match.Id } else { $Match.id }
if ($Mode -eq 'Cmdlet') {
$version = Get-VaultSecret -OrganizationId $organizationId -ApplicationId $applicationId -SecretId $secretId -SecretVersion 'current'
if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version.Value)) {
return $null
}
return [string]$version.Value
}
$uri = "$($Connection.BaseAddress)/api/vault/organization/$organizationId/application/$applicationId/secret/$secretId" +
'?secretVersion=current'
$headers = @{ 'X-API-KEY' = $Connection.ApiKey }
$version = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers
$value = if ($version.PSObject.Properties.Name -contains 'Value') { $version.Value } else { $version.value }
if ([string]::IsNullOrWhiteSpace([string]$value)) {
return $null
}
return [string]$value
}
function Resolve-RepoUtilsVaultSecretValue {
param(
[Parameter(Mandatory = $true)]
[string]$OrganizationName,
[Parameter(Mandatory = $true)]
[string]$ApplicationName,
[Parameter(Mandatory = $true)]
[string]$SecretName,
[Parameter(Mandatory = $true)]
[pscustomobject]$Connection,
[Parameter(Mandatory = $true)]
[string]$Mode
)
$match = Find-RepoUtilsVaultSecretMatch `
-OrganizationName $OrganizationName `
-ApplicationName $ApplicationName `
-SecretName $SecretName `
-Connection $Connection `
-Mode $Mode
if ($null -eq $match) {
return $null
}
$value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName"
return $value
}
function Initialize-RepoUtilsVaultSecrets {
param(
[Parameter(Mandatory = $true)]
$Settings,
[Parameter(Mandatory = $true)]
$Plugins
)
if (-not (Test-RepoUtilsUseVaultEnabled -Settings $Settings)) {
return
}
$names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
$sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
$packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
$sharedPresent = -not [string]::IsNullOrWhiteSpace($sharedRaw)
$packPresent = -not [string]::IsNullOrWhiteSpace($packRaw)
if ($sharedPresent -and $packPresent) {
[void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv)
[void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv)
Write-Log -Level 'INFO' -Message "Vault mode: pack env vars '$($names.SharedEnv)' and '$($names.PackEnv)' already set; skipping Vault fetch."
return
}
$connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings
$raw = [Environment]::GetEnvironmentVariable($connectionName)
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "useVault is true but environment variable '$connectionName' is not set (expected baseAddress|apiKey)."
}
$connection = ConvertFrom-VaultConnectionSecret -Raw $raw
$scope = Get-RepoUtilsVaultScope -Settings $Settings
$orgPackApplication = Get-RepoUtilsVaultOrgPackApplicationName -Settings $Settings
Write-Log -Level 'INFO' -Message "Vault mode: loading RepoUtilsSecrets packs for $($scope.Organization)/$orgPackApplication and $($scope.Organization)/$($scope.Application)"
$mode = 'Rest'
try {
Import-RepoUtilsVaultClientModule
if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) {
Connect-Vault -BaseAddress $connection.BaseAddress -ApiKey $connection.ApiKey
$mode = 'Cmdlet'
Write-Log -Level 'INFO' -Message 'Connected to Vault with MaksIT.Vault.Client.PowerShell'
}
else {
Write-Log -Level 'INFO' -Message 'MaksIT.Vault.Client.PowerShell not found; using Vault HTTP API'
}
}
catch {
Write-Log -Level 'INFO' -Message "Vault PowerShell module not loaded ($($_.Exception.Message)); using Vault HTTP API"
$mode = 'Rest'
}
if (-not $sharedPresent) {
$sharedValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization `
-ApplicationName $orgPackApplication `
-SecretName $names.SharedEnv `
-Connection $connection `
-Mode $mode
if ([string]::IsNullOrWhiteSpace($sharedValue)) {
$sharedValue = '{}'
Write-Log -Level 'INFO' -Message "Vault secret '$($names.SharedEnv)' was not found for $($scope.Organization)/$orgPackApplication; using empty org-pack."
}
[Environment]::SetEnvironmentVariable($names.SharedEnv, $sharedValue, 'Process')
}
if (-not $packPresent) {
$packValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization `
-ApplicationName $scope.Application `
-SecretName $names.PackEnv `
-Connection $connection `
-Mode $mode
if ([string]::IsNullOrWhiteSpace($packValue)) {
$packValue = '{}'
Write-Log -Level 'INFO' -Message "Vault secret '$($names.PackEnv)' was not found for $($scope.Organization)/$($scope.Application); using empty slug-pack."
}
[Environment]::SetEnvironmentVariable($names.PackEnv, $packValue, 'Process')
}
}
Export-ModuleMember -Function @(
'Test-RepoUtilsUseVaultEnabled',
'ConvertFrom-VaultConnectionSecret',
'Get-RepoUtilsVaultScope',
'Get-RepoUtilsVaultConnectionSecretName',
'Get-RepoUtilsVaultOrgPackApplicationName',
'Initialize-RepoUtilsVaultSecrets'
)

View File

@ -107,7 +107,7 @@ function Invoke-ExternalCommand {
if ($ThrowOnError -and $exitCode -ne 0) {
$preview = ($output | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ }
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 8) -join ' '
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 12) -join ' '
if ([string]::IsNullOrWhiteSpace($preview)) {
throw "External command '$Name' failed with exit code $exitCode."
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,445 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Linux Flatpak pack plugin for published .NET desktop apps (Community).
.DESCRIPTION
Stages a linux-* DotNetPublish folder into a Flatpak layout (manifest,
desktop file, metainfo, launch script). Prefers Flathub org.flatpak.Builder
(current builder) over distro flatpak-builder. On Windows, native builder
is tried first; if missing, the plugin builds via a pinned WSL distro
(default Debian) on the Linux filesystem, then copies the bundle back.
Docker Desktop WSL distros are never used. whenBuilderMissing skip|fail
applies when neither native nor WSL builder is available.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-FlatpakWhenBuilderMissing {
param($PluginSettings)
$value = [string](Get-PluginPropertyValue -PluginSettings $PluginSettings -Name 'whenBuilderMissing' -Default 'skip')
if ($value -notin @('skip', 'fail')) {
throw "FlatpakPack whenBuilderMissing must be 'skip' or 'fail'."
}
return $value
}
function Test-FlatpakBuilderMissingException {
param(
[Parameter(Mandatory = $true)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
$message = [string]$ErrorRecord.Exception.Message
if ($message -match '(?i)bash: line \d+') {
return $false
}
if ($ErrorRecord.Exception -is [System.Management.Automation.CommandNotFoundException]) {
return $true
}
if ($message -match '(?i)(not recognized|command not found|The term .+ is not recognized|marked unavailable)') {
return $true
}
return $false
}
function Test-WslFallbackSkippable {
param(
[Parameter(Mandatory = $true)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
if (Test-FlatpakBuilderMissingException -ErrorRecord $ErrorRecord) {
return $true
}
$message = [string]$ErrorRecord.Exception.Message
if ($message -match "(?i)WSL distro '.+' is not installed") {
return $true
}
return $false
}
function Get-WslInstalledDistroNames {
$output = Invoke-ExternalCommand -Name wsl -ArgumentList @('-l', '-q') -ThrowOnError:$false
return @(ConvertFrom-WslListOutput -InputObject $output)
}
function Invoke-FlatpakBundleViaWsl {
param(
[Parameter(Mandatory = $true)]
[string]$Distro,
[Parameter(Mandatory = $true)]
[string]$WindowsStageRoot,
[Parameter(Mandatory = $true)]
[string]$WindowsBundlePath,
[Parameter(Mandatory = $true)]
[string]$AppId
)
Assert-WslDistroAllowedForFlatpak -Distro $Distro
$installed = @(Get-WslInstalledDistroNames)
if (-not (Test-WslDistroInstalled -Distro $Distro -InstalledNames $installed)) {
throw "WSL distro '$Distro' is not installed. Install Debian with: wsl --install Debian --no-launch --web-download"
}
$script = ConvertTo-UnixLineEndings -Value (
New-WslFlatpakBuildScript `
-WindowsStageRoot $WindowsStageRoot `
-WindowsBundlePath $WindowsBundlePath `
-AppId $AppId
)
if (-not $script.EndsWith("`n")) {
$script += "`n"
}
$tempScript = Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-flatpak-" + [guid]::NewGuid().ToString('N') + ".sh")
try {
[System.IO.File]::WriteAllBytes($tempScript, [System.Text.UTF8Encoding]::new($false).GetBytes($script))
$wslScript = ConvertTo-WslMountPath -WindowsPath $tempScript
Invoke-ExternalCommand -Name wsl -ArgumentList @('-d', $Distro, '--', 'bash', $wslScript) | Out-Null
}
finally {
if (Test-Path -LiteralPath $tempScript -PathType Leaf) {
Remove-Item -LiteralPath $tempScript -Force -ErrorAction SilentlyContinue
}
}
}
function Invoke-HostFlatpakBuilder {
param(
[Parameter(Mandatory = $true)]
[string]$RepoDir,
[Parameter(Mandatory = $true)]
[string]$BuildDir,
[Parameter(Mandatory = $true)]
[string]$ManifestPath
)
$hasFlathubBuilder = $false
try {
Invoke-ExternalCommand -Name flatpak -ArgumentList @('info', 'org.flatpak.Builder') -ThrowOnError:$false | Out-Null
$hasFlathubBuilder = ($global:LASTEXITCODE -eq 0)
}
catch {
if (-not (Test-FlatpakBuilderMissingException -ErrorRecord $_)) {
throw
}
}
if ($hasFlathubBuilder) {
$stageRoot = [System.IO.Path]::GetDirectoryName($ManifestPath)
Invoke-ExternalCommand -Name flatpak -ArgumentList @(
'run',
'--filesystem=host',
'--share=network',
"--cwd=$stageRoot",
'org.flatpak.Builder',
'--disable-rofiles-fuse',
"--repo=$RepoDir",
'--force-clean',
$BuildDir,
$ManifestPath
) | Out-Null
return
}
Invoke-ExternalCommand -Name flatpak-builder -ArgumentList @(
'--disable-rofiles-fuse',
"--repo=$RepoDir",
'--force-clean',
$BuildDir,
$ManifestPath
) | Out-Null
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-WslFlatpakBuildScript"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = [string]$sharedSettings.version
$artifactsDirectory = $sharedSettings.artifactsDirectory
$appId = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appId')
$appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName')
if ([string]::IsNullOrWhiteSpace($appId)) {
throw "FlatpakPack plugin requires appId (reverse-DNS, e.g. com.maks_it.Wvc210)."
}
Assert-FlatpakAppId -AppId $appId
if ([string]::IsNullOrWhiteSpace($appName)) {
throw "FlatpakPack plugin requires appName."
}
$command = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'command')
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'linux-x64')
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
$summary = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'summary' -Default $appName)
$projectLicense = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'projectLicense' -Default 'MIT')
$categories = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'categories' -Default 'Utility;')
$runtime = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtime' -Default 'org.freedesktop.Platform')
$runtimeVersion = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeVersion' -Default '24.08')
$sdk = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sdk' -Default 'org.freedesktop.Sdk')
$whenBuilderMissing = Get-FlatpakWhenBuilderMissing -PluginSettings $pluginSettings
$wslDistro = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'wslDistro' -Default 'Debian')
$useWslSetting = Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'useWsl' -Default $null
$useWsl = if ($null -eq $useWslSetting) { [bool]$IsWindows } else { [bool]$useWslSetting }
$buildBundle = $true
if ($pluginSettings.PSObject.Properties['buildBundle'] -and $null -ne $pluginSettings.buildBundle) {
$buildBundle = [bool]$pluginSettings.buildBundle
}
$attachSourceZip = $false
if ($pluginSettings.PSObject.Properties['attachSourceZip'] -and $null -ne $pluginSettings.attachSourceZip) {
$attachSourceZip = [bool]$pluginSettings.attachSourceZip
}
$finishArgs = @(Get-DefaultFlatpakFinishArgs)
if ($pluginSettings.PSObject.Properties['finishArgs'] -and $null -ne $pluginSettings.finishArgs) {
$finishArgs = @($pluginSettings.finishArgs | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
throw "FlatpakPack plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path -LiteralPath $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$publishDirectory = Resolve-DesktopPublishDirectory `
-Context $sharedSettings `
-RuntimeIdentifier $runtimeIdentifier `
-PublishDir $publishDirSetting `
-ScriptDir $scriptDir
$executablePath = Resolve-DesktopExecutablePath `
-PublishDirectory $publishDirectory `
-ExecutableName $executableName
$executableFileName = [System.IO.Path]::GetFileName($executablePath)
if ([string]::IsNullOrWhiteSpace($command)) {
$command = $executableFileName
}
$moduleName = ($appId.Split('.') | Select-Object -Last 1).ToLowerInvariant()
$stageRoot = Join-Path $artifactsDirectory "flatpak-$moduleName"
if (Test-Path -LiteralPath $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force
}
$filesRoot = Join-Path $stageRoot 'files'
$libDir = Join-Path $filesRoot "lib\$moduleName"
$binDir = Join-Path $filesRoot 'bin'
$shareAppDir = Join-Path $filesRoot 'share\applications'
$shareMetaDir = Join-Path $filesRoot 'share\metainfo'
$shareIconDir = Join-Path $filesRoot 'share\icons\hicolor\256x256\apps'
New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $shareIconDir -Force | Out-Null
Write-Log -Level "STEP" -Message "Staging Flatpak layout from $publishDirectory"
Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $libDir -Recurse -Force
$launchScript = New-FlatpakLaunchScript -ModuleName $moduleName -ExecutableFileName $executableFileName
$launchPath = Join-Path $binDir $command
[System.IO.File]::WriteAllText($launchPath, $launchScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
$desktopPath = Join-Path $shareAppDir "$appId.desktop"
[System.IO.File]::WriteAllText(
$desktopPath,
(New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"),
[System.Text.UTF8Encoding]::new($false)
)
$metaPath = Join-Path $shareMetaDir "$appId.metainfo.xml"
[System.IO.File]::WriteAllText(
$metaPath,
(New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -ProjectLicense $projectLicense).Replace("`r`n", "`n"),
[System.Text.UTF8Encoding]::new($false)
)
if (-not [string]::IsNullOrWhiteSpace($iconSetting)) {
$iconPath = if ([System.IO.Path]::IsPathRooted($iconSetting)) {
$iconSetting
}
else {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $iconSetting))
}
if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
throw "FlatpakPack iconPath not found: $iconPath"
}
if ([System.IO.Path]::GetExtension($iconPath) -ine '.png') {
Write-Log -Level "WARN" -Message " Flatpak icon should be PNG; skipping copy of $iconPath"
}
else {
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $shareIconDir "$appId.png") -Force
}
}
$buildCommands = @(
'mkdir -p /app/lib /app/share',
"cp -a lib/$moduleName /app/lib/$moduleName",
'cp -a share /app/share',
"install -Dm755 bin/$command /app/bin/$command",
"chmod +x /app/lib/$moduleName/$executableFileName"
)
$manifest = New-FlatpakManifestObject `
-AppId $appId `
-Command $command `
-ModuleName $moduleName `
-Runtime $runtime `
-RuntimeVersion $runtimeVersion `
-Sdk $sdk `
-FinishArgs $finishArgs `
-BuildCommands $buildCommands
$manifestPath = Join-Path $stageRoot 'manifest.json'
$manifestJson = $manifest | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($manifestPath, $manifestJson, [System.Text.UTF8Encoding]::new($false))
$bundleNamePattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'bundleNamePattern' -Default '{name}-{version}.flatpak')
$safeName = ($appName -replace '[^A-Za-z0-9._-]', '-').Trim('-')
$bundleFileName = $bundleNamePattern.Replace('{version}', $version).Replace('{id}', $appId).Replace('{name}', $safeName)
$bundlePath = Join-Path $artifactsDirectory $bundleFileName
$builderBlock = (Get-FlatpakBuilderShellLines) -join "`n"
$buildScript = @"
#!/bin/sh
set -e
cd "`$(dirname "`$0")"
$builderBlock
flatpak build-bundle "`$ROOT/repo" "../$bundleFileName" "$appId"
"@
[System.IO.File]::WriteAllText((Join-Path $stageRoot 'build-flatpak.sh'), $buildScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
$assetPaths = [System.Collections.Generic.List[string]]::new()
if ($attachSourceZip) {
$sourceZipPattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sourceZipNamePattern' -Default '{id}-{version}-flatpak-src.zip')
$sourceZipName = $sourceZipPattern.Replace('{version}', $version).Replace('{id}', $appId).Replace('{name}', $safeName)
$sourceZipPath = Join-Path $artifactsDirectory $sourceZipName
if (Test-Path -LiteralPath $sourceZipPath -PathType Leaf) {
Remove-Item -LiteralPath $sourceZipPath -Force
}
Compress-Archive -Path $stageRoot -DestinationPath $sourceZipPath -CompressionLevel Optimal -Force
Write-Log -Level "OK" -Message " Flatpak source archive: $sourceZipPath"
$assetPaths.Add($sourceZipPath)
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'flatpakSourcePath' -Value $sourceZipPath -Overwrite Replace
}
if ($buildBundle) {
Write-Log -Level "STEP" -Message "Building Flatpak bundle..."
$buildDir = Join-Path $stageRoot 'build-dir'
$repoDir = Join-Path $stageRoot 'repo'
$built = $false
try {
Invoke-HostFlatpakBuilder -RepoDir $repoDir -BuildDir $buildDir -ManifestPath $manifestPath
if (Test-Path -LiteralPath $bundlePath -PathType Leaf) {
Remove-Item -LiteralPath $bundlePath -Force
}
Invoke-ExternalCommand -Name flatpak -ArgumentList @(
'build-bundle',
$repoDir,
$bundlePath,
$appId
) | Out-Null
if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) {
throw "flatpak build-bundle completed but bundle was not produced: $bundlePath"
}
$built = $true
}
catch {
if (-not (Test-FlatpakBuilderMissingException -ErrorRecord $_)) {
throw
}
Write-Log -Level "INFO" -Message " Native Flatpak builder not available (Flathub org.flatpak.Builder or distro flatpak-builder)."
}
if (-not $built -and $useWsl -and $IsWindows) {
try {
Write-Log -Level "STEP" -Message "Building Flatpak bundle via WSL distro $wslDistro (Linux filesystem, not Docker Desktop)..."
if (Test-Path -LiteralPath $bundlePath -PathType Leaf) {
Remove-Item -LiteralPath $bundlePath -Force
}
Invoke-FlatpakBundleViaWsl `
-Distro $wslDistro `
-WindowsStageRoot $stageRoot `
-WindowsBundlePath $bundlePath `
-AppId $appId
if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) {
throw "WSL flatpak build-bundle completed but bundle was not produced: $bundlePath"
}
$built = $true
}
catch {
if ($whenBuilderMissing -eq 'skip' -and (Test-WslFallbackSkippable -ErrorRecord $_)) {
Write-Log -Level "WARN" -Message " WSL Flatpak builder not available ($($_.Exception.Message)); skipped bundle."
}
else {
throw
}
}
}
if ($built) {
Write-Log -Level "OK" -Message " Flatpak bundle ready: $bundlePath"
$assetPaths.Add($bundlePath)
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'flatpakBundlePath' -Value $bundlePath -Overwrite Replace
}
elseif ($whenBuilderMissing -eq 'fail') {
throw "Flatpak builder is not available (Flathub org.flatpak.Builder, distro flatpak-builder, or WSL distro $wslDistro)."
}
elseif (-not $useWsl -or -not $IsWindows) {
Write-Log -Level "WARN" -Message " Flatpak builder not available; skipped bundle. Install Flathub org.flatpak.Builder (preferred) or distro flatpak-builder."
}
}
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
if ($assetPaths.Count -gt 0) {
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($assetPaths)
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,303 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Windows installer plugin for published .NET desktop apps (Community).
.DESCRIPTION
Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps
it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS
stay in a staging folder and are not added to the portable zip.
`wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so
per-machine installs go to `C:\Program Files`, not Program Files (x86).
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
`wix extension add -g WixToolset.BootstrapperApplications.wixext`.
Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Test-WixMissingException {
param(
[Parameter(Mandatory = $true)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
$message = [string]$ErrorRecord.Exception.Message
if ($ErrorRecord.Exception -is [System.Management.Automation.CommandNotFoundException]) {
return $true
}
return ($message -match '(?i)(not recognized|command not found|The term .+ is not recognized|marked unavailable)')
}
function Get-WixMissingInstallMessage {
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
}
function Get-WixCliVersionText {
$raw = Invoke-ExternalCommand -Name wix -ArgumentList @('--version')
return ((@($raw) | ForEach-Object { [string]$_ }) -join '').Trim()
}
function Get-WixAcceptEulaArguments {
param(
[Parameter(Mandatory = $false)]
[string]$VersionText
)
if ($VersionText -match '^7\.') {
return @('-acceptEula', 'wix7')
}
return @()
}
function Get-WixBundleExtensionName {
param(
[Parameter(Mandatory = $false)]
[string]$VersionText
)
if ($VersionText -match '^[5-9]\.') {
return 'WixToolset.BootstrapperApplications.wixext'
}
return 'WixToolset.Bal.wixext'
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-WixPackageXml"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = [string]$sharedSettings.version
$artifactsDirectory = $sharedSettings.artifactsDirectory
$appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName')
$upgradeCodeRaw = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'upgradeCode')
if ([string]::IsNullOrWhiteSpace($appName)) {
throw "WindowsInstaller plugin requires appName."
}
if ([string]::IsNullOrWhiteSpace($upgradeCodeRaw)) {
throw "WindowsInstaller plugin requires a stable upgradeCode GUID (per product, never change it)."
}
$upgradeCode = [guid]$upgradeCodeRaw
$manufacturer = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'manufacturer' -Default 'MaksIT')
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'win-x64')
$wixArch = Get-WixArchitectureFromRuntimeIdentifier -RuntimeIdentifier $runtimeIdentifier
$archArgs = @('-arch', $wixArch)
$installScope = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installScope' -Default 'perMachine')
$installFolderName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installFolderName')
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
$logoSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'logoPath')
$logoSideSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'logoSidePath')
$themeSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'themePath')
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
throw "WindowsInstaller plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path -LiteralPath $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$publishDirectory = Resolve-DesktopPublishDirectory `
-Context $sharedSettings `
-RuntimeIdentifier $runtimeIdentifier `
-PublishDir $publishDirSetting `
-ScriptDir $scriptDir
$executablePath = Resolve-DesktopExecutablePath `
-PublishDirectory $publishDirectory `
-ExecutableName $executableName `
-Windows
$brandDir = Join-Path $PSScriptRoot 'brand'
$resolvePackPath = {
param([string]$Setting)
if ([string]::IsNullOrWhiteSpace($Setting)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($Setting)) {
return $Setting
}
return [System.IO.Path]::GetFullPath((Join-Path $scriptDir $Setting))
}
$iconPath = & $resolvePackPath $iconSetting
if (-not [string]::IsNullOrWhiteSpace($iconSetting) -and -not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
throw "WindowsInstaller iconPath not found: $iconPath"
}
if ([string]::IsNullOrWhiteSpace($iconPath)) {
$fallbackIcon = Join-Path $brandDir 'mark.ico'
if (Test-Path -LiteralPath $fallbackIcon -PathType Leaf) {
$iconPath = $fallbackIcon
}
}
$logoPath = & $resolvePackPath $logoSetting
if (-not [string]::IsNullOrWhiteSpace($logoSetting) -and -not (Test-Path -LiteralPath $logoPath -PathType Leaf)) {
throw "WindowsInstaller logoPath not found: $logoPath"
}
if ([string]::IsNullOrWhiteSpace($logoPath)) {
$fallbackLogo = Join-Path $brandDir 'logo-64.png'
if (Test-Path -LiteralPath $fallbackLogo -PathType Leaf) {
$logoPath = $fallbackLogo
}
}
$logoSidePath = & $resolvePackPath $logoSideSetting
if (-not [string]::IsNullOrWhiteSpace($logoSideSetting) -and -not (Test-Path -LiteralPath $logoSidePath -PathType Leaf)) {
throw "WindowsInstaller logoSidePath not found: $logoSidePath"
}
if ([string]::IsNullOrWhiteSpace($logoSidePath)) {
$fallbackSide = Join-Path $brandDir 'sidebar.png'
if (Test-Path -LiteralPath $fallbackSide -PathType Leaf) {
$logoSidePath = $fallbackSide
}
}
$themePath = & $resolvePackPath $themeSetting
if (-not [string]::IsNullOrWhiteSpace($themeSetting) -and -not (Test-Path -LiteralPath $themePath -PathType Leaf)) {
throw "WindowsInstaller themePath not found: $themePath"
}
if ([string]::IsNullOrWhiteSpace($themePath)) {
$fallbackTheme = Join-Path $brandDir 'HyperlinkSidebarTheme.xml'
if (Test-Path -LiteralPath $fallbackTheme -PathType Leaf) {
$themePath = $fallbackTheme
}
}
$productVersion = Get-MsiProductVersion -Version $version
$safeName = ($appName -replace '[^A-Za-z0-9._-]', '-').Trim('-')
$exeNamePattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'exeNamePattern' -Default '{name}-{version}.exe')
$exeFileName = $exeNamePattern.Replace('{version}', $version).Replace('{name}', $safeName)
$exePath = Join-Path $artifactsDirectory $exeFileName
$stageDir = Join-Path $artifactsDirectory '.wix-stage'
if (Test-Path -LiteralPath $stageDir) {
Remove-Item -LiteralPath $stageDir -Recurse -Force
}
New-Item -ItemType Directory -Path $stageDir | Out-Null
$wxsPath = Join-Path $stageDir ($safeName + '-' + $version + '.wxs')
$msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi')
$bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs')
Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)"
$xml = New-WixPackageXml `
-AppName $appName `
-Manufacturer $manufacturer `
-ProductVersion $productVersion `
-UpgradeCode $upgradeCode `
-PublishDirectory $publishDirectory `
-ExecutablePath $executablePath `
-InstallScope $installScope `
-InstallFolderName $installFolderName `
-Architecture $wixArch `
-IconPath $iconPath
$xml.Save($wxsPath)
Write-Log -Level "OK" -Message " WiX source: $wxsPath"
try {
$wixVersion = Get-WixCliVersionText
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
$eulaArgs = @(Get-WixAcceptEulaArguments -VersionText $wixVersion)
$bundleExt = Get-WixBundleExtensionName -VersionText $wixVersion
Write-Log -Level "STEP" -Message "Building MSI with WiX ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($wxsPath, '-o', $msiPath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
if (-not (Test-Path -LiteralPath $msiPath -PathType Leaf)) {
throw "WiX completed but MSI was not produced: $msiPath"
}
$bundleXml = New-WixBundleXml `
-AppName $appName `
-Manufacturer $manufacturer `
-ProductVersion $productVersion `
-UpgradeCode $upgradeCode `
-MsiPath $msiPath `
-IconPath $iconPath `
-LogoPath $logoPath `
-LogoSidePath $logoSidePath `
-ThemePath $themePath `
-InstallScope $installScope `
-InstallFolderName $installFolderName `
-Architecture $wixArch
[System.IO.File]::WriteAllText($bundleWxsPath, $bundleXml, [System.Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $exePath -PathType Leaf) {
Remove-Item -LiteralPath $exePath -Force
}
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {
throw "WiX completed but installer exe was not produced: $exePath"
}
Write-Log -Level "OK" -Message " Windows installer ready: $exePath"
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'windowsInstallerPath' -Value $exePath -Overwrite Replace
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($exePath)
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. -->
<Theme xmlns="http://wixtoolset.org/schemas/v4/thmutil">
<Font Id="0" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
<Font Id="1" Height="-24" Weight="500" Foreground="windowtext">Segoe UI</Font>
<Font Id="2" Height="-22" Weight="500" Foreground="graytext">Segoe UI</Font>
<Font Id="3" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
<Window Width="600" Height="450" HexStyle="100a0000" FontId="0" Caption="#(loc.Caption)">
<Page Name="Help">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.HelpHeader)</Label>
<Label X="11" Y="121" Width="-11" Height="-35" FontId="3" DisablePrefix="yes">#(loc.HelpText)</Label>
<Button Name="HelpCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.HelpCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Loading">
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes" Visible="no" Name="CheckingForUpdatesLabel" />
</Page>
<Page Name="Install">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.InstallHeader)</Label>
<Label X="185" Y="91" Width="-11" Height="40" FontId="3" DisablePrefix="yes">
<Text Condition="WixStdBASuppressOptionsUI">#(loc.InstallMessage)</Text>
<Text Condition="NOT WixStdBASuppressOptionsUI">#(loc.InstallMessageOptions)</Text>
</Label>
<Label X="185" Y="138" Width="-11" Height="17" FontId="3">#(loc.OptionsLocationLabel)</Label>
<Editbox Name="InstallFolder" X="185" Y="160" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" />
<Button Name="InstallFolderBrowseButton" X="-11" Y="159" Width="75" Height="23" TabStop="yes" FontId="3">
<Text>#(loc.OptionsBrowseButton)</Text>
<BrowseDirectoryAction VariableName="InstallFolder" />
</Button>
<Checkbox Name="InstallDesktopShortcut" X="185" Y="192" Width="-11" Height="17" TabStop="yes" FontId="3">Place a shortcut on the desktop</Checkbox>
<Hypertext Name="EulaHyperlink" X="185" Y="-111" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallLicenseLinkText)</Hypertext>
<Label Name="InstallVersion" X="185" Y="-81" Width="-11" Height="17" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBAShowVersion">#(loc.InstallVersion)</Label>
<Checkbox Name="EulaAcceptCheckbox" X="185" Y="-51" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallAcceptCheckbox)</Checkbox>
<Button Name="InstallUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
<Button Name="OptionsButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" VisibleCondition="NOT WixStdBASuppressOptionsUI">
<Text>#(loc.InstallOptionsButton)</Text>
<ChangePageAction Page="Options" />
</Button>
<Button Name="InstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.InstallInstallButton)</Button>
<Button Name="InstallCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.InstallCancelButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Options">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.OptionsHeader)</Label>
<RadioButtons Name="WixStdBAScope">
<RadioButton X="11" Y="180" Height="20" Width="-11" Value="PerUser" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerUserScopeText)</RadioButton>
<RadioButton X="11" Y="200" Height="20" Width="-11" Value="PerMachine" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerMachineScopeText)</RadioButton>
</RadioButtons>
<Button Name="OptionsOkButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.OptionsOkButton)</Text>
<ChangePageAction Page="Install" />
</Button>
<Button Name="OptionsCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.OptionsCancelButton)</Text>
<ChangePageAction Page="Install" Cancel="yes" />
</Button>
</Page>
<Page Name="Progress">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ProgressHeader)</Label>
<Label X="11" Y="141" Width="70" Height="17" FontId="3" DisablePrefix="yes">#(loc.ProgressLabel)</Label>
<Label Name="OverallProgressPackageText" X="85" Y="141" Width="-11" Height="17" FontId="3" DisablePrefix="yes">#(loc.OverallProgressPackageText)</Label>
<Progressbar Name="OverallCalculatedProgressbar" X="11" Y="163" Width="-11" Height="20" />
<Button Name="ProgressCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.ProgressCancelButton)</Button>
</Page>
<Page Name="Modify">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ModifyHeader)</Label>
<Button Name="ModifyUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
<Button Name="RepairButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyRepairButton)</Button>
<Button Name="UninstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyUninstallButton)</Button>
<Button Name="ModifyCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.ModifyCancelButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Success">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
<Text>#(loc.SuccessHeader)</Text>
<Text Condition="WixBundleAction = 2">#(loc.SuccessLayoutHeader)</Text>
<Text Condition="WixBundleAction = 3">#(loc.SuccessUnsafeUninstallHeader)</Text>
<Text Condition="WixBundleAction = 4">#(loc.SuccessUninstallHeader)</Text>
<Text Condition="WixBundleAction = 5">#(loc.SuccessCacheHeader)</Text>
<Text Condition="WixBundleAction = 6">#(loc.SuccessInstallHeader)</Text>
<Text Condition="WixBundleAction = 7">#(loc.SuccessModifyHeader)</Text>
<Text Condition="WixBundleAction = 8">#(loc.SuccessRepairHeader)</Text>
</Label>
<Button Name="LaunchButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessLaunchButton)</Button>
<Label X="185" Y="-51" Width="400" Height="34" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">
<Text>#(loc.SuccessRestartText)</Text>
<Text Condition="WixBundleAction = 3">#(loc.SuccessUninstallRestartText)</Text>
</Label>
<Button Name="SuccessRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessRestartButton)</Button>
<Button Name="SuccessCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.SuccessCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Failure">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
<Text>#(loc.FailureHeader)</Text>
<Text Condition="WixBundleAction = 2">#(loc.FailureLayoutHeader)</Text>
<Text Condition="WixBundleAction = 3">#(loc.FailureUnsafeUninstallHeader)</Text>
<Text Condition="WixBundleAction = 4">#(loc.FailureUninstallHeader)</Text>
<Text Condition="WixBundleAction = 5">#(loc.FailureCacheHeader)</Text>
<Text Condition="WixBundleAction = 6">#(loc.FailureInstallHeader)</Text>
<Text Condition="WixBundleAction = 7">#(loc.FailureModifyHeader)</Text>
<Text Condition="WixBundleAction = 8">#(loc.FailureRepairHeader)</Text>
</Label>
<Hypertext Name="FailureLogFileLink" X="185" Y="121" Width="-11" Height="68" FontId="3" TabStop="yes" HideWhenDisabled="yes">#(loc.FailureHyperlinkLogText)</Hypertext>
<Hypertext Name="FailureMessageText" X="185" Y="-115" Width="-11" Height="80" FontId="3" TabStop="yes" HideWhenDisabled="yes" />
<Label X="185" Y="-57" Width="-11" Height="80" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">#(loc.FailureRestartText)</Label>
<Button Name="FailureRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.FailureRestartButton)</Button>
<Button Name="FailureCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.FailureCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
</Window>
</Theme>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="MAKS.IT">
<defs>
<linearGradient id="hi" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#33A5CF"/>
<stop offset="1" stop-color="#006199"/>
</linearGradient>
<linearGradient id="mid" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#0795C5"/>
<stop offset="1" stop-color="#003D7A"/>
</linearGradient>
<linearGradient id="deep" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00427E"/>
<stop offset="1" stop-color="#002A6A"/>
</linearGradient>
<linearGradient id="navy" x1="1" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00306F"/>
<stop offset="1" stop-color="#001428"/>
</linearGradient>
</defs>
<!-- Origami M: sharp facets, light from upper-left. No wordmark. -->
<polygon fill="url(#hi)" points="28,40 78,40 62,216 12,216"/>
<polygon fill="url(#navy)" points="78,40 118,128 102,216 62,216"/>
<polygon fill="url(#mid)" points="118,128 158,40 174,216 102,216"/>
<polygon fill="url(#hi)" points="158,40 228,40 244,216 174,216"/>
<polygon fill="url(#deep)" points="28,40 78,40 70,128 20,128"/>
<polygon fill="url(#navy)" points="158,40 228,40 220,128 166,128"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,122 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET artifact cleanup plugin — remove NuGet build outputs after release.
.DESCRIPTION
Removes files from the configured artifacts directory using glob patterns.
Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the
end of the Release stage after DotNetCreateArchive or publish plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-CleanupPatternsInternal {
param(
[Parameter(Mandatory = $false)]
$ConfiguredPatterns
)
if ($null -eq $ConfiguredPatterns) {
return @('*.nupkg', '*.snupkg')
}
if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) {
return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) {
return @('*.nupkg', '*.snupkg')
}
return @([string]$ConfiguredPatterns)
}
function Get-ExcludePatternsInternal {
param(
[Parameter(Mandatory = $false)]
$ConfiguredPatterns
)
if ($null -eq $ConfiguredPatterns) {
return @()
}
if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) {
return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) {
return @()
}
return @([string]$ConfiguredPatterns)
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$artifactsDirectory = $sharedSettings.artifactsDirectory
$patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns
$excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw "DotNetCleanupArtifacts plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory"
return
}
Write-Log -Level "STEP" -Message "Cleaning generated artifacts..."
$itemsToRemove = @()
foreach ($pattern in $patterns) {
$matchedItems = @(
Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern }
)
if ($excludePatterns.Count -gt 0) {
$matchedItems = @(
$matchedItems |
Where-Object {
$item = $_
-not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1)
}
)
}
$itemsToRemove += @($matchedItems)
}
$itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique)
if ($itemsToRemove.Count -eq 0) {
Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules."
return
}
foreach ($item in $itemsToRemove) {
Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue
Write-Log -Level "OK" -Message " Removed: $($item.Name)"
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -9,6 +9,10 @@
This plugin compresses .NET release artifact inputs prepared by an earlier
DotNet plugin (DotNetPack or DotNetPublish) into a zip file
and exposes the resulting release assets for later publisher plugins.
For desktop apps with per-RID publish folders, the zip is the portable
build only (default win-x64). Windows installer and Flatpak artifacts are
separate GitHub assets and are never added to this zip.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -26,7 +30,7 @@ function Invoke-Plugin {
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
@ -49,6 +53,34 @@ function Invoke-Plugin {
}
}
$portableRid = $null
if ($pluginSettings.PSObject.Properties['portableRuntimeIdentifier'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.portableRuntimeIdentifier)) {
$portableRid = ([string]$pluginSettings.portableRuntimeIdentifier).Trim()
}
$publishOutputs = @(Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Default @())
if ($publishOutputs.Count -gt 0) {
if ([string]::IsNullOrWhiteSpace($portableRid)) {
$portableRid = 'win-x64'
}
$portableOutputs = @(
$publishOutputs |
Where-Object { [string]$_.runtimeIdentifier -eq $portableRid }
)
if ($portableOutputs.Count -eq 0) {
$portableOutputs = @(
$publishOutputs |
Where-Object { ([string]$_.runtimeIdentifier).StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase) }
)
}
if ($portableOutputs.Count -gt 0) {
$archiveInputs = @($portableOutputs | ForEach-Object { [string]$_.directory })
Write-Log -Level "INFO" -Message " Portable zip uses runtime '$portableRid' only (installer/Flatpak stay out of the zip)."
}
}
if ($archiveInputs.Count -eq 0) {
throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first."
}
@ -84,19 +116,20 @@ function Invoke-Plugin {
Write-Log -Level "OK" -Message " Release archive ready: $zipPath"
$releaseAssetPaths = @($zipPath)
$newAssets = [System.Collections.Generic.List[string]]::new()
$newAssets.Add($zipPath)
$packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile')
if ($null -ne $packageFile) {
$releaseAssetPaths += $packageFile.FullName
$newAssets.Add($packageFile.FullName)
}
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
if ($null -ne $symbolsPackageFile) {
$releaseAssetPaths += $symbolsPackageFile.FullName
$newAssets.Add($symbolsPackageFile.FullName)
}
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($newAssets)
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -29,10 +29,10 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret'
$packageFile = $sharedSettings.packageFile
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' -PluginDisplayName 'DotNetNuGet' -Required
Assert-Command dotnet
@ -52,13 +52,9 @@ function Invoke-Plugin {
return
}
if ([string]::IsNullOrWhiteSpace($nugetSecret)) {
throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)."
}
$nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret
$nugetKey = Get-RepoUtilsSecretSlot -Name $nugetSecret -Settings $sharedSettings
if ([string]::IsNullOrWhiteSpace($nugetKey)) {
throw "NuGet API key is not set. Set environment variable '$nugetSecret'."
throw "NuGet API key is not set. Set RepoUtilsSecrets slot '$nugetSecret' (nugetSecret)."
}
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {

View File

@ -6,9 +6,14 @@
.NET publish plugin for producing application release artifacts.
.DESCRIPTION
This plugin publishes the configured .NET project into a release output
directory and exposes that published directory to the shared release
context so later release-stage plugins can archive and publish it.
This plugin publishes configured .NET projects into the artifacts directory
and appends those publish folders to shared archive inputs so later plugins
can zip them next to any earlier pack outputs. Existing NuGet package facts
(packageFile) are left unchanged.
Optional runtimeIdentifiers (or runtimeIdentifier) produce per-RID folders
under artifactsDir/<ProjectName>/<rid> for desktop installers (Windows MSI /
Linux Flatpak). When RIDs are set, selfContained defaults to true.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -19,6 +24,28 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
}
}
function Get-DotNetPublishRuntimeIdentifiers {
param(
[Parameter(Mandatory = $true)]
$PluginSettings
)
$rids = [System.Collections.Generic.List[string]]::new()
if ($PluginSettings.PSObject.Properties['runtimeIdentifiers'] -and $null -ne $PluginSettings.runtimeIdentifiers) {
foreach ($item in @($PluginSettings.runtimeIdentifiers)) {
$value = [string]$item
if (-not [string]::IsNullOrWhiteSpace($value)) {
$rids.Add($value.Trim())
}
}
}
elseif ($PluginSettings.PSObject.Properties['runtimeIdentifier'] -and -not [string]::IsNullOrWhiteSpace([string]$PluginSettings.runtimeIdentifier)) {
$rids.Add(([string]$PluginSettings.runtimeIdentifier).Trim())
}
return @($rids)
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
@ -28,48 +55,145 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
$artifactsDirectory = $sharedSettings.artifactsDirectory
$publishProjectPath = $null
$scriptDir = $sharedSettings.scriptDir
$projectFiles = @()
Assert-Command dotnet
if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
throw "DotNetPublish plugin requires project files in the shared context."
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
}
else {
$fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
if ($null -ne $fromFact) {
$projectFiles = @($fromFact)
}
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
$projectFiles = @($sharedSettings.projectFiles)
}
}
$projectFiles = @($projectFiles)
if ($projectFiles.Count -eq 0) {
throw "DotNetPublish plugin requires projectFiles in plugin settings or projectFiles on shared context."
}
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.artifactsDir)))
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
else {
$artifactsDirectory = $sharedSettings.artifactsDirectory
}
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
throw "DotNetPublish plugin requires artifactsDir in plugin settings or artifactsDirectory on shared context."
}
if (!(Test-Path $artifactsDirectory)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
# The first configured project remains the canonical release artifact source.
$publishProjectPath = $projectFiles[0]
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
if (Test-Path $publishDir) {
Remove-Item -Path $publishDir -Recurse -Force
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
$archiveInputs = [System.Collections.Generic.List[object]]::new()
if ($null -ne $existing) {
foreach ($item in @($existing)) {
if ($null -ne $item) {
$archiveInputs.Add($item)
}
}
}
Write-Log -Level "STEP" -Message "Publishing release artifact..."
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for $publishProjectPath."
$runtimeIdentifiers = @(Get-DotNetPublishRuntimeIdentifiers -PluginSettings $pluginSettings)
$selfContained = $null
if ($pluginSettings.PSObject.Properties['selfContained'] -and $null -ne $pluginSettings.selfContained) {
$selfContained = [bool]$pluginSettings.selfContained
}
elseif ($runtimeIdentifiers.Count -gt 0) {
$selfContained = $true
}
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
if ($publishedItems.Count -eq 0) {
throw "dotnet publish completed, but no files were produced in: $publishDir"
$publishSingleFile = $false
if ($pluginSettings.PSObject.Properties['publishSingleFile'] -and $null -ne $pluginSettings.publishSingleFile) {
$publishSingleFile = [bool]$pluginSettings.publishSingleFile
}
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
$additionalArguments = @()
if ($pluginSettings.PSObject.Properties['additionalPublishArguments'] -and $null -ne $pluginSettings.additionalPublishArguments) {
$additionalArguments = @($pluginSettings.additionalPublishArguments | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
$publishOutputs = [System.Collections.Generic.List[object]]::new()
$ridLoop = @($null)
if ($runtimeIdentifiers.Count -gt 0) {
$ridLoop = @($runtimeIdentifiers)
}
foreach ($publishProjectPath in $projectFiles) {
$projectName = [System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)
$projectRoot = Join-Path $artifactsDirectory $projectName
foreach ($runtimeIdentifier in $ridLoop) {
$publishDir = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) {
$projectRoot
}
else {
Join-Path $projectRoot ([string]$runtimeIdentifier)
}
if (Test-Path $publishDir) {
Remove-Item -Path $publishDir -Recurse -Force
}
$ridLabel = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) { 'current runtime' } else { [string]$runtimeIdentifier }
Write-Log -Level "STEP" -Message "Publishing release artifact ($projectName / $ridLabel)..."
$dotnetPublishArguments = @(
'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo'
)
if (-not [string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) {
$dotnetPublishArguments += @('-r', [string]$runtimeIdentifier)
}
if ($null -ne $selfContained) {
if ($selfContained) {
$dotnetPublishArguments += '--self-contained'
}
else {
$dotnetPublishArguments += '--no-self-contained'
}
}
if ($publishSingleFile) {
$dotnetPublishArguments += '-p:PublishSingleFile=true'
}
if ($additionalArguments.Count -gt 0) {
$dotnetPublishArguments += $additionalArguments
}
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetPublishArguments | Out-Null
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
if ($publishedItems.Count -eq 0) {
throw "dotnet publish completed, but no files were produced in: $publishDir"
}
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
$archiveInputs.Add($publishDir)
$publishOutputs.Add([pscustomobject]@{
projectName = $projectName
runtimeIdentifier = [string]$runtimeIdentifier
directory = $publishDir
})
}
}
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Value @($publishOutputs) -Overwrite Replace
}
Export-ModuleMember -Function Invoke-Plugin
Export-ModuleMember -Function Invoke-Plugin, Get-DotNetPublishRuntimeIdentifiers

View File

@ -7,12 +7,15 @@
.DESCRIPTION
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
via TestRunner, then publishes metrics on the shared engine context for any later
via TestRunner (Microsoft Testing Platform + coverlet.MTP only — no VSTest /
coverlet.collector), then publishes metrics on the shared engine context for any later
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
multi-project default TestResults folder) is used, Cobertura output is kept on disk
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
Product solutions must place `src/global.json` with `test.runner` =
Microsoft.Testing.Platform next to the `.sln`/`.slnx`.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {

View File

@ -6,8 +6,7 @@
Publishes npm workspace packages to the npm registry.
.DESCRIPTION
Publishes packages in configured order using npmSecret (logical secret name).
Pass the token via an environment variable named like the configured npm secret (e.g. Npm).
Publishes packages in configured order using RepoUtilsSecrets slot Npm.
Uses a temporary .npmrc in the workspace root.
#>
@ -85,6 +84,8 @@ function Invoke-Plugin {
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
@ -99,14 +100,9 @@ function Invoke-Plugin {
return
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret'
if ([string]::IsNullOrWhiteSpace($npmSecret)) {
throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)."
}
$npmToken = Get-SecretEnvironmentValue -Name $npmSecret
$npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared
if ([string]::IsNullOrWhiteSpace($npmToken)) {
throw "npm API key is not set. Set environment variable '$npmSecret'."
throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)."
}
$registryHost = ([uri]$registry).Host

View File

@ -101,7 +101,6 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret'
$configuredRepository = $pluginSettings.repository
$releaseNotesFileSetting = $pluginSettings.releaseNotesFile
$releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern
@ -112,6 +111,7 @@ function Invoke-Plugin {
$releaseAssetPaths = @()
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json."
@ -138,13 +138,9 @@ function Invoke-Plugin {
Assert-Command gh
if ([string]::IsNullOrWhiteSpace($githubSecret)) {
throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)."
}
$ghToken = Get-SecretEnvironmentValue -Name $githubSecret
$ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings
if ([string]::IsNullOrWhiteSpace($ghToken)) {
throw "GitHub token is not set. Set environment variable '$githubSecret'."
throw "GitHub token is not set. Set RepoUtilsSecrets slot '$githubSecret' (githubSecret)."
}
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
@ -235,7 +231,7 @@ function Invoke-Plugin {
$authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
}
throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository."
throw "GitHub CLI authentication failed for repository '$repo'. Ensure RepoUtilsSecrets slot '$githubSecret' is valid and has access to this repository."
}
Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"