Compare commits
1 Commits
v0.1.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5b839976 |
@ -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`.
|
||||
|
||||
17
CHANGELOG.md
@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0-alpha.2] - 2026-09-06
|
||||
|
||||
Still alpha versus **0.1.0-alpha.1**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line.
|
||||
|
||||
### Added
|
||||
|
||||
- Desktop packaging as sibling GitHub assets: Windows setup `maksit-lto-backup-{version}.exe` (Avalonia UI) and `maksit-lto-backup-{version}.flatpak` (Avalonia UI), alongside the portable `maksit-lto-backup-{version}.zip` (win-x64 console, Avalonia UI, and Worker). The installer and Flatpak are not packed inside the zip. On Windows the Flatpak bundle is built via WSL Debian.
|
||||
- Faceted LTO cartridge app icon (not the brand M).
|
||||
|
||||
### Changed
|
||||
|
||||
- Avalonia UI is **MVVM** (`CommunityToolkit.Mvvm`): operations, settings, monitor, and service controls live in `MainViewModel`; `MainWindow` is a thin view.
|
||||
- Configuration (console, UI, Worker) is written to `%AppData%/MaksIT/LTO Backup/settings.json` (WiX `installFolderName`). The shipped `configuration.json` next to the exe is seed-only.
|
||||
- Dark theme uses the MAKS.IT origami blues (`#33A5CF` / `#006199`).
|
||||
- Synced RepoUtils utils: ContainerRegistry JSON catalog (PascalCase Harbor / InCluster keys).
|
||||
|
||||
## [0.1.0-alpha.1] - 2026-08-21
|
||||
|
||||
Alpha preview versus last published **0.0.2** (HEAD was console-only Windows on **net8.0**). Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line.
|
||||
@ -46,3 +62,4 @@ Alpha preview versus last published **0.0.2** (HEAD was console-only Windows on
|
||||
- AES-GCM encrypted descriptors, checksum verification, cartridge EEPROM (MAM) read/write.
|
||||
- Dependency injection, console and file logging.
|
||||
- Build scripts and contributor docs (`README.md`, `CONTRIBUTING.md`, `LICENSE.md`).
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
21
README.md
@ -1,8 +1,8 @@
|
||||
# MaksIT.LTO.Backup
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
17
src/MaksIT.LTO.Backup.Shared/UserSettingsPath.cs
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
BIN
src/MaksIT.LTO.Backup.UI/Assets/icon.ico
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/MaksIT.LTO.Backup.UI/Assets/icon.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
23
src/MaksIT.LTO.Backup.UI/Assets/icon.svg
Normal 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 |
@ -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"
|
||||
|
||||
21
src/MaksIT.LTO.Backup.UI/DialogService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
129
src/MaksIT.LTO.Backup.UI/ViewModels/MainViewModel.Monitor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
74
src/MaksIT.LTO.Backup.UI/ViewModels/MainViewModel.Service.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
558
src/MaksIT.LTO.Backup.UI/ViewModels/MainViewModel.Settings.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
676
src/MaksIT.LTO.Backup.UI/ViewModels/MainViewModel.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
101
src/MaksIT.LTO.Backup.UI/ViewModels/UiModels.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
@ -35,6 +35,7 @@ else {
|
||||
}
|
||||
|
||||
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
|
||||
Initialize-RepoUtilsVaultSecrets -Settings $settings -Plugins $configuredPlugins
|
||||
|
||||
$releaseBanner = if ($null -ne $releaseExtension) {
|
||||
$releaseExtension.StepBanner
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
373
utils/modules/Engine/VaultSupport.psm1
Normal 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'
|
||||
)
|
||||
@ -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."
|
||||
}
|
||||
|
||||
1037
utils/plugins/Desktop/DesktopPackSupport.psm1
Normal file
445
utils/plugins/Desktop/FlatpakPack.psm1
Normal 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
|
||||
303
utils/plugins/Desktop/WindowsInstaller.psm1
Normal 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
|
||||
139
utils/plugins/Desktop/brand/HyperlinkSidebarTheme.xml
Normal 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>
|
||||
BIN
utils/plugins/Desktop/brand/logo-64.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
utils/plugins/Desktop/brand/mark.ico
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
utils/plugins/Desktop/brand/mark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
27
utils/plugins/Desktop/brand/mark.svg
Normal 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 |
BIN
utils/plugins/Desktop/brand/sidebar.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)"
|
||||
|
||||