(feature): bulk footer actions, origami branding, and sibling release assets

This commit is contained in:
Maksym Sadovnychyy 2026-08-30 18:01:58 +02:00
parent d51c636636
commit d09fe2aada
32 changed files with 2530 additions and 222 deletions

View File

@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.6.0] - 2026-08-30
### Fixed
- Footer actions that can run independently now apply to every selected table row (Restart, Scale, Delete, Force delete, Force delete namespace, Cordon, Uncordon, Drain, Trigger, Stop port-forward), not only the current row.
### Changed
- Dark UI uses the MAKS.IT origami blues (`#33A5CF` highlight, `#006199` accent). Window/installer/Flatpak icon is a faceted cluster of nodes with a console chevron (not the brand M). GitHub release assets are siblings: portable `maksit-cluster-console-{version}.zip` (win-x64 only), Windows setup `maksit-cluster-console-{version}.exe`, and `maksit-cluster-console-{version}.flatpak`. The installer and Flatpak are not packed inside the zip. On Windows the Flatpak bundle is built via WSL Debian.
## [0.5.1] - 2026-08-24
### Added

View File

@ -27,7 +27,7 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**.
1. Update [CHANGELOG.md](CHANGELOG.md) and bump `<Version>` in [src/Directory.Build.props](src/Directory.Build.props).
2. Commit on `main`, tag `v{version}` on HEAD (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.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, and Flatpak.
## Commit format

View File

@ -1,8 +1,8 @@
# MaksIT.ClusterConsole
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.2%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.7%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.1%25-green)
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.4%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.8%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.4%25-green)
![.NET](https://img.shields.io/badge/.NET-10-512BD4)
![License](https://img.shields.io/badge/License-Apache%202.0-blue)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6)
@ -98,6 +98,8 @@ Tests use kubeconfig fixtures and do not require a live cluster. Coverage shield
2. Tag `v{version}` on `main`.
3. Run `utils\Invoke-ReleasePackage.bat`.
GitHub assets are siblings: portable `maksit-cluster-console-{version}.zip` (win-x64 only), Windows setup `maksit-cluster-console-{version}.exe`, and `maksit-cluster-console-{version}.flatpak`. The installer and Flatpak are not inside the zip. On Windows the Flatpak bundle is built via WSL Debian.
## Solution layout
```text

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.5.1</Version>
<Version>0.6.0</Version>
<Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup>

View File

@ -0,0 +1,53 @@
using MaksIT.Results;
namespace MaksIT.ClusterConsole.Shared;
public readonly record struct ResourceActionBatchOutcome(
int Succeeded,
int Total,
IReadOnlyList<string> Failures) {
public string Format(string oneDone, string manyDone) {
if (Total <= 0)
return "";
if (Failures.Count == 0)
return Total == 1 ? oneDone : manyDone;
if (Succeeded == 0)
return string.Join("; ", Failures);
return $"{Succeeded} of {Total} succeeded. {string.Join("; ", Failures)}";
}
}
public static class ResourceActionBatch {
public static IReadOnlyList<ResourceRow> Targets(
IReadOnlyList<ResourceRow> selectedRows,
ResourceRow? selectedRow) {
if (selectedRows.Count > 0)
return selectedRows;
return selectedRow is null ? [] : [selectedRow];
}
public static string Label(ResourceRow row) =>
string.IsNullOrEmpty(row.Namespace) ? row.Name : $"{row.Namespace}/{row.Name}";
public static async Task<ResourceActionBatchOutcome> RunAsync(
IReadOnlyList<ResourceRow> rows,
Func<ResourceRow, Task<Result>> action) {
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(action);
var succeeded = 0;
var failures = new List<string>();
foreach (var row in rows) {
var result = await action(row);
if (result.IsSuccess) {
succeeded++;
continue;
}
failures.Add($"{Label(row)}: {string.Join("; ", result.Messages)}");
}
return new ResourceActionBatchOutcome(succeeded, rows.Count, failures);
}
}

View File

@ -0,0 +1,69 @@
using System.Text.Json.Nodes;
using MaksIT.ClusterConsole.Shared;
using MaksIT.Results;
namespace MaksIT.ClusterConsole.Tests;
public class ResourceActionBatchTests {
[Fact]
public void Targets_falls_back_to_the_current_row() {
var current = Row("web");
Assert.Equal([current], ResourceActionBatch.Targets([], current));
}
[Fact]
public void Targets_uses_every_selected_row() {
var first = Row("web-a");
var second = Row("web-b");
Assert.Equal([first, second], ResourceActionBatch.Targets([first, second], first));
}
[Fact]
public async Task RunAsync_runs_the_action_for_each_row() {
var names = new List<string>();
var outcome = await ResourceActionBatch.RunAsync(
[Row("web-a", "apps"), Row("web-b", "apps")],
row => {
names.Add(row.Name);
return Task.FromResult(Result.Ok());
});
Assert.Equal(["web-a", "web-b"], names);
Assert.Equal(2, outcome.Succeeded);
Assert.Equal(2, outcome.Total);
Assert.Empty(outcome.Failures);
Assert.Equal("Restarted 2.", outcome.Format("Restarted.", "Restarted 2."));
}
[Fact]
public async Task RunAsync_keeps_going_after_a_failure() {
var outcome = await ResourceActionBatch.RunAsync(
[Row("web-a", "apps"), Row("web-b", "apps")],
row => Task.FromResult(
row.Name == "web-a"
? Result.InternalServerError("forbidden")
: Result.Ok()));
Assert.Equal(1, outcome.Succeeded);
Assert.Equal(["apps/web-a: forbidden"], outcome.Failures);
Assert.Equal(
"1 of 2 succeeded. apps/web-a: forbidden",
outcome.Format("Restarted.", "Restarted 2."));
}
[Fact]
public void Format_keeps_the_singular_status_for_one_row() {
var outcome = new ResourceActionBatchOutcome(1, 1, []);
Assert.Equal("Restarted.", outcome.Format("Restarted.", "Restarted 2."));
}
private static ResourceRow Row(string name, string? ns = null) =>
new() {
Uid = name,
Name = name,
Namespace = ns,
Document = new JsonObject(),
Cells = new Dictionary<string, string> { ["Name"] = name }
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,12 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="MaksIT ClusterConsole">
<!-- Original MaksIT mark: console on a rounded tile. Not the Kubernetes wheel. -->
<rect x="16" y="16" width="224" height="224" rx="48" fill="#2F3A8F"/>
<rect x="40" y="52" width="176" height="152" rx="20" fill="#151A3A"/>
<rect x="40" y="52" width="176" height="36" rx="20" fill="#2EC4B6"/>
<rect x="40" y="70" width="176" height="18" fill="#2EC4B6"/>
<circle cx="64" cy="70" r="7" fill="#151A3A"/>
<circle cx="86" cy="70" r="7" fill="#151A3A"/>
<circle cx="108" cy="70" r="7" fill="#151A3A"/>
<path d="M72 128 L100 148 L72 168" fill="none" stroke="#E8EEFF" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="118" y="136" width="78" height="16" rx="4" fill="#2EC4B6"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Cluster Console">
<defs>
<linearGradient id="bg" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#33A5CF"/>
<stop offset="1" stop-color="#002A6A"/>
</linearGradient>
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#C5E8F6"/>
</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="36" y="50" width="88" height="88" rx="20" fill="url(#tile)"/>
<rect x="132" y="50" width="88" height="88" rx="20" fill="url(#tile)"/>
<rect x="84" y="110" width="88" height="88" rx="20" fill="url(#tile)"/>
<polyline points="112,134 152,154 112,174" fill="none" stroke="#002A6A" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -547,11 +547,13 @@
<DataGrid Name="ResourceGrid"
ItemsSource="{Binding ActivePage.Rows}"
SelectedItem="{Binding ActivePage.SelectedRow}"
SelectionMode="Extended"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserResizeColumns="True"
CanUserSortColumns="True"
ClipboardCopyMode="IncludeHeader"
SelectionChanged="OnResourceGridSelectionChanged"
CopyingRowClipboardContent="OnResourceGridCopyingRowClipboardContent"
DoubleTapped="OnResourceGridDoubleTapped" />
<GridSplitter Grid.Column="1" Classes="shell-split" ResizeDirection="Columns" />

View File

@ -19,7 +19,7 @@ namespace MaksIT.ClusterConsole.UI;
public partial class MainWindow : Window {
private LayoutPersistence? _layout;
private ClusterPageViewModel? _logsPage;
private ClusterPageViewModel? _activePage;
public MainWindow() {
InitializeComponent();
@ -41,9 +41,9 @@ public partial class MainWindow : Window {
RebuildColumns(viewModel);
if (e.PropertyName == nameof(MainViewModel.ActivePage))
HookLogsPage(viewModel.ActivePage);
HookActivePage(viewModel.ActivePage);
};
HookLogsPage(viewModel.ActivePage);
HookActivePage(viewModel.ActivePage);
viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel);
viewModel.VolumeFilesRequested += OpenVolumeFiles;
}
@ -53,13 +53,17 @@ public partial class MainWindow : Window {
window.Show(this);
}
private void HookLogsPage(ClusterPageViewModel? page) {
if (_logsPage is not null)
_logsPage.PropertyChanged -= OnLogsPagePropertyChanged;
private void HookActivePage(ClusterPageViewModel? page) {
if (_activePage is not null) {
_activePage.PropertyChanged -= OnLogsPagePropertyChanged;
_activePage.SelectedRowsRestored -= OnSelectedRowsRestored;
}
_logsPage = page;
if (page is not null)
_activePage = page;
if (page is not null) {
page.PropertyChanged += OnLogsPagePropertyChanged;
page.SelectedRowsRestored += OnSelectedRowsRestored;
}
}
private void OnLogsPagePropertyChanged(object? sender, PropertyChangedEventArgs e) {
@ -83,6 +87,39 @@ public partial class MainWindow : Window {
await viewModel.ConnectNamedAsync(connect);
}
private void OnResourceGridSelectionChanged(object? sender, SelectionChangedEventArgs e) {
if (DataContext is not MainViewModel { ActivePage: { } page })
return;
if (page.SyncingSelection || sender is not DataGrid grid)
return;
page.ReplaceSelectedRows(grid.SelectedItems.OfType<ResourceRow>());
}
private void OnSelectedRowsRestored(IReadOnlyList<ResourceRow> rows) {
var grid = this.FindControl<DataGrid>("ResourceGrid");
if (grid is null)
return;
ApplyGridSelection(grid, rows, _activePage?.SelectedRow);
}
private static void ApplyGridSelection(DataGrid grid, IReadOnlyList<ResourceRow> rows, ResourceRow? current) {
var wanted = rows.ToHashSet();
for (var i = grid.SelectedItems.Count - 1; i >= 0; i--) {
if (grid.SelectedItems[i] is not ResourceRow row || !wanted.Contains(row))
grid.SelectedItems.RemoveAt(i);
}
foreach (var row in rows) {
if (!grid.SelectedItems.Contains(row))
grid.SelectedItems.Add(row);
}
if (current is not null && !ReferenceEquals(grid.SelectedItem, current))
grid.SelectedItem = current;
}
private void OnResourceGridDoubleTapped(object? sender, TappedEventArgs e) {
if (e.Source is not Control { DataContext: ResourceRow })
return;

View File

@ -2,20 +2,23 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls">
<Styles.Resources>
<Color x:Key="BrandHighlight">#33A5CF</Color>
<Color x:Key="BrandPrimary">#006199</Color>
<Color x:Key="BrandNavy">#002A6A</Color>
<SolidColorBrush x:Key="DataGridGridLinesBrush" Color="#33383e" />
<SolidColorBrush x:Key="DataGridColumnHeaderBackgroundBrush" Color="#1e2124" />
<SolidColorBrush x:Key="DataGridColumnHeaderForegroundBrush" Color="#9aa0a6" />
<SolidColorBrush x:Key="DataGridColumnHeaderHoveredBackgroundBrush" Color="#2a2e33" />
<SolidColorBrush x:Key="DataGridColumnHeaderPressedBackgroundBrush" Color="#33383e" />
<SolidColorBrush x:Key="DataGridRowHoveredBackgroundColor" Color="#2a2e33" />
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#3d5afe" />
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#4d6bff" />
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#2f364d" />
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#3a4258" />
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#006199" />
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#33A5CF" />
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#16324C" />
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#1A3A5C" />
</Styles.Resources>
<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>
@ -48,7 +51,7 @@
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Button.scope-toggle.selected">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
</Style>
<Style Selector="Button.nav-section">
<Setter Property="HorizontalAlignment" Value="Stretch" />
@ -86,7 +89,7 @@
<Setter Property="Background" Value="#2a2e33" />
</Style>
<Style Selector="Button.nav-item.selected">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
</Style>
<Style Selector="Button.catalog-item">
<Setter Property="HorizontalAlignment" Value="Stretch" />
@ -100,7 +103,7 @@
<Setter Property="Background" Value="#2a2e33" />
</Style>
<Style Selector="Button.catalog-item.selected">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
</Style>
<Style Selector="Button.catalog-close">
<Setter Property="Padding" Value="6,2" />
@ -156,7 +159,7 @@
<Setter Property="Stroke" Value="#7ee787" />
</Style>
<Style Selector="ListBoxItem:selected">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
</Style>
<Style Selector="GridSplitter.shell-split">
<Setter Property="Width" Value="4" />
@ -164,7 +167,7 @@
<Setter Property="MinWidth" Value="4" />
</Style>
<Style Selector="GridSplitter.shell-split:pointerover">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
</Style>
<Style Selector="Border.details-toolbar">
<Setter Property="Background" Value="#1a1d20" />
@ -183,7 +186,7 @@
<Setter Property="Background" Value="#25282c" />
</Style>
<Style Selector="Border.chat-bubble.user">
<Setter Property="Background" Value="#3d5afe" />
<Setter Property="Background" Value="#006199" />
<Setter Property="Margin" Value="24,0,0,8" />
</Style>
<Style Selector="Border.chat-bubble.assistant">

View File

@ -57,12 +57,20 @@ public partial class ClusterPageViewModel {
[RelayCommand]
private void AskAboutSelection() {
if (SelectedRow is null) {
var rows = ActionTargets;
if (rows.Count == 0) {
ChatInput = "What is currently unhealthy in this cluster?";
return;
}
var name = SelectedRelatedPod?.Name ?? SelectedRow.Name;
if (rows.Count > 1) {
var kind = SelectedDescriptor?.Title ?? "resources";
var names = string.Join(", ", rows.Select(ResourceActionBatch.Label));
ChatInput = $"What is wrong with these {kind}: {names}?";
return;
}
var name = SelectedRelatedPod?.Name ?? rows[0].Name;
var container = SelectedContainer is null ? "" : $" container {SelectedContainer.Name}";
ChatInput = $"What is wrong with {SelectedDocumentKind ?? SelectedResourceRef()?.Kind ?? "this resource"} {name}{container}?";
}

View File

@ -33,6 +33,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private string? _rebindUid;
private readonly List<double> _cpuHistory = [];
private readonly List<double> _memoryHistory = [];
private readonly List<string> _selectedUids = [];
private IReadOnlyList<ResourceRow> _selectedRows = [];
private bool _syncingSelection;
private const int HistoryPoints = 60;
public ClusterPageViewModel(
@ -90,6 +93,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[ObservableProperty]
private ResourceRow? selectedRow;
public IReadOnlyList<ResourceRow> SelectedRows =>
_selectedRows;
public event Action<IReadOnlyList<ResourceRow>>? SelectedRowsRestored;
public bool SyncingSelection =>
_syncingSelection;
[ObservableProperty]
private LimitRowViewModel? selectedLimitRow;
@ -209,15 +220,8 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public bool CanForceDelete =>
CanDelete && SelectedDescriptor?.Kind != "Namespace";
public bool CanDeleteNamespace {
get {
var name = TargetNamespaceName;
return HasSelectedRow
&& SelectedDescriptor?.Kind == "Namespace"
&& !string.IsNullOrEmpty(name)
&& !IsProtectedNamespace(name);
}
}
public bool CanDeleteNamespace =>
SelectedDescriptor?.Kind == "Namespace" && DeletableNamespaceNames.Count > 0;
public bool CanBrowseFiles =>
HasSelectedRow
@ -297,6 +301,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public string DetailsTitle {
get {
var targets = ActionTargets;
if (targets.Count > 1)
return $"{targets.Count} {SelectedDescriptor?.Title ?? "items"} selected";
if (SelectedRow is null)
return "Select a resource";
@ -448,9 +455,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
private void ApplyColumnFilters() =>
ApplyColumnFilters(SelectedRow?.Uid);
ApplyColumnFilters(SnapshotSelectedUids());
private void ApplyColumnFilters(string? keepUid) {
private void ApplyColumnFilters(IReadOnlyList<string> keepUids) {
var filters = _columnFilters.Values.Select(filter => filter.Model);
var desired = new List<ResourceRow>();
foreach (var row in _listedRows) {
@ -460,6 +467,8 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
SortRows(desired);
_syncingSelection = true;
try {
CollectionSync.MergeByKey(
Rows,
desired,
@ -467,15 +476,32 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
static (current, incoming) => current.CopyFrom(incoming),
matchSourceOrder: true);
var restored = new List<ResourceRow>();
foreach (var uid in keepUids) {
var row = Rows.FirstOrDefault(item => string.Equals(item.Uid, uid, StringComparison.Ordinal));
if (row is not null)
restored.Add(row);
}
if (SelectedRow is null || !Rows.Contains(SelectedRow))
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid);
else if (!IsDirty) {
SelectedRow = restored.FirstOrDefault();
ReplaceSelectedRows(restored);
SelectedRowsRestored?.Invoke(_selectedRows);
}
finally {
_syncingSelection = false;
}
if (SelectedRow is not null && !IsDirty) {
if (IsPodSelection)
ApplyContainers(SelectedRow.Document);
OverviewText = SelectedRow.FormatOverview(Containers);
}
if (SelectedRow?.Uid != _detailsUid)
_ = LoadDetailsAsync();
var title = SelectedNavItem?.Title ?? "items";
var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count;
_setStatus(filtered
@ -496,7 +522,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
NotifyDetailsUi();
_listedRows.Clear();
Rows.Clear();
SelectedRow = null;
ClearTableSelection();
if (!_syncingLayout) {
var cfg = _configuration.Current;
@ -530,6 +556,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
partial void OnSelectedRowChanged(ResourceRow? value) {
if (_syncingSelection)
return;
NotifyActionFlags();
NotifyDetailsUi();
SyncRebindLocalPort();
@ -613,7 +642,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
var keepUid = SelectedRow?.Uid;
var keepUids = SnapshotSelectedUids();
_listedRows.Clear();
if (!listed.IsSuccess) {
Rows.Clear();
@ -626,7 +655,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
FilterFor(column.Header).LoadValues(_listedRows);
SeedNamespaceColumnFromSelection();
ApplyColumnFilters(keepUid);
ApplyColumnFilters(keepUids);
NotifyActionFlags();
NotifyDetailsUi();
OnPropertyChanged(nameof(IsDataEditor));
@ -701,7 +730,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
return;
_detailsUid = null;
SelectedRow = null;
ClearTableSelection();
_editDocument = YamlFormatter.ToJsonObject(ResourceDocument.NewTemplate(SelectedDescriptor, SelectedNamespace));
_loadingDetails = true;
YamlText = _editDocument is null ? "" : YamlFormatter.FromJson(_editDocument);
@ -719,37 +748,39 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[RelayCommand]
private async Task DeleteAsync() {
if (_workspace.Session is null || SelectedRow is null)
return;
var resource = SelectedResourceRef();
if (resource is null || resource.Actions.CanDelete == false)
return;
var deleted = await _workspace.Session.DeleteAsync(resource.ToRef(), SelectedRow.Name, SelectedRow.Namespace);
_setStatus(deleted.IsSuccess
? ReplicaSetWarning(SelectedRow)
: string.Join("; ", deleted.Messages));
var rows = ActionTargets;
var owned = rows.Any(IsControllerOwned);
if (await RunOnSelectedAsync(row =>
_workspace.Session!.DeleteAsync(resource.ToRef(), row.Name, row.Namespace)) is not { } outcome)
return;
var status = outcome.Format("Deleted.", $"Deleted {outcome.Total}.");
if (owned && outcome.Succeeded > 0)
status += " A controller may recreate this pod — open Namespaces and force-delete the sandbox namespace.";
_setStatus(status);
await RefreshRowsAsync();
}
[RelayCommand]
private async Task ForceDeleteAsync() {
if (_workspace.Session is null || SelectedRow is null)
return;
var resource = SelectedResourceRef();
if (resource is null || resource.Actions.CanDelete == false)
return;
var deleted = await _workspace.Session.DeleteAsync(
resource.ToRef(),
SelectedRow.Name,
SelectedRow.Namespace,
force: true);
_setStatus(deleted.IsSuccess
? "Force-deleted. " + ReplicaSetWarning(SelectedRow)
: string.Join("; ", deleted.Messages));
var rows = ActionTargets;
var owned = rows.Any(IsControllerOwned);
if (await RunOnSelectedAsync(row =>
_workspace.Session!.DeleteAsync(resource.ToRef(), row.Name, row.Namespace, force: true)) is not { } outcome)
return;
var status = outcome.Format("Force-deleted.", $"Force-deleted {outcome.Total}.");
if (owned && outcome.Succeeded > 0)
status += " A controller may recreate this pod — open Namespaces and force-delete the sandbox namespace.";
_setStatus(status);
await RefreshRowsAsync();
}
@ -758,15 +789,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (_workspace.Session is null)
return;
var name = TargetNamespaceName;
if (string.IsNullOrEmpty(name) || IsProtectedNamespace(name))
var names = DeletableNamespaceNames;
if (names.Count == 0)
return;
var deleted = await _workspace.Session.ForceDeleteNamespaceAsync(name);
_setStatus(deleted.IsSuccess
? $"Force-deleted namespace {name}."
: string.Join("; ", deleted.Messages));
if (string.Equals(SelectedNamespace, name, StringComparison.Ordinal)) {
var rows = ActionTargets
.Where(row => names.Contains(row.Name, StringComparer.Ordinal))
.ToList();
var outcome = await ResourceActionBatch.RunAsync(
rows,
row => _workspace.Session.ForceDeleteNamespaceAsync(row.Name));
_setStatus(outcome.Format(
$"Force-deleted namespace {names[0]}.",
$"Force-deleted {outcome.Total} namespaces."));
if (names.Contains(SelectedNamespace, StringComparer.Ordinal)) {
SelectedNamespace = Configuration.AllNamespaces;
_columnFilters.Remove("Namespace");
}
@ -774,8 +810,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
await RefreshRowsAsync();
}
private string? TargetNamespaceName =>
SelectedDescriptor?.Kind == "Namespace" ? SelectedRow?.Name : null;
private IReadOnlyList<string> DeletableNamespaceNames =>
SelectedDescriptor?.Kind != "Namespace"
? []
: ActionTargets
.Select(row => row.Name)
.Where(name => !string.IsNullOrEmpty(name) && !IsProtectedNamespace(name))
.Distinct(StringComparer.Ordinal)
.ToList();
private static bool IsProtectedNamespace(string name) =>
name.Equals("default", StringComparison.OrdinalIgnoreCase)
@ -783,78 +825,76 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|| name.Equals("kube-public", StringComparison.OrdinalIgnoreCase)
|| name.Equals("kube-node-lease", StringComparison.OrdinalIgnoreCase);
private static string ReplicaSetWarning(ResourceRow row) {
private static bool IsControllerOwned(ResourceRow row) {
var owners = row.Document["metadata"]?["ownerReferences"] as JsonArray;
var owned = owners?.OfType<JsonObject>().Any(o => {
return owners?.OfType<JsonObject>().Any(o => {
var kind = o["kind"]?.GetValue<string>();
return kind is "ReplicaSet" or "Deployment" or "StatefulSet" or "DaemonSet" or "Job";
}) == true;
return owned
? "Deleted. A controller may recreate this pod — open Namespaces and force-delete the sandbox namespace."
: "Deleted.";
}
[RelayCommand]
private async Task ScaleAsync() {
if (_workspace.Session is null || SelectedRow is null)
return;
var resource = SelectedResourceRef();
if (resource is null || resource.Actions.CanScale == false)
return;
var scaled = await _workspace.Session.ScaleAsync(resource.ToRef(), SelectedRow.Name, SelectedRow.Namespace, ScaleReplicas);
_setStatus(scaled.IsSuccess ? $"Scaled to {ScaleReplicas}." : string.Join("; ", scaled.Messages));
var replicas = ScaleReplicas;
if (await RunOnSelectedAsync(row =>
_workspace.Session!.ScaleAsync(resource.ToRef(), row.Name, row.Namespace, replicas)) is not { } outcome)
return;
_setStatus(outcome.Format($"Scaled to {replicas}.", $"Scaled {outcome.Total} to {replicas}."));
await RefreshRowsAsync();
}
[RelayCommand]
private async Task RestartAsync() {
if (_workspace.Session is null || SelectedRow is null)
return;
var resource = SelectedResourceRef();
if (resource is null || resource.Actions.CanRestart == false)
return;
var restarted = await _workspace.Session.RestartAsync(resource.ToRef(), SelectedRow.Name, SelectedRow.Namespace);
_setStatus(restarted.IsSuccess ? "Restarted." : string.Join("; ", restarted.Messages));
if (await RunOnSelectedAsync(row =>
_workspace.Session!.RestartAsync(resource.ToRef(), row.Name, row.Namespace)) is not { } outcome)
return;
_setStatus(outcome.Format("Restarted.", $"Restarted {outcome.Total}."));
}
[RelayCommand]
private async Task CordonAsync() {
if (_workspace.Session is null || SelectedRow is null)
if (await RunOnSelectedAsync(row =>
_workspace.Session!.CordonAsync(row.Name, true)) is not { } outcome)
return;
var result = await _workspace.Session.CordonAsync(SelectedRow.Name, true);
_setStatus(result.IsSuccess ? "Cordoned." : string.Join("; ", result.Messages));
_setStatus(outcome.Format("Cordoned.", $"Cordoned {outcome.Total}."));
}
[RelayCommand]
private async Task UncordonAsync() {
if (_workspace.Session is null || SelectedRow is null)
if (await RunOnSelectedAsync(row =>
_workspace.Session!.CordonAsync(row.Name, false)) is not { } outcome)
return;
var result = await _workspace.Session.CordonAsync(SelectedRow.Name, false);
_setStatus(result.IsSuccess ? "Uncordoned." : string.Join("; ", result.Messages));
_setStatus(outcome.Format("Uncordoned.", $"Uncordoned {outcome.Total}."));
}
[RelayCommand]
private async Task DrainAsync() {
if (_workspace.Session is null || SelectedRow is null)
if (await RunOnSelectedAsync(row =>
_workspace.Session!.DrainAsync(row.Name)) is not { } outcome)
return;
var result = await _workspace.Session.DrainAsync(SelectedRow.Name);
_setStatus(result.IsSuccess ? "Drain requested." : string.Join("; ", result.Messages));
_setStatus(outcome.Format("Drain requested.", $"Drain requested for {outcome.Total}."));
}
[RelayCommand]
private async Task TriggerCronAsync() {
if (_workspace.Session is null || SelectedRow is null)
if (await RunOnSelectedAsync(row =>
_workspace.Session!.TriggerCronJobAsync(row.Name, row.Namespace ?? "default")) is not { } outcome)
return;
var result = await _workspace.Session.TriggerCronJobAsync(SelectedRow.Name, SelectedRow.Namespace ?? "default");
_setStatus(result.IsSuccess ? "Job created." : string.Join("; ", result.Messages));
_setStatus(outcome.Format("Job created.", $"Created {outcome.Total} jobs."));
}
[RelayCommand]
@ -924,21 +964,42 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[RelayCommand]
private void StopSelectedPortForward() {
if (SelectedRow is null)
var rows = ActionTargets;
if (rows.Count == 0)
return;
var live = PortForwards.FirstOrDefault(item => item.Uid == SelectedRow.Uid);
var stoppedPorts = new List<int>();
var failures = new List<string>();
foreach (var row in rows) {
var live = PortForwards.FirstOrDefault(item => item.Uid == row.Uid);
if (live is not null) {
StopPortForward(live);
return;
var port = live.Handle.LocalPort;
live.Handle.Dispose();
PortForwards.Remove(live);
PersistStopped(port);
stoppedPorts.Add(port);
continue;
}
if (!PortForwardRow.TryLocalPort(SelectedRow, out var localPort))
return;
if (!PortForwardRow.TryLocalPort(row, out var localPort)) {
failures.Add($"{row.Name}: not a port-forward");
continue;
}
PersistStopped(localPort);
stoppedPorts.Add(localPort);
}
if (IsPortForwardingView)
ShowPortForwardRows();
_setStatus($"Stopped port-forward localhost:{localPort}.");
if (failures.Count == 0 && stoppedPorts.Count == 1)
_setStatus($"Stopped port-forward localhost:{stoppedPorts[0]}.");
else if (failures.Count == 0)
_setStatus($"Stopped {stoppedPorts.Count} port-forwards.");
else if (stoppedPorts.Count == 0)
_setStatus(string.Join("; ", failures));
else
_setStatus($"{stoppedPorts.Count} of {rows.Count} succeeded. {string.Join("; ", failures)}");
}
[RelayCommand]
@ -1579,7 +1640,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
private void ShowPortForwardRows(string? keepUid = null) {
keepUid ??= SelectedRow?.Uid;
var keepUids = SnapshotSelectedUids().ToList();
if (keepUid is not null && !keepUids.Contains(keepUid, StringComparer.Ordinal))
keepUids.Add(keepUid);
var livePorts = PortForwards.Select(item => item.Handle.LocalPort).ToHashSet();
_listedRows.Clear();
foreach (var item in PortForwards)
@ -1595,7 +1658,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
foreach (var column in ResourceCatalog.PortForwardingDescriptor.Columns)
FilterFor(column.Header).LoadValues(_listedRows);
ApplyColumnFilters(keepUid);
ApplyColumnFilters(keepUids);
NotifyActionFlags();
NotifyDetailsUi();
}
@ -1933,6 +1996,38 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
return SelectedDescriptor?.DetailTabs.Contains(tab) == true;
}
public void ReplaceSelectedRows(IEnumerable<ResourceRow> rows) {
var selected = rows.DistinctBy(row => row.Uid).ToList();
_selectedRows = selected;
_selectedUids.Clear();
_selectedUids.AddRange(selected.Select(row => row.Uid));
OnPropertyChanged(nameof(SelectedRows));
OnPropertyChanged(nameof(DetailsTitle));
NotifyActionFlags();
}
private IReadOnlyList<ResourceRow> ActionTargets =>
ResourceActionBatch.Targets(_selectedRows, SelectedRow);
private IReadOnlyList<string> SnapshotSelectedUids() {
if (_selectedUids.Count > 0)
return [.. _selectedUids];
return SelectedRow is null ? [] : [SelectedRow.Uid];
}
private void ClearTableSelection() {
ReplaceSelectedRows([]);
SelectedRow = null;
SelectedRowsRestored?.Invoke([]);
}
private async Task<ResourceActionBatchOutcome?> RunOnSelectedAsync(Func<ResourceRow, Task<Result>> action) {
if (_workspace.Session is null || ActionTargets.Count == 0)
return null;
return await ResourceActionBatch.RunAsync(ActionTargets, action);
}
private void NotifyActionFlags() {
OnPropertyChanged(nameof(CanDelete));
OnPropertyChanged(nameof(CanForceDelete));

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "MaksIT.ClusterConsole Release Script Settings",
"description": "RepoUtils release pipeline for MaksIT.ClusterConsole Avalonia UI.",
"description": "Community desktop pipeline. GitHub assets are siblings: portable zip, Windows setup exe, Flatpak. The zip never contains the installer or Flatpak.",
"plugins": [
{
"name": "DotNetReleaseVersion",
@ -35,13 +35,51 @@
"projectFiles": [
"..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\MaksIT.ClusterConsole.UI.csproj"
],
"artifactsDir": "..\\..\\..\\releases"
"artifactsDir": "..\\..\\..\\releases",
"runtimeIdentifiers": [
"win-x64",
"linux-x64"
],
"selfContained": true,
"publishSingleFile": false
},
{
"name": "DotNetCreateArchive",
"stageLabel": "build",
"enabled": true,
"zipNamePattern": "maksit-cluster-console-{version}.zip"
"zipNamePattern": "maksit-cluster-console-{version}.zip",
"portableRuntimeIdentifier": "win-x64"
},
{
"name": "WindowsInstaller",
"stageLabel": "build",
"enabled": true,
"appName": "MaksIT Cluster Console",
"manufacturer": "MaksIT",
"upgradeCode": "3e45e88a-6524-49cc-88e9-d522d1261b3a",
"executableName": "MaksIT.ClusterConsole.UI.exe",
"runtimeIdentifier": "win-x64",
"installScope": "perUser",
"iconPath": "..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\Assets\\icon.ico",
"exeNamePattern": "maksit-cluster-console-{version}.exe"
},
{
"name": "FlatpakPack",
"stageLabel": "build",
"enabled": true,
"appId": "com.maks_it.ClusterConsole",
"appName": "MaksIT Cluster Console",
"command": "maksit-cluster-console",
"executableName": "MaksIT.ClusterConsole.UI",
"runtimeIdentifier": "linux-x64",
"summary": "Kubernetes desktop console",
"projectLicense": "Apache-2.0",
"iconPath": "..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\Assets\\icon.png",
"bundleNamePattern": "maksit-cluster-console-{version}.flatpak",
"buildBundle": true,
"attachSourceZip": false,
"wslDistro": "Debian",
"whenBuilderMissing": "skip"
},
{
"name": "ReleasePublishGuard",
@ -64,33 +102,20 @@
"githubSecret": "GitHub",
"repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
"releaseTitlePattern": "Release {version}",
"requireReleaseAssets": true
},
{
"name": "DotNetNuGet",
"stageLabel": "release",
"enabled": false,
"nugetSecret": "NuGet",
"source": "https://api.nuget.org/v3/index.json"
},
{
"name": "NpmPublish",
"stageLabel": "release",
"enabled": false,
"npmSecret": "Npm",
"registry": "https://registry.npmjs.org",
"access": "public",
"workspaceRoot": "..\\..\\.."
},
{
"name": "DotNetCleanupArtifacts",
"name": "CleanupArtifacts",
"stageLabel": "release",
"enabled": true,
"includePatterns": [
"*"
],
"excludePatterns": [
"*.zip"
"*.zip",
"*.exe",
"*.flatpak"
]
}
]

View File

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

View File

@ -54,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
@ -597,7 +597,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) {

View File

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

View File

@ -0,0 +1,931 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Helpers for Community desktop pack plugins (Windows MSI, Linux Flatpak).
#>
function ConvertTo-WixIdentifier {
param(
[Parameter(Mandatory = $true)]
[string]$Value,
[Parameter(Mandatory = $false)]
[string]$Prefix = 'id'
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '')
}
finally {
$sha.Dispose()
}
return ($Prefix + $hash.Substring(0, 16))
}
function Get-MsiProductVersion {
param(
[Parameter(Mandatory = $true)]
[string]$Version
)
$numeric = $Version
$plusIndex = $numeric.IndexOf('+')
if ($plusIndex -ge 0) {
$numeric = $numeric.Substring(0, $plusIndex)
}
$hyphenIndex = $numeric.IndexOf('-')
if ($hyphenIndex -ge 0) {
$numeric = $numeric.Substring(0, $hyphenIndex)
}
$parts = @($numeric.Split('.') | Where-Object { $_ -match '^\d+$' })
if ($parts.Count -lt 2) {
throw "Cannot derive an MSI ProductVersion from '$Version'. Expected semver with at least major.minor."
}
while ($parts.Count -lt 3) {
$parts += '0'
}
if ($parts.Count -gt 4) {
$parts = $parts[0..3]
}
return ($parts -join '.')
}
function Get-PluginPropertyValue {
param(
[Parameter(Mandatory = $true)]
$PluginSettings,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
$Default = $null
)
if ($PluginSettings.PSObject.Properties[$Name] -and $null -ne $PluginSettings.$Name) {
$value = $PluginSettings.$Name
if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) {
return $Default
}
return $value
}
return $Default
}
function Resolve-DesktopPublishDirectory {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$RuntimeIdentifier,
[Parameter(Mandatory = $false)]
[string]$PublishDir,
[Parameter(Mandatory = $false)]
[string]$ScriptDir
)
if (-not [string]::IsNullOrWhiteSpace($PublishDir)) {
$resolved = $PublishDir
if (-not [System.IO.Path]::IsPathRooted($resolved) -and -not [string]::IsNullOrWhiteSpace($ScriptDir)) {
$resolved = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $PublishDir))
}
if (-not (Test-Path -LiteralPath $resolved -PathType Container)) {
throw "Desktop publish directory not found: $resolved"
}
return $resolved
}
$outputs = @(Get-EngineFact -Context $Context -Namespace 'dotnet' -Name 'publishOutputs' -Default @())
if ($outputs.Count -gt 0) {
$exact = @(
$outputs |
Where-Object { [string]$_.runtimeIdentifier -eq $RuntimeIdentifier }
)
if ($exact.Count -gt 0) {
return [string]$exact[0].directory
}
$family = $RuntimeIdentifier.Split('-')[0]
if (-not [string]::IsNullOrWhiteSpace($family)) {
$matchedFamily = @(
$outputs |
Where-Object { ([string]$_.runtimeIdentifier).StartsWith("$family-", [System.StringComparison]::OrdinalIgnoreCase) }
)
if ($matchedFamily.Count -gt 0) {
return [string]$matchedFamily[0].directory
}
}
}
$archiveInputs = @(Get-EngineFact -Context $Context -Namespace 'release' -Name 'archiveInputs' -Default @() -LegacyProperty @('releaseArchiveInputs'))
$dirs = @(
$archiveInputs |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) -and (Test-Path -LiteralPath ([string]$_) -PathType Container) }
)
if ($dirs.Count -eq 1) {
return [string]$dirs[0]
}
throw "Could not resolve a '$RuntimeIdentifier' publish directory. Run DotNetPublish with runtimeIdentifiers (or set publishDir)."
}
function Resolve-DesktopExecutablePath {
param(
[Parameter(Mandatory = $true)]
[string]$PublishDirectory,
[Parameter(Mandatory = $false)]
[string]$ExecutableName,
[Parameter(Mandatory = $false)]
[switch]$Windows
)
if (-not [string]::IsNullOrWhiteSpace($ExecutableName)) {
$direct = Join-Path $PublishDirectory $ExecutableName
if (Test-Path -LiteralPath $direct -PathType Leaf) {
return $direct
}
$withExe = Join-Path $PublishDirectory ($ExecutableName + '.exe')
if ($Windows -and (Test-Path -LiteralPath $withExe -PathType Leaf)) {
return $withExe
}
throw "Executable '$ExecutableName' was not found in: $PublishDirectory"
}
$candidates = @()
if ($Windows) {
$candidates = @(Get-ChildItem -LiteralPath $PublishDirectory -File -Filter '*.exe' | Where-Object { $_.Name -notin @('createdump.exe') })
}
else {
$candidates = @(
Get-ChildItem -LiteralPath $PublishDirectory -File |
Where-Object { [string]::IsNullOrWhiteSpace($_.Extension) }
)
}
if ($candidates.Count -eq 1) {
return $candidates[0].FullName
}
if ($candidates.Count -gt 1) {
throw "Multiple executables found in '$PublishDirectory'. Set executableName."
}
throw "No executable found in '$PublishDirectory'. Set executableName."
}
function New-WixPackageXml {
param(
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$Manufacturer,
[Parameter(Mandatory = $true)]
[string]$ProductVersion,
[Parameter(Mandatory = $true)]
[guid]$UpgradeCode,
[Parameter(Mandatory = $true)]
[string]$PublishDirectory,
[Parameter(Mandatory = $true)]
[string]$ExecutablePath,
[Parameter(Mandatory = $false)]
[string]$InstallScope = 'perUser',
[Parameter(Mandatory = $false)]
[string]$IconPath
)
$ns = 'http://wixtoolset.org/schemas/v4/wxs'
$xml = New-Object System.Xml.XmlDocument
$null = $xml.AppendChild($xml.CreateXmlDeclaration('1.0', 'utf-8', $null))
$wix = $xml.CreateElement('Wix', $ns)
$null = $xml.AppendChild($wix)
$scope = if ($InstallScope -eq 'perMachine') { 'perMachine' } else { 'perUser' }
$package = $xml.CreateElement('Package', $ns)
$null = $package.SetAttribute('Name', $AppName)
$null = $package.SetAttribute('Manufacturer', $Manufacturer)
$null = $package.SetAttribute('Version', $ProductVersion)
$null = $package.SetAttribute('UpgradeCode', $UpgradeCode.ToString('D'))
$null = $package.SetAttribute('Scope', $scope)
$null = $wix.AppendChild($package)
$major = $xml.CreateElement('MajorUpgrade', $ns)
$null = $major.SetAttribute('DowngradeErrorMessage', 'A newer version is already installed.')
$null = $package.AppendChild($major)
$media = $xml.CreateElement('MediaTemplate', $ns)
$null = $media.SetAttribute('EmbedCab', 'yes')
$null = $package.AppendChild($media)
if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
$icon = $xml.CreateElement('Icon', $ns)
$null = $icon.SetAttribute('Id', 'AppIcon')
$null = $icon.SetAttribute('SourceFile', $IconPath)
$null = $package.AppendChild($icon)
$prop = $xml.CreateElement('Property', $ns)
$null = $prop.SetAttribute('Id', 'ARPPRODUCTICON')
$null = $prop.SetAttribute('Value', 'AppIcon')
$null = $package.AppendChild($prop)
}
$desktopProp = $xml.CreateElement('Property', $ns)
$null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
$null = $desktopProp.SetAttribute('Value', '0')
$null = $package.AppendChild($desktopProp)
$stdLocal = $xml.CreateElement('StandardDirectory', $ns)
$rootFolderId = if ($scope -eq 'perMachine') { 'ProgramFiles6432Folder' } else { 'LocalAppDataFolder' }
$null = $stdLocal.SetAttribute('Id', $rootFolderId)
$null = $package.AppendChild($stdLocal)
$programs = $xml.CreateElement('Directory', $ns)
$null = $programs.SetAttribute('Id', 'LocalProgramsFolder')
$null = $programs.SetAttribute('Name', 'Programs')
$null = $stdLocal.AppendChild($programs)
$installFolder = $xml.CreateElement('Directory', $ns)
$null = $installFolder.SetAttribute('Id', 'INSTALLFOLDER')
$null = $installFolder.SetAttribute('Name', $AppName)
$null = $programs.AppendChild($installFolder)
$stdMenu = $xml.CreateElement('StandardDirectory', $ns)
$null = $stdMenu.SetAttribute('Id', 'ProgramMenuFolder')
$null = $package.AppendChild($stdMenu)
$menuDir = $xml.CreateElement('Directory', $ns)
$null = $menuDir.SetAttribute('Id', 'AppShortcutFolder')
$null = $menuDir.SetAttribute('Name', $AppName)
$null = $stdMenu.AppendChild($menuDir)
$stdDesktop = $xml.CreateElement('StandardDirectory', $ns)
$null = $stdDesktop.SetAttribute('Id', 'DesktopFolder')
$null = $package.AppendChild($stdDesktop)
$dirNodes = @{
'' = $installFolder
}
$componentIds = [System.Collections.Generic.List[string]]::new()
$publishRoot = [System.IO.Path]::GetFullPath($PublishDirectory)
$files = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File)
if ($files.Count -eq 0) {
throw "WindowsInstaller found no files to harvest in: $publishRoot"
}
foreach ($file in $files) {
$relative = $file.FullName.Substring($publishRoot.Length).TrimStart('\', '/')
$relativeDir = [System.IO.Path]::GetDirectoryName($relative)
if ($null -eq $relativeDir) {
$relativeDir = ''
}
$parent = $installFolder
if (-not [string]::IsNullOrWhiteSpace($relativeDir)) {
$parts = $relativeDir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries)
$walk = ''
foreach ($part in $parts) {
$walk = if ($walk) { Join-Path $walk $part } else { $part }
if (-not $dirNodes.ContainsKey($walk)) {
$child = $xml.CreateElement('Directory', $ns)
$null = $child.SetAttribute('Id', (ConvertTo-WixIdentifier -Value $walk -Prefix 'd'))
$null = $child.SetAttribute('Name', $part)
$null = $parent.AppendChild($child)
$dirNodes[$walk] = $child
}
$parent = $dirNodes[$walk]
}
}
$component = $xml.CreateElement('Component', $ns)
$componentId = ConvertTo-WixIdentifier -Value $relative -Prefix 'c'
$null = $component.SetAttribute('Id', $componentId)
$null = $component.SetAttribute('Guid', '*')
$componentIds.Add($componentId)
$fileNode = $xml.CreateElement('File', $ns)
$null = $fileNode.SetAttribute('Id', (ConvertTo-WixIdentifier -Value $relative -Prefix 'f'))
$null = $fileNode.SetAttribute('Source', $file.FullName)
$null = $fileNode.SetAttribute('Name', $file.Name)
$null = $fileNode.SetAttribute('KeyPath', 'yes')
$null = $component.AppendChild($fileNode)
$null = $parent.AppendChild($component)
}
$exeName = [System.IO.Path]::GetFileName($ExecutablePath)
$shortcut = $xml.CreateElement('Component', $ns)
$null = $shortcut.SetAttribute('Id', 'StartMenuShortcut')
$null = $shortcut.SetAttribute('Directory', 'AppShortcutFolder')
$null = $shortcut.SetAttribute('Guid', '*')
$shortcutNode = $xml.CreateElement('Shortcut', $ns)
$null = $shortcutNode.SetAttribute('Id', 'AppStartMenuShortcut')
$null = $shortcutNode.SetAttribute('Name', $AppName)
$null = $shortcutNode.SetAttribute('Target', "[INSTALLFOLDER]$exeName")
$null = $shortcutNode.SetAttribute('WorkingDirectory', 'INSTALLFOLDER')
if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
$null = $shortcutNode.SetAttribute('Icon', 'AppIcon')
}
$null = $shortcut.AppendChild($shortcutNode)
$remove = $xml.CreateElement('RemoveFolder', $ns)
$null = $remove.SetAttribute('Id', 'AppShortcutFolder')
$null = $remove.SetAttribute('On', 'uninstall')
$null = $shortcut.AppendChild($remove)
$reg = $xml.CreateElement('RegistryValue', $ns)
$null = $reg.SetAttribute('Root', 'HKCU')
$null = $reg.SetAttribute('Key', "Software\$Manufacturer\$AppName")
$null = $reg.SetAttribute('Name', 'installed')
$null = $reg.SetAttribute('Type', 'integer')
$null = $reg.SetAttribute('Value', '1')
$null = $reg.SetAttribute('KeyPath', 'yes')
$null = $shortcut.AppendChild($reg)
$null = $package.AppendChild($shortcut)
$desktop = $xml.CreateElement('Component', $ns)
$null = $desktop.SetAttribute('Id', 'DesktopShortcut')
$null = $desktop.SetAttribute('Directory', 'DesktopFolder')
$null = $desktop.SetAttribute('Guid', '*')
$null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1')
$desktopShortcut = $xml.CreateElement('Shortcut', $ns)
$null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut')
$null = $desktopShortcut.SetAttribute('Name', $AppName)
$null = $desktopShortcut.SetAttribute('Target', "[INSTALLFOLDER]$exeName")
$null = $desktopShortcut.SetAttribute('WorkingDirectory', 'INSTALLFOLDER')
if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
$null = $desktopShortcut.SetAttribute('Icon', 'AppIcon')
}
$null = $desktop.AppendChild($desktopShortcut)
$desktopReg = $xml.CreateElement('RegistryValue', $ns)
$null = $desktopReg.SetAttribute('Root', 'HKCU')
$null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName")
$null = $desktopReg.SetAttribute('Name', 'desktop')
$null = $desktopReg.SetAttribute('Type', 'integer')
$null = $desktopReg.SetAttribute('Value', '1')
$null = $desktopReg.SetAttribute('KeyPath', 'yes')
$null = $desktop.AppendChild($desktopReg)
$null = $package.AppendChild($desktop)
$feature = $xml.CreateElement('Feature', $ns)
$null = $feature.SetAttribute('Id', 'Main')
$null = $feature.SetAttribute('Title', $AppName)
$null = $feature.SetAttribute('Level', '1')
$refShortcut = $xml.CreateElement('ComponentRef', $ns)
$null = $refShortcut.SetAttribute('Id', 'StartMenuShortcut')
$null = $feature.AppendChild($refShortcut)
$refDesktop = $xml.CreateElement('ComponentRef', $ns)
$null = $refDesktop.SetAttribute('Id', 'DesktopShortcut')
$null = $feature.AppendChild($refDesktop)
foreach ($id in $componentIds) {
$cref = $xml.CreateElement('ComponentRef', $ns)
$null = $cref.SetAttribute('Id', $id)
$null = $feature.AppendChild($cref)
}
$null = $package.AppendChild($feature)
return $xml
}
function Get-DefaultFlatpakFinishArgs {
return @(
'--share=ipc',
'--share=network',
'--socket=fallback-x11',
'--socket=wayland',
'--device=dri',
'--filesystem=home'
)
}
function New-FlatpakDesktopEntry {
param(
[Parameter(Mandatory = $true)]
[string]$AppId,
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$Command,
[Parameter(Mandatory = $false)]
[string]$Categories = 'Utility;'
)
$lines = @(
'[Desktop Entry]',
'Type=Application',
"Name=$AppName",
"Exec=$Command",
"Icon=$AppId",
'Terminal=false',
"Categories=$Categories"
)
return (($lines -join "`n") + "`n")
}
function New-FlatpakMetainfoXml {
param(
[Parameter(Mandatory = $true)]
[string]$AppId,
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$Summary,
[Parameter(Mandatory = $false)]
[string]$ProjectLicense = 'MIT',
[Parameter(Mandatory = $false)]
[string]$MetadataLicense = 'CC0-1.0'
)
$escapedName = [System.Security.SecurityElement]::Escape($AppName)
$escapedSummary = [System.Security.SecurityElement]::Escape($Summary)
return @"
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>$AppId</id>
<name>$escapedName</name>
<summary>$escapedSummary</summary>
<launchable type="desktop-id">$AppId.desktop</launchable>
<metadata_license>$MetadataLicense</metadata_license>
<project_license>$ProjectLicense</project_license>
</component>
"@
}
function New-FlatpakManifestObject {
param(
[Parameter(Mandatory = $true)]
[string]$AppId,
[Parameter(Mandatory = $true)]
[string]$Command,
[Parameter(Mandatory = $true)]
[string]$ModuleName,
[Parameter(Mandatory = $false)]
[string]$Runtime = 'org.freedesktop.Platform',
[Parameter(Mandatory = $false)]
[string]$RuntimeVersion = '24.08',
[Parameter(Mandatory = $false)]
[string]$Sdk = 'org.freedesktop.Sdk',
[Parameter(Mandatory = $false)]
[string[]]$FinishArgs,
[Parameter(Mandatory = $false)]
[string[]]$BuildCommands
)
if ($null -eq $FinishArgs -or $FinishArgs.Count -eq 0) {
$FinishArgs = @(Get-DefaultFlatpakFinishArgs)
}
if ($null -eq $BuildCommands -or $BuildCommands.Count -eq 0) {
$BuildCommands = @(
'mkdir -p /app/lib /app/share',
'cp -a lib /app/lib',
'cp -a share /app/share',
"install -Dm755 bin/$Command /app/bin/$Command",
"chmod +x /app/lib/$ModuleName/$Command"
)
}
return [ordered]@{
'app-id' = $AppId
runtime = $Runtime
'runtime-version' = $RuntimeVersion
sdk = $Sdk
command = $Command
'finish-args' = @($FinishArgs)
modules = @(
[ordered]@{
name = $ModuleName
buildsystem = 'simple'
'build-commands' = @($BuildCommands)
sources = @(
[ordered]@{
type = 'dir'
path = 'files'
}
)
}
)
}
}
function New-FlatpakLaunchScript {
param(
[Parameter(Mandatory = $true)]
[string]$ModuleName,
[Parameter(Mandatory = $true)]
[string]$ExecutableFileName
)
return "#!/bin/sh`nexec /app/lib/$ModuleName/$ExecutableFileName `"`$@`"`n"
}
function Assert-FlatpakAppId {
param(
[Parameter(Mandatory = $true)]
[string]$AppId
)
$hint = "Domain hyphens become underscores (maks-it.com → com.maks_it.App). Only the last segment may contain '-'."
$segments = @($AppId.Split('.'))
if ($segments.Count -lt 3) {
throw "Flatpak appId '$AppId' is invalid: need at least three reverse-DNS segments (e.g. com.maks_it.Wvc210). $hint"
}
for ($i = 0; $i -lt $segments.Count; $i++) {
$segment = $segments[$i]
$isLast = ($i -eq ($segments.Count - 1))
$pattern = if ($isLast) { '^[A-Za-z_][A-Za-z0-9_-]*$' } else { '^[A-Za-z_][A-Za-z0-9_]*$' }
if ($segment -notmatch $pattern) {
throw "Flatpak appId '$AppId' is invalid: Only last name segment can contain '-'. $hint"
}
}
}
function Get-DerivedBundleUpgradeCode {
param(
[Parameter(Mandatory = $true)]
[guid]$UpgradeCode
)
$bytes = $UpgradeCode.ToByteArray()
$bytes[0] = $bytes[0] -bxor 0x5A
return [guid]::new($bytes)
}
function ConvertTo-ShSingleQuoted {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
return "'" + ($Value -replace "'", "'\''") + "'"
}
function ConvertTo-UnixLineEndings {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
return (($Value -replace "`r`n", "`n") -replace "`r", "`n")
}
function ConvertTo-WslMountPath {
param(
[Parameter(Mandatory = $true)]
[string]$WindowsPath
)
$full = [System.IO.Path]::GetFullPath($WindowsPath)
if ($full.StartsWith('\\')) {
throw "UNC paths cannot be mapped to /mnt for WSL Flatpak builds: $WindowsPath"
}
$root = [System.IO.Path]::GetPathRoot($full)
$drive = $root.Substring(0, 1).ToLowerInvariant()
$rest = $full.Substring($root.Length).Replace('\', '/')
return "/mnt/$drive/$rest"
}
function ConvertFrom-WslMountPath {
param(
[Parameter(Mandatory = $true)]
[string]$UnixPath
)
if ($UnixPath -notmatch '^/mnt/([A-Za-z])/(.*)$') {
return $null
}
$drive = $Matches[1].ToUpperInvariant()
$rest = $Matches[2].Replace('/', '\')
return "${drive}:\$rest"
}
function ConvertFrom-WslText {
param($InputObject)
$parts = foreach ($item in @($InputObject)) {
if ($null -eq $item) {
continue
}
[string]$item
}
$text = ($parts -join "`n").Replace("`0", '')
$lines = [System.Collections.Generic.List[string]]::new()
foreach ($line in ($text -split "`r?`n")) {
$trimmed = $line.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) {
continue
}
$utf16Spaced = $trimmed.Length -ge 3 -and ($trimmed.Length % 2) -eq 1
if ($utf16Spaced) {
for ($i = 1; $i -lt $trimmed.Length; $i += 2) {
if ($trimmed[$i] -ne [char]' ') {
$utf16Spaced = $false
break
}
}
}
if ($utf16Spaced) {
$chars = for ($i = 0; $i -lt $trimmed.Length; $i += 2) { $trimmed[$i] }
$trimmed = (-join $chars).Trim()
}
if (-not [string]::IsNullOrWhiteSpace($trimmed)) {
$lines.Add($trimmed)
}
}
return @($lines)
}
function ConvertFrom-WslListOutput {
param($InputObject)
return @(
ConvertFrom-WslText -InputObject $InputObject |
Where-Object { $_ -notmatch '(?i)^(Windows Subsystem|Distributions:|NAME\s+STATE|The operation completed)' } |
ForEach-Object { ($_ -replace '\s+\(.*Default.*\)$', '').Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
}
function Test-WslDistroBlockedForFlatpak {
param(
[Parameter(Mandatory = $true)]
[string]$Distro
)
$blocked = @('docker-desktop', 'docker-desktop-data')
return $blocked -contains $Distro.Trim().ToLowerInvariant()
}
function Assert-WslDistroAllowedForFlatpak {
param(
[Parameter(Mandatory = $true)]
[string]$Distro
)
if ([string]::IsNullOrWhiteSpace($Distro)) {
throw "FlatpakPack wslDistro is required on Windows (pin Debian; do not use the default WSL distro)."
}
if (Test-WslDistroBlockedForFlatpak -Distro $Distro) {
throw "FlatpakPack wslDistro cannot be '$Distro' (Docker Desktop). Install Debian and set wslDistro to Debian."
}
}
function Test-WslDistroInstalled {
param(
[Parameter(Mandatory = $true)]
[string]$Distro,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[string[]]$InstalledNames
)
foreach ($name in @($InstalledNames)) {
if ([string]::Equals($name, $Distro, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Get-FlatpakBuilderShellLines {
param(
[Parameter(Mandatory = $false)]
[string]$Repo = 'repo',
[Parameter(Mandatory = $false)]
[string]$BuildDir = 'build-dir',
[Parameter(Mandatory = $false)]
[string]$Manifest = 'manifest.json'
)
# org.flatpak.Builder's sandbox cwd is $HOME, so relative manifest.json
# resolves to /home/<user>/manifest.json. Always pass absolute paths.
# --disable-rofiles-fuse: WSL/bwrap has no working rofiles FUSE overlay.
$runLine = ' flatpak run --filesystem=host --filesystem="$ROOT" --share=network --cwd="$ROOT" org.flatpak.Builder --disable-rofiles-fuse --repo="$ROOT/{0}" --force-clean "$ROOT/{1}" "$ROOT/{2}"' -f $Repo, $BuildDir, $Manifest
$cliLine = ' flatpak-builder --disable-rofiles-fuse --repo="$ROOT/{0}" --force-clean "$ROOT/{1}" "$ROOT/{2}"' -f $Repo, $BuildDir, $Manifest
return @(
'ROOT=$(pwd)',
'if flatpak info org.flatpak.Builder >/dev/null 2>&1; then',
$runLine,
'elif command -v flatpak-builder >/dev/null 2>&1; then',
$cliLine,
'else',
' echo "flatpak builder missing; install Flathub org.flatpak.Builder" >&2',
' exit 127',
'fi'
)
}
function New-WslFlatpakBuildScript {
param(
[Parameter(Mandatory = $true)]
[string]$WindowsStageRoot,
[Parameter(Mandatory = $true)]
[string]$WindowsBundlePath,
[Parameter(Mandatory = $true)]
[string]$AppId
)
$stageQuoted = ConvertTo-ShSingleQuoted -Value $WindowsStageRoot
$bundleQuoted = ConvertTo-ShSingleQuoted -Value $WindowsBundlePath
$appQuoted = ConvertTo-ShSingleQuoted -Value $AppId
$lines = @(
'set -euo pipefail',
"STAGE_WIN=$stageQuoted",
"BUNDLE_WIN=$bundleQuoted",
"APP_ID=$appQuoted",
'STAGE=$(wslpath -a "$STAGE_WIN")',
'BUNDLE=$(wslpath -a "$BUNDLE_WIN")',
'mkdir -p "$(dirname "$BUNDLE")"',
'CACHE="${XDG_CACHE_HOME:-$HOME/.cache}"',
'mkdir -p "$CACHE"',
'WORK=$(mktemp -d "$CACHE/maksit-flatpak.XXXXXX")',
'cleanup() { rm -rf "$WORK"; }',
'trap cleanup EXIT',
'cp -a "$STAGE"/. "$WORK"/',
'cd "$WORK"',
'if [ -d files/bin ]; then',
' find files/bin -type f -print0 | xargs -0 -r sed -i "s/\r$//"',
' chmod +x files/bin/* || true',
'fi'
)
$lines += @(Get-FlatpakBuilderShellLines)
$lines += @(
'flatpak build-bundle "$ROOT/repo" "$BUNDLE" "$APP_ID"',
'test -f "$BUNDLE"'
)
return (($lines -join "`n") + "`n")
}
function New-WixBundleXml {
param(
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$Manufacturer,
[Parameter(Mandatory = $true)]
[string]$ProductVersion,
[Parameter(Mandatory = $true)]
[guid]$UpgradeCode,
[Parameter(Mandatory = $true)]
[string]$MsiPath,
[Parameter(Mandatory = $false)]
[string]$IconPath,
[Parameter(Mandatory = $false)]
[string]$LogoPath,
[Parameter(Mandatory = $false)]
[string]$LogoSidePath,
[Parameter(Mandatory = $false)]
[string]$ThemePath,
[Parameter(Mandatory = $false)]
[string]$InstallScope = 'perUser'
)
$escapedName = [System.Security.SecurityElement]::Escape($AppName)
$escapedMfr = [System.Security.SecurityElement]::Escape($Manufacturer)
$escapedMsi = [System.Security.SecurityElement]::Escape($MsiPath)
$bundleUpgrade = Get-DerivedBundleUpgradeCode -UpgradeCode $UpgradeCode
$iconAttr = ''
if (-not [string]::IsNullOrWhiteSpace($IconPath)) {
$iconAttr = " IconSourceFile=`"$([System.Security.SecurityElement]::Escape($IconPath))`""
}
$theme = 'hyperlinkLicense'
$logoAttrs = ''
if (-not [string]::IsNullOrWhiteSpace($LogoPath)) {
$logoAttrs += " LogoFile=`"$([System.Security.SecurityElement]::Escape($LogoPath))`""
}
if (-not [string]::IsNullOrWhiteSpace($LogoSidePath)) {
$theme = 'hyperlinkSidebarLicense'
$logoAttrs += " LogoSideFile=`"$([System.Security.SecurityElement]::Escape($LogoSidePath))`""
}
if (-not [string]::IsNullOrWhiteSpace($ThemePath)) {
$theme = 'hyperlinkSidebarLicense'
$logoAttrs += " ThemeFile=`"$([System.Security.SecurityElement]::Escape($ThemePath))`""
}
$folderRoot = if ($InstallScope -eq 'perMachine') {
'[ProgramFiles64Folder]Programs\'
}
else {
'[LocalAppDataFolder]Programs\'
}
$escapedFolder = [System.Security.SecurityElement]::Escape($folderRoot + $AppName)
return @"
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal">
<Bundle Name="$escapedName" Manufacturer="$escapedMfr" Version="$ProductVersion" UpgradeCode="$($bundleUpgrade.ToString('D'))"$iconAttr>
<Variable Name="InstallFolder" Type="string" Value="$escapedFolder" bal:Overridable="yes" />
<Variable Name="InstallDesktopShortcut" Type="numeric" Value="0" bal:Overridable="yes" />
<BootstrapperApplication>
<bal:WixStandardBootstrapperApplication Theme="$theme" LicenseUrl=""$logoAttrs />
</BootstrapperApplication>
<Chain>
<MsiPackage SourceFile="$escapedMsi" Compressed="yes" Vital="yes">
<MsiProperty Name="INSTALLFOLDER" Value="[InstallFolder]" />
<MsiProperty Name="INSTALLDESKTOPSHORTCUT" Value="[InstallDesktopShortcut]" />
</MsiPackage>
</Chain>
</Bundle>
</Wix>
"@
}
Export-ModuleMember -Function `
ConvertTo-WixIdentifier, `
Get-MsiProductVersion, `
Get-PluginPropertyValue, `
Resolve-DesktopPublishDirectory, `
Resolve-DesktopExecutablePath, `
New-WixPackageXml, `
Get-DerivedBundleUpgradeCode, `
New-WixBundleXml, `
Get-DefaultFlatpakFinishArgs, `
New-FlatpakDesktopEntry, `
New-FlatpakMetainfoXml, `
New-FlatpakManifestObject, `
New-FlatpakLaunchScript, `
Assert-FlatpakAppId, `
ConvertTo-ShSingleQuoted, `
ConvertTo-UnixLineEndings, `
ConvertTo-WslMountPath, `
ConvertFrom-WslMountPath, `
ConvertFrom-WslText, `
ConvertFrom-WslListOutput, `
Test-WslDistroBlockedForFlatpak, `
Assert-WslDistroAllowedForFlatpak, `
Test-WslDistroInstalled, `
Get-FlatpakBuilderShellLines, `
New-WslFlatpakBuildScript

View File

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

View File

@ -0,0 +1,294 @@
#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.
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')
$installScope = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installScope' -Default 'perUser')
$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"
$xml = New-WixPackageXml `
-AppName $appName `
-Manufacturer $manufacturer `
-ProductVersion $productVersion `
-UpgradeCode $upgradeCode `
-PublishDirectory $publishDirectory `
-ExecutablePath $executablePath `
-InstallScope $installScope `
-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..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + @($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
[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..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {
throw "WiX completed but installer exe was not produced: $exePath"
}
Write-Log -Level "OK" -Message " Windows installer ready: $exePath"
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'windowsInstallerPath' -Value $exePath -Overwrite Replace
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($exePath)
}
Export-ModuleMember -Function Invoke-Plugin

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@ -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))
$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)
}
}
}
$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
}
$publishSingleFile = $false
if ($pluginSettings.PSObject.Properties['publishSingleFile'] -and $null -ne $pluginSettings.publishSingleFile) {
$publishSingleFile = [bool]$pluginSettings.publishSingleFile
}
$additionalArguments = @()
if ($pluginSettings.PSObject.Properties['additionalPublishArguments'] -and $null -ne $pluginSettings.additionalPublishArguments) {
$additionalArguments = @($pluginSettings.additionalPublishArguments | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
$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
}
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."
$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 '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'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Value @($publishOutputs) -Overwrite Replace
}
Export-ModuleMember -Function Invoke-Plugin
Export-ModuleMember -Function Invoke-Plugin, Get-DotNetPublishRuntimeIdentifiers

View File

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

View File

@ -1,22 +0,0 @@
# CI builder image for .NET NuGet libraries: test (coverlet.MTP) + pack in one ENTRYPOINT.
#
# Setup in a product repo:
# 1. Copy to utils/engines/containerbuilder/{repo}-containerbuilder.Dockerfile
# 2. Customize test/pack .csproj paths below
# 3. Ensure src/global.json (solution root) sets test.runner to Microsoft.Testing.Platform
# 4. Reference the same dockerfile from engines/test and engines/release scriptSettings
# 5. Disable host DotNetTest and DotNetPack; enable CollectCoverage + DiscoverPackageArtifacts (release)
#
# Source injection: ContainerBuilder copies sourceContextPath into WORKDIR (/src).
# - Repo root (sourceContextPath ..\..\..): use paths like src/YourProject/YourProject.csproj
# - src folder (sourceContextPath ..\..\..\src): use paths like YourProject/YourProject.csproj
#
# Reference: maksit-nats (utils/engines/containerbuilder/maksit-nats-containerbuilder.Dockerfile)
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /src
ENV ARTIFACTS_DIR=/artifacts
RUN mkdir -p /artifacts /testResults
# Option B: COPY build/ci-dotnet-pack.sh /usr/local/bin/ci-pack.sh && chmod +x ... && ENTRYPOINT ["/usr/local/bin/ci-pack.sh"]
ENTRYPOINT ["sh", "-c", "dotnet test path/to/YourProject.Tests/YourProject.Tests.csproj -c Release --coverlet --coverlet-output-format cobertura --coverlet-include \"[MaksIT.*]*\" --results-directory /testResults && dotnet pack path/to/YourProject/YourProject.csproj -c Release -o /artifacts --nologo -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg"]

View File

@ -1,13 +0,0 @@
#!/bin/sh
set -eu
: "${ARTIFACTS_DIR:=/artifacts}"
mkdir -p "$ARTIFACTS_DIR" /testResults
# Customize test/pack .csproj paths (see templates/Dockerfile.ci.dotnet).
# Requires src/global.json (solution root) with test.runner = Microsoft.Testing.Platform and coverlet.MTP on the test project.
dotnet test path/to/YourProject.Tests/YourProject.Tests.csproj -c Release \
--coverlet --coverlet-output-format cobertura --coverlet-include "[MaksIT.*]*" \
--results-directory /testResults
dotnet pack path/to/YourProject/YourProject.csproj -c Release -o "$ARTIFACTS_DIR" --nologo \
-p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg