(feature): bulk footer actions, origami branding, and sibling release assets
10
CHANGELOG.md
@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.5.1] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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).
|
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.
|
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
|
## Commit format
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# MaksIT.ClusterConsole
|
# MaksIT.ClusterConsole
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@ -98,6 +98,8 @@ Tests use kubeconfig fixtures and do not require a live cluster. Coverage shield
|
|||||||
2. Tag `v{version}` on `main`.
|
2. Tag `v{version}` on `main`.
|
||||||
3. Run `utils\Invoke-ReleasePackage.bat`.
|
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
|
## Solution layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Version>0.5.1</Version>
|
<Version>0.6.0</Version>
|
||||||
<Product>MaksIT.ClusterConsole</Product>
|
<Product>MaksIT.ClusterConsole</Product>
|
||||||
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
53
src/MaksIT.ClusterConsole.Shared/ResourceActionBatch.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/MaksIT.ClusterConsole.Tests/ResourceActionBatchTests.cs
Normal 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 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 11 KiB |
@ -1,12 +1,23 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="MaksIT ClusterConsole">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Cluster Console">
|
||||||
<!-- Original MaksIT mark: console on a rounded tile. Not the Kubernetes wheel. -->
|
<defs>
|
||||||
<rect x="16" y="16" width="224" height="224" rx="48" fill="#2F3A8F"/>
|
<linearGradient id="bg" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
<rect x="40" y="52" width="176" height="152" rx="20" fill="#151A3A"/>
|
<stop offset="0" stop-color="#33A5CF"/>
|
||||||
<rect x="40" y="52" width="176" height="36" rx="20" fill="#2EC4B6"/>
|
<stop offset="1" stop-color="#002A6A"/>
|
||||||
<rect x="40" y="70" width="176" height="18" fill="#2EC4B6"/>
|
</linearGradient>
|
||||||
<circle cx="64" cy="70" r="7" fill="#151A3A"/>
|
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
|
||||||
<circle cx="86" cy="70" r="7" fill="#151A3A"/>
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
<circle cx="108" cy="70" r="7" fill="#151A3A"/>
|
<stop offset="1" stop-color="#C5E8F6"/>
|
||||||
<path d="M72 128 L100 148 L72 168" fill="none" stroke="#E8EEFF" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
</linearGradient>
|
||||||
<rect x="118" y="136" width="78" height="16" rx="4" fill="#2EC4B6"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 832 B After Width: | Height: | Size: 1.1 KiB |
@ -547,11 +547,13 @@
|
|||||||
<DataGrid Name="ResourceGrid"
|
<DataGrid Name="ResourceGrid"
|
||||||
ItemsSource="{Binding ActivePage.Rows}"
|
ItemsSource="{Binding ActivePage.Rows}"
|
||||||
SelectedItem="{Binding ActivePage.SelectedRow}"
|
SelectedItem="{Binding ActivePage.SelectedRow}"
|
||||||
|
SelectionMode="Extended"
|
||||||
AutoGenerateColumns="False"
|
AutoGenerateColumns="False"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
CanUserResizeColumns="True"
|
CanUserResizeColumns="True"
|
||||||
CanUserSortColumns="True"
|
CanUserSortColumns="True"
|
||||||
ClipboardCopyMode="IncludeHeader"
|
ClipboardCopyMode="IncludeHeader"
|
||||||
|
SelectionChanged="OnResourceGridSelectionChanged"
|
||||||
CopyingRowClipboardContent="OnResourceGridCopyingRowClipboardContent"
|
CopyingRowClipboardContent="OnResourceGridCopyingRowClipboardContent"
|
||||||
DoubleTapped="OnResourceGridDoubleTapped" />
|
DoubleTapped="OnResourceGridDoubleTapped" />
|
||||||
<GridSplitter Grid.Column="1" Classes="shell-split" ResizeDirection="Columns" />
|
<GridSplitter Grid.Column="1" Classes="shell-split" ResizeDirection="Columns" />
|
||||||
|
|||||||
@ -19,7 +19,7 @@ namespace MaksIT.ClusterConsole.UI;
|
|||||||
|
|
||||||
public partial class MainWindow : Window {
|
public partial class MainWindow : Window {
|
||||||
private LayoutPersistence? _layout;
|
private LayoutPersistence? _layout;
|
||||||
private ClusterPageViewModel? _logsPage;
|
private ClusterPageViewModel? _activePage;
|
||||||
|
|
||||||
public MainWindow() {
|
public MainWindow() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@ -41,9 +41,9 @@ public partial class MainWindow : Window {
|
|||||||
RebuildColumns(viewModel);
|
RebuildColumns(viewModel);
|
||||||
|
|
||||||
if (e.PropertyName == nameof(MainViewModel.ActivePage))
|
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.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel);
|
||||||
viewModel.VolumeFilesRequested += OpenVolumeFiles;
|
viewModel.VolumeFilesRequested += OpenVolumeFiles;
|
||||||
}
|
}
|
||||||
@ -53,13 +53,17 @@ public partial class MainWindow : Window {
|
|||||||
window.Show(this);
|
window.Show(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HookLogsPage(ClusterPageViewModel? page) {
|
private void HookActivePage(ClusterPageViewModel? page) {
|
||||||
if (_logsPage is not null)
|
if (_activePage is not null) {
|
||||||
_logsPage.PropertyChanged -= OnLogsPagePropertyChanged;
|
_activePage.PropertyChanged -= OnLogsPagePropertyChanged;
|
||||||
|
_activePage.SelectedRowsRestored -= OnSelectedRowsRestored;
|
||||||
|
}
|
||||||
|
|
||||||
_logsPage = page;
|
_activePage = page;
|
||||||
if (page is not null)
|
if (page is not null) {
|
||||||
page.PropertyChanged += OnLogsPagePropertyChanged;
|
page.PropertyChanged += OnLogsPagePropertyChanged;
|
||||||
|
page.SelectedRowsRestored += OnSelectedRowsRestored;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLogsPagePropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnLogsPagePropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
@ -83,6 +87,39 @@ public partial class MainWindow : Window {
|
|||||||
await viewModel.ConnectNamedAsync(connect);
|
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) {
|
private void OnResourceGridDoubleTapped(object? sender, TappedEventArgs e) {
|
||||||
if (e.Source is not Control { DataContext: ResourceRow })
|
if (e.Source is not Control { DataContext: ResourceRow })
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -2,20 +2,23 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls">
|
xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls">
|
||||||
<Styles.Resources>
|
<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="DataGridGridLinesBrush" Color="#33383e" />
|
||||||
<SolidColorBrush x:Key="DataGridColumnHeaderBackgroundBrush" Color="#1e2124" />
|
<SolidColorBrush x:Key="DataGridColumnHeaderBackgroundBrush" Color="#1e2124" />
|
||||||
<SolidColorBrush x:Key="DataGridColumnHeaderForegroundBrush" Color="#9aa0a6" />
|
<SolidColorBrush x:Key="DataGridColumnHeaderForegroundBrush" Color="#9aa0a6" />
|
||||||
<SolidColorBrush x:Key="DataGridColumnHeaderHoveredBackgroundBrush" Color="#2a2e33" />
|
<SolidColorBrush x:Key="DataGridColumnHeaderHoveredBackgroundBrush" Color="#2a2e33" />
|
||||||
<SolidColorBrush x:Key="DataGridColumnHeaderPressedBackgroundBrush" Color="#33383e" />
|
<SolidColorBrush x:Key="DataGridColumnHeaderPressedBackgroundBrush" Color="#33383e" />
|
||||||
<SolidColorBrush x:Key="DataGridRowHoveredBackgroundColor" Color="#2a2e33" />
|
<SolidColorBrush x:Key="DataGridRowHoveredBackgroundColor" Color="#2a2e33" />
|
||||||
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#3d5afe" />
|
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#006199" />
|
||||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#4d6bff" />
|
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#33A5CF" />
|
||||||
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#2f364d" />
|
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#16324C" />
|
||||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#3a4258" />
|
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#1A3A5C" />
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
<Style Selector="Window">
|
<Style Selector="Window">
|
||||||
<Setter Property="Background" Value="#1e2124" />
|
<Setter Property="Background" Value="#0E1620" />
|
||||||
<Setter Property="Foreground" Value="#e6e6e6" />
|
<Setter Property="Foreground" Value="#E8F4FC" />
|
||||||
<Setter Property="FontFamily" Value="Segoe UI, Ubuntu, sans-serif" />
|
<Setter Property="FontFamily" Value="Segoe UI, Ubuntu, sans-serif" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
</Style>
|
</Style>
|
||||||
@ -48,7 +51,7 @@
|
|||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.scope-toggle.selected">
|
<Style Selector="Button.scope-toggle.selected">
|
||||||
<Setter Property="Background" Value="#3d5afe" />
|
<Setter Property="Background" Value="#006199" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.nav-section">
|
<Style Selector="Button.nav-section">
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
@ -86,7 +89,7 @@
|
|||||||
<Setter Property="Background" Value="#2a2e33" />
|
<Setter Property="Background" Value="#2a2e33" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.nav-item.selected">
|
<Style Selector="Button.nav-item.selected">
|
||||||
<Setter Property="Background" Value="#3d5afe" />
|
<Setter Property="Background" Value="#006199" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.catalog-item">
|
<Style Selector="Button.catalog-item">
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
@ -100,7 +103,7 @@
|
|||||||
<Setter Property="Background" Value="#2a2e33" />
|
<Setter Property="Background" Value="#2a2e33" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.catalog-item.selected">
|
<Style Selector="Button.catalog-item.selected">
|
||||||
<Setter Property="Background" Value="#3d5afe" />
|
<Setter Property="Background" Value="#006199" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.catalog-close">
|
<Style Selector="Button.catalog-close">
|
||||||
<Setter Property="Padding" Value="6,2" />
|
<Setter Property="Padding" Value="6,2" />
|
||||||
@ -156,7 +159,7 @@
|
|||||||
<Setter Property="Stroke" Value="#7ee787" />
|
<Setter Property="Stroke" Value="#7ee787" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem:selected">
|
<Style Selector="ListBoxItem:selected">
|
||||||
<Setter Property="Background" Value="#3d5afe" />
|
<Setter Property="Background" Value="#006199" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="GridSplitter.shell-split">
|
<Style Selector="GridSplitter.shell-split">
|
||||||
<Setter Property="Width" Value="4" />
|
<Setter Property="Width" Value="4" />
|
||||||
@ -164,7 +167,7 @@
|
|||||||
<Setter Property="MinWidth" Value="4" />
|
<Setter Property="MinWidth" Value="4" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="GridSplitter.shell-split:pointerover">
|
<Style Selector="GridSplitter.shell-split:pointerover">
|
||||||
<Setter Property="Background" Value="#3d5afe" />
|
<Setter Property="Background" Value="#006199" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.details-toolbar">
|
<Style Selector="Border.details-toolbar">
|
||||||
<Setter Property="Background" Value="#1a1d20" />
|
<Setter Property="Background" Value="#1a1d20" />
|
||||||
@ -183,7 +186,7 @@
|
|||||||
<Setter Property="Background" Value="#25282c" />
|
<Setter Property="Background" Value="#25282c" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.chat-bubble.user">
|
<Style Selector="Border.chat-bubble.user">
|
||||||
<Setter Property="Background" Value="#3d5afe" />
|
<Setter Property="Background" Value="#006199" />
|
||||||
<Setter Property="Margin" Value="24,0,0,8" />
|
<Setter Property="Margin" Value="24,0,0,8" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.chat-bubble.assistant">
|
<Style Selector="Border.chat-bubble.assistant">
|
||||||
|
|||||||
@ -57,12 +57,20 @@ public partial class ClusterPageViewModel {
|
|||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AskAboutSelection() {
|
private void AskAboutSelection() {
|
||||||
if (SelectedRow is null) {
|
var rows = ActionTargets;
|
||||||
|
if (rows.Count == 0) {
|
||||||
ChatInput = "What is currently unhealthy in this cluster?";
|
ChatInput = "What is currently unhealthy in this cluster?";
|
||||||
return;
|
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}";
|
var container = SelectedContainer is null ? "" : $" container {SelectedContainer.Name}";
|
||||||
ChatInput = $"What is wrong with {SelectedDocumentKind ?? SelectedResourceRef()?.Kind ?? "this resource"} {name}{container}?";
|
ChatInput = $"What is wrong with {SelectedDocumentKind ?? SelectedResourceRef()?.Kind ?? "this resource"} {name}{container}?";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
private string? _rebindUid;
|
private string? _rebindUid;
|
||||||
private readonly List<double> _cpuHistory = [];
|
private readonly List<double> _cpuHistory = [];
|
||||||
private readonly List<double> _memoryHistory = [];
|
private readonly List<double> _memoryHistory = [];
|
||||||
|
private readonly List<string> _selectedUids = [];
|
||||||
|
private IReadOnlyList<ResourceRow> _selectedRows = [];
|
||||||
|
private bool _syncingSelection;
|
||||||
private const int HistoryPoints = 60;
|
private const int HistoryPoints = 60;
|
||||||
|
|
||||||
public ClusterPageViewModel(
|
public ClusterPageViewModel(
|
||||||
@ -90,6 +93,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ResourceRow? selectedRow;
|
private ResourceRow? selectedRow;
|
||||||
|
|
||||||
|
public IReadOnlyList<ResourceRow> SelectedRows =>
|
||||||
|
_selectedRows;
|
||||||
|
|
||||||
|
public event Action<IReadOnlyList<ResourceRow>>? SelectedRowsRestored;
|
||||||
|
|
||||||
|
public bool SyncingSelection =>
|
||||||
|
_syncingSelection;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private LimitRowViewModel? selectedLimitRow;
|
private LimitRowViewModel? selectedLimitRow;
|
||||||
|
|
||||||
@ -209,15 +220,8 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
public bool CanForceDelete =>
|
public bool CanForceDelete =>
|
||||||
CanDelete && SelectedDescriptor?.Kind != "Namespace";
|
CanDelete && SelectedDescriptor?.Kind != "Namespace";
|
||||||
|
|
||||||
public bool CanDeleteNamespace {
|
public bool CanDeleteNamespace =>
|
||||||
get {
|
SelectedDescriptor?.Kind == "Namespace" && DeletableNamespaceNames.Count > 0;
|
||||||
var name = TargetNamespaceName;
|
|
||||||
return HasSelectedRow
|
|
||||||
&& SelectedDescriptor?.Kind == "Namespace"
|
|
||||||
&& !string.IsNullOrEmpty(name)
|
|
||||||
&& !IsProtectedNamespace(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CanBrowseFiles =>
|
public bool CanBrowseFiles =>
|
||||||
HasSelectedRow
|
HasSelectedRow
|
||||||
@ -297,6 +301,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
public string DetailsTitle {
|
public string DetailsTitle {
|
||||||
get {
|
get {
|
||||||
|
var targets = ActionTargets;
|
||||||
|
if (targets.Count > 1)
|
||||||
|
return $"{targets.Count} {SelectedDescriptor?.Title ?? "items"} selected";
|
||||||
if (SelectedRow is null)
|
if (SelectedRow is null)
|
||||||
return "Select a resource";
|
return "Select a resource";
|
||||||
|
|
||||||
@ -448,9 +455,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyColumnFilters() =>
|
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 filters = _columnFilters.Values.Select(filter => filter.Model);
|
||||||
var desired = new List<ResourceRow>();
|
var desired = new List<ResourceRow>();
|
||||||
foreach (var row in _listedRows) {
|
foreach (var row in _listedRows) {
|
||||||
@ -460,6 +467,8 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
SortRows(desired);
|
SortRows(desired);
|
||||||
|
|
||||||
|
_syncingSelection = true;
|
||||||
|
try {
|
||||||
CollectionSync.MergeByKey(
|
CollectionSync.MergeByKey(
|
||||||
Rows,
|
Rows,
|
||||||
desired,
|
desired,
|
||||||
@ -467,15 +476,32 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
static (current, incoming) => current.CopyFrom(incoming),
|
static (current, incoming) => current.CopyFrom(incoming),
|
||||||
matchSourceOrder: true);
|
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))
|
if (SelectedRow is null || !Rows.Contains(SelectedRow))
|
||||||
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid);
|
SelectedRow = restored.FirstOrDefault();
|
||||||
else if (!IsDirty) {
|
ReplaceSelectedRows(restored);
|
||||||
|
SelectedRowsRestored?.Invoke(_selectedRows);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
_syncingSelection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SelectedRow is not null && !IsDirty) {
|
||||||
if (IsPodSelection)
|
if (IsPodSelection)
|
||||||
ApplyContainers(SelectedRow.Document);
|
ApplyContainers(SelectedRow.Document);
|
||||||
|
|
||||||
OverviewText = SelectedRow.FormatOverview(Containers);
|
OverviewText = SelectedRow.FormatOverview(Containers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (SelectedRow?.Uid != _detailsUid)
|
||||||
|
_ = LoadDetailsAsync();
|
||||||
|
|
||||||
var title = SelectedNavItem?.Title ?? "items";
|
var title = SelectedNavItem?.Title ?? "items";
|
||||||
var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count;
|
var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count;
|
||||||
_setStatus(filtered
|
_setStatus(filtered
|
||||||
@ -496,7 +522,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
NotifyDetailsUi();
|
NotifyDetailsUi();
|
||||||
_listedRows.Clear();
|
_listedRows.Clear();
|
||||||
Rows.Clear();
|
Rows.Clear();
|
||||||
SelectedRow = null;
|
ClearTableSelection();
|
||||||
|
|
||||||
if (!_syncingLayout) {
|
if (!_syncingLayout) {
|
||||||
var cfg = _configuration.Current;
|
var cfg = _configuration.Current;
|
||||||
@ -530,6 +556,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedRowChanged(ResourceRow? value) {
|
partial void OnSelectedRowChanged(ResourceRow? value) {
|
||||||
|
if (_syncingSelection)
|
||||||
|
return;
|
||||||
|
|
||||||
NotifyActionFlags();
|
NotifyActionFlags();
|
||||||
NotifyDetailsUi();
|
NotifyDetailsUi();
|
||||||
SyncRebindLocalPort();
|
SyncRebindLocalPort();
|
||||||
@ -613,7 +642,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
|
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
|
||||||
var keepUid = SelectedRow?.Uid;
|
var keepUids = SnapshotSelectedUids();
|
||||||
_listedRows.Clear();
|
_listedRows.Clear();
|
||||||
if (!listed.IsSuccess) {
|
if (!listed.IsSuccess) {
|
||||||
Rows.Clear();
|
Rows.Clear();
|
||||||
@ -626,7 +655,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
FilterFor(column.Header).LoadValues(_listedRows);
|
FilterFor(column.Header).LoadValues(_listedRows);
|
||||||
SeedNamespaceColumnFromSelection();
|
SeedNamespaceColumnFromSelection();
|
||||||
|
|
||||||
ApplyColumnFilters(keepUid);
|
ApplyColumnFilters(keepUids);
|
||||||
NotifyActionFlags();
|
NotifyActionFlags();
|
||||||
NotifyDetailsUi();
|
NotifyDetailsUi();
|
||||||
OnPropertyChanged(nameof(IsDataEditor));
|
OnPropertyChanged(nameof(IsDataEditor));
|
||||||
@ -701,7 +730,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_detailsUid = null;
|
_detailsUid = null;
|
||||||
SelectedRow = null;
|
ClearTableSelection();
|
||||||
_editDocument = YamlFormatter.ToJsonObject(ResourceDocument.NewTemplate(SelectedDescriptor, SelectedNamespace));
|
_editDocument = YamlFormatter.ToJsonObject(ResourceDocument.NewTemplate(SelectedDescriptor, SelectedNamespace));
|
||||||
_loadingDetails = true;
|
_loadingDetails = true;
|
||||||
YamlText = _editDocument is null ? "" : YamlFormatter.FromJson(_editDocument);
|
YamlText = _editDocument is null ? "" : YamlFormatter.FromJson(_editDocument);
|
||||||
@ -719,37 +748,39 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteAsync() {
|
private async Task DeleteAsync() {
|
||||||
if (_workspace.Session is null || SelectedRow is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var resource = SelectedResourceRef();
|
var resource = SelectedResourceRef();
|
||||||
if (resource is null || resource.Actions.CanDelete == false)
|
if (resource is null || resource.Actions.CanDelete == false)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var deleted = await _workspace.Session.DeleteAsync(resource.ToRef(), SelectedRow.Name, SelectedRow.Namespace);
|
var rows = ActionTargets;
|
||||||
_setStatus(deleted.IsSuccess
|
var owned = rows.Any(IsControllerOwned);
|
||||||
? ReplicaSetWarning(SelectedRow)
|
if (await RunOnSelectedAsync(row =>
|
||||||
: string.Join("; ", deleted.Messages));
|
_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();
|
await RefreshRowsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ForceDeleteAsync() {
|
private async Task ForceDeleteAsync() {
|
||||||
if (_workspace.Session is null || SelectedRow is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var resource = SelectedResourceRef();
|
var resource = SelectedResourceRef();
|
||||||
if (resource is null || resource.Actions.CanDelete == false)
|
if (resource is null || resource.Actions.CanDelete == false)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var deleted = await _workspace.Session.DeleteAsync(
|
var rows = ActionTargets;
|
||||||
resource.ToRef(),
|
var owned = rows.Any(IsControllerOwned);
|
||||||
SelectedRow.Name,
|
if (await RunOnSelectedAsync(row =>
|
||||||
SelectedRow.Namespace,
|
_workspace.Session!.DeleteAsync(resource.ToRef(), row.Name, row.Namespace, force: true)) is not { } outcome)
|
||||||
force: true);
|
return;
|
||||||
_setStatus(deleted.IsSuccess
|
|
||||||
? "Force-deleted. " + ReplicaSetWarning(SelectedRow)
|
var status = outcome.Format("Force-deleted.", $"Force-deleted {outcome.Total}.");
|
||||||
: string.Join("; ", deleted.Messages));
|
if (owned && outcome.Succeeded > 0)
|
||||||
|
status += " A controller may recreate this pod — open Namespaces and force-delete the sandbox namespace.";
|
||||||
|
_setStatus(status);
|
||||||
await RefreshRowsAsync();
|
await RefreshRowsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -758,15 +789,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
if (_workspace.Session is null)
|
if (_workspace.Session is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var name = TargetNamespaceName;
|
var names = DeletableNamespaceNames;
|
||||||
if (string.IsNullOrEmpty(name) || IsProtectedNamespace(name))
|
if (names.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var deleted = await _workspace.Session.ForceDeleteNamespaceAsync(name);
|
var rows = ActionTargets
|
||||||
_setStatus(deleted.IsSuccess
|
.Where(row => names.Contains(row.Name, StringComparer.Ordinal))
|
||||||
? $"Force-deleted namespace {name}."
|
.ToList();
|
||||||
: string.Join("; ", deleted.Messages));
|
var outcome = await ResourceActionBatch.RunAsync(
|
||||||
if (string.Equals(SelectedNamespace, name, StringComparison.Ordinal)) {
|
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;
|
SelectedNamespace = Configuration.AllNamespaces;
|
||||||
_columnFilters.Remove("Namespace");
|
_columnFilters.Remove("Namespace");
|
||||||
}
|
}
|
||||||
@ -774,8 +810,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
await RefreshRowsAsync();
|
await RefreshRowsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? TargetNamespaceName =>
|
private IReadOnlyList<string> DeletableNamespaceNames =>
|
||||||
SelectedDescriptor?.Kind == "Namespace" ? SelectedRow?.Name : null;
|
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) =>
|
private static bool IsProtectedNamespace(string name) =>
|
||||||
name.Equals("default", StringComparison.OrdinalIgnoreCase)
|
name.Equals("default", StringComparison.OrdinalIgnoreCase)
|
||||||
@ -783,78 +825,76 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|| name.Equals("kube-public", StringComparison.OrdinalIgnoreCase)
|
|| name.Equals("kube-public", StringComparison.OrdinalIgnoreCase)
|
||||||
|| name.Equals("kube-node-lease", 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 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>();
|
var kind = o["kind"]?.GetValue<string>();
|
||||||
return kind is "ReplicaSet" or "Deployment" or "StatefulSet" or "DaemonSet" or "Job";
|
return kind is "ReplicaSet" or "Deployment" or "StatefulSet" or "DaemonSet" or "Job";
|
||||||
}) == true;
|
}) == true;
|
||||||
return owned
|
|
||||||
? "Deleted. A controller may recreate this pod — open Namespaces and force-delete the sandbox namespace."
|
|
||||||
: "Deleted.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ScaleAsync() {
|
private async Task ScaleAsync() {
|
||||||
if (_workspace.Session is null || SelectedRow is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var resource = SelectedResourceRef();
|
var resource = SelectedResourceRef();
|
||||||
if (resource is null || resource.Actions.CanScale == false)
|
if (resource is null || resource.Actions.CanScale == false)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var scaled = await _workspace.Session.ScaleAsync(resource.ToRef(), SelectedRow.Name, SelectedRow.Namespace, ScaleReplicas);
|
var replicas = ScaleReplicas;
|
||||||
_setStatus(scaled.IsSuccess ? $"Scaled to {ScaleReplicas}." : string.Join("; ", scaled.Messages));
|
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();
|
await RefreshRowsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task RestartAsync() {
|
private async Task RestartAsync() {
|
||||||
if (_workspace.Session is null || SelectedRow is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var resource = SelectedResourceRef();
|
var resource = SelectedResourceRef();
|
||||||
if (resource is null || resource.Actions.CanRestart == false)
|
if (resource is null || resource.Actions.CanRestart == false)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var restarted = await _workspace.Session.RestartAsync(resource.ToRef(), SelectedRow.Name, SelectedRow.Namespace);
|
if (await RunOnSelectedAsync(row =>
|
||||||
_setStatus(restarted.IsSuccess ? "Restarted." : string.Join("; ", restarted.Messages));
|
_workspace.Session!.RestartAsync(resource.ToRef(), row.Name, row.Namespace)) is not { } outcome)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_setStatus(outcome.Format("Restarted.", $"Restarted {outcome.Total}."));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CordonAsync() {
|
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;
|
return;
|
||||||
|
|
||||||
var result = await _workspace.Session.CordonAsync(SelectedRow.Name, true);
|
_setStatus(outcome.Format("Cordoned.", $"Cordoned {outcome.Total}."));
|
||||||
_setStatus(result.IsSuccess ? "Cordoned." : string.Join("; ", result.Messages));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task UncordonAsync() {
|
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;
|
return;
|
||||||
|
|
||||||
var result = await _workspace.Session.CordonAsync(SelectedRow.Name, false);
|
_setStatus(outcome.Format("Uncordoned.", $"Uncordoned {outcome.Total}."));
|
||||||
_setStatus(result.IsSuccess ? "Uncordoned." : string.Join("; ", result.Messages));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DrainAsync() {
|
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;
|
return;
|
||||||
|
|
||||||
var result = await _workspace.Session.DrainAsync(SelectedRow.Name);
|
_setStatus(outcome.Format("Drain requested.", $"Drain requested for {outcome.Total}."));
|
||||||
_setStatus(result.IsSuccess ? "Drain requested." : string.Join("; ", result.Messages));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task TriggerCronAsync() {
|
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;
|
return;
|
||||||
|
|
||||||
var result = await _workspace.Session.TriggerCronJobAsync(SelectedRow.Name, SelectedRow.Namespace ?? "default");
|
_setStatus(outcome.Format("Job created.", $"Created {outcome.Total} jobs."));
|
||||||
_setStatus(result.IsSuccess ? "Job created." : string.Join("; ", result.Messages));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@ -924,21 +964,42 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void StopSelectedPortForward() {
|
private void StopSelectedPortForward() {
|
||||||
if (SelectedRow is null)
|
var rows = ActionTargets;
|
||||||
|
if (rows.Count == 0)
|
||||||
return;
|
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) {
|
if (live is not null) {
|
||||||
StopPortForward(live);
|
var port = live.Handle.LocalPort;
|
||||||
return;
|
live.Handle.Dispose();
|
||||||
|
PortForwards.Remove(live);
|
||||||
|
PersistStopped(port);
|
||||||
|
stoppedPorts.Add(port);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!PortForwardRow.TryLocalPort(SelectedRow, out var localPort))
|
if (!PortForwardRow.TryLocalPort(row, out var localPort)) {
|
||||||
return;
|
failures.Add($"{row.Name}: not a port-forward");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
PersistStopped(localPort);
|
PersistStopped(localPort);
|
||||||
|
stoppedPorts.Add(localPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPortForwardingView)
|
||||||
ShowPortForwardRows();
|
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]
|
[RelayCommand]
|
||||||
@ -1579,7 +1640,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ShowPortForwardRows(string? keepUid = null) {
|
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();
|
var livePorts = PortForwards.Select(item => item.Handle.LocalPort).ToHashSet();
|
||||||
_listedRows.Clear();
|
_listedRows.Clear();
|
||||||
foreach (var item in PortForwards)
|
foreach (var item in PortForwards)
|
||||||
@ -1595,7 +1658,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
foreach (var column in ResourceCatalog.PortForwardingDescriptor.Columns)
|
foreach (var column in ResourceCatalog.PortForwardingDescriptor.Columns)
|
||||||
FilterFor(column.Header).LoadValues(_listedRows);
|
FilterFor(column.Header).LoadValues(_listedRows);
|
||||||
|
|
||||||
ApplyColumnFilters(keepUid);
|
ApplyColumnFilters(keepUids);
|
||||||
NotifyActionFlags();
|
NotifyActionFlags();
|
||||||
NotifyDetailsUi();
|
NotifyDetailsUi();
|
||||||
}
|
}
|
||||||
@ -1933,6 +1996,38 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
return SelectedDescriptor?.DetailTabs.Contains(tab) == true;
|
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() {
|
private void NotifyActionFlags() {
|
||||||
OnPropertyChanged(nameof(CanDelete));
|
OnPropertyChanged(nameof(CanDelete));
|
||||||
OnPropertyChanged(nameof(CanForceDelete));
|
OnPropertyChanged(nameof(CanForceDelete));
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft-07/schema",
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
"title": "MaksIT.ClusterConsole Release Script Settings",
|
"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": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "DotNetReleaseVersion",
|
"name": "DotNetReleaseVersion",
|
||||||
@ -35,13 +35,51 @@
|
|||||||
"projectFiles": [
|
"projectFiles": [
|
||||||
"..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\MaksIT.ClusterConsole.UI.csproj"
|
"..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\MaksIT.ClusterConsole.UI.csproj"
|
||||||
],
|
],
|
||||||
"artifactsDir": "..\\..\\..\\releases"
|
"artifactsDir": "..\\..\\..\\releases",
|
||||||
|
"runtimeIdentifiers": [
|
||||||
|
"win-x64",
|
||||||
|
"linux-x64"
|
||||||
|
],
|
||||||
|
"selfContained": true,
|
||||||
|
"publishSingleFile": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DotNetCreateArchive",
|
"name": "DotNetCreateArchive",
|
||||||
"stageLabel": "build",
|
"stageLabel": "build",
|
||||||
"enabled": true,
|
"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",
|
"name": "ReleasePublishGuard",
|
||||||
@ -64,33 +102,20 @@
|
|||||||
"githubSecret": "GitHub",
|
"githubSecret": "GitHub",
|
||||||
"repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console",
|
"repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console",
|
||||||
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
|
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
|
||||||
"releaseTitlePattern": "Release {version}"
|
"releaseTitlePattern": "Release {version}",
|
||||||
|
"requireReleaseAssets": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DotNetNuGet",
|
"name": "CleanupArtifacts",
|
||||||
"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",
|
|
||||||
"stageLabel": "release",
|
"stageLabel": "release",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"includePatterns": [
|
"includePatterns": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
"excludePatterns": [
|
"excludePatterns": [
|
||||||
"*.zip"
|
"*.zip",
|
||||||
|
"*.exe",
|
||||||
|
"*.flatpak"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -321,6 +321,37 @@ function Get-EngineState {
|
|||||||
return $null
|
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 `
|
Export-ModuleMember -Function `
|
||||||
Resolve-RelativePaths, `
|
Resolve-RelativePaths, `
|
||||||
Initialize-EngineFactsBag, `
|
Initialize-EngineFactsBag, `
|
||||||
@ -329,4 +360,5 @@ Export-ModuleMember -Function `
|
|||||||
Test-EngineFact, `
|
Test-EngineFact, `
|
||||||
Set-EngineState, `
|
Set-EngineState, `
|
||||||
Add-EnginePublishCompletion, `
|
Add-EnginePublishCompletion, `
|
||||||
|
Add-ReleaseAssetPaths, `
|
||||||
Get-EngineState
|
Get-EngineState
|
||||||
|
|||||||
@ -54,7 +54,7 @@ function Get-PluginDependencyGroupDirectories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
|
# 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()
|
$dirs = [System.Collections.Generic.List[string]]::new()
|
||||||
foreach ($name in $preferred) {
|
foreach ($name in $preferred) {
|
||||||
$path = Join-Path $PluginsRoot $name
|
$path = Join-Path $PluginsRoot $name
|
||||||
@ -597,7 +597,7 @@ function Resolve-PluginModulePath {
|
|||||||
$candidatePaths = [System.Collections.Generic.List[string]]::new()
|
$candidatePaths = [System.Collections.Generic.List[string]]::new()
|
||||||
$candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName))
|
$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))
|
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName))
|
||||||
|
|
||||||
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
|
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
|
||||||
|
|||||||
@ -107,7 +107,7 @@ function Invoke-ExternalCommand {
|
|||||||
if ($ThrowOnError -and $exitCode -ne 0) {
|
if ($ThrowOnError -and $exitCode -ne 0) {
|
||||||
$preview = ($output | ForEach-Object {
|
$preview = ($output | ForEach-Object {
|
||||||
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ }
|
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)) {
|
if ([string]::IsNullOrWhiteSpace($preview)) {
|
||||||
throw "External command '$Name' failed with exit code $exitCode."
|
throw "External command '$Name' failed with exit code $exitCode."
|
||||||
}
|
}
|
||||||
|
|||||||
931
utils/plugins/Desktop/DesktopPackSupport.psm1
Normal 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
|
||||||
445
utils/plugins/Desktop/FlatpakPack.psm1
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
#requires -Version 7.0
|
||||||
|
#requires -PSEdition Core
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Linux Flatpak pack plugin for published .NET desktop apps (Community).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Stages a linux-* DotNetPublish folder into a Flatpak layout (manifest,
|
||||||
|
desktop file, metainfo, launch script). Prefers Flathub org.flatpak.Builder
|
||||||
|
(current builder) over distro flatpak-builder. On Windows, native builder
|
||||||
|
is tried first; if missing, the plugin builds via a pinned WSL distro
|
||||||
|
(default Debian) on the Linux filesystem, then copies the bundle back.
|
||||||
|
Docker Desktop WSL distros are never used. whenBuilderMissing skip|fail
|
||||||
|
applies when neither native nor WSL builder is available.
|
||||||
|
#>
|
||||||
|
|
||||||
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
|
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||||
|
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
|
||||||
|
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
|
||||||
|
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-FlatpakWhenBuilderMissing {
|
||||||
|
param($PluginSettings)
|
||||||
|
|
||||||
|
$value = [string](Get-PluginPropertyValue -PluginSettings $PluginSettings -Name 'whenBuilderMissing' -Default 'skip')
|
||||||
|
if ($value -notin @('skip', 'fail')) {
|
||||||
|
throw "FlatpakPack whenBuilderMissing must be 'skip' or 'fail'."
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-FlatpakBuilderMissingException {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[System.Management.Automation.ErrorRecord]$ErrorRecord
|
||||||
|
)
|
||||||
|
|
||||||
|
$message = [string]$ErrorRecord.Exception.Message
|
||||||
|
if ($message -match '(?i)bash: line \d+') {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ErrorRecord.Exception -is [System.Management.Automation.CommandNotFoundException]) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($message -match '(?i)(not recognized|command not found|The term .+ is not recognized|marked unavailable)') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-WslFallbackSkippable {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[System.Management.Automation.ErrorRecord]$ErrorRecord
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-FlatpakBuilderMissingException -ErrorRecord $ErrorRecord) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = [string]$ErrorRecord.Exception.Message
|
||||||
|
if ($message -match "(?i)WSL distro '.+' is not installed") {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WslInstalledDistroNames {
|
||||||
|
$output = Invoke-ExternalCommand -Name wsl -ArgumentList @('-l', '-q') -ThrowOnError:$false
|
||||||
|
return @(ConvertFrom-WslListOutput -InputObject $output)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-FlatpakBundleViaWsl {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Distro,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsStageRoot,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WindowsBundlePath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$AppId
|
||||||
|
)
|
||||||
|
|
||||||
|
Assert-WslDistroAllowedForFlatpak -Distro $Distro
|
||||||
|
$installed = @(Get-WslInstalledDistroNames)
|
||||||
|
if (-not (Test-WslDistroInstalled -Distro $Distro -InstalledNames $installed)) {
|
||||||
|
throw "WSL distro '$Distro' is not installed. Install Debian with: wsl --install Debian --no-launch --web-download"
|
||||||
|
}
|
||||||
|
|
||||||
|
$script = ConvertTo-UnixLineEndings -Value (
|
||||||
|
New-WslFlatpakBuildScript `
|
||||||
|
-WindowsStageRoot $WindowsStageRoot `
|
||||||
|
-WindowsBundlePath $WindowsBundlePath `
|
||||||
|
-AppId $AppId
|
||||||
|
)
|
||||||
|
if (-not $script.EndsWith("`n")) {
|
||||||
|
$script += "`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempScript = Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-flatpak-" + [guid]::NewGuid().ToString('N') + ".sh")
|
||||||
|
try {
|
||||||
|
[System.IO.File]::WriteAllBytes($tempScript, [System.Text.UTF8Encoding]::new($false).GetBytes($script))
|
||||||
|
$wslScript = ConvertTo-WslMountPath -WindowsPath $tempScript
|
||||||
|
Invoke-ExternalCommand -Name wsl -ArgumentList @('-d', $Distro, '--', 'bash', $wslScript) | Out-Null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $tempScript -PathType Leaf) {
|
||||||
|
Remove-Item -LiteralPath $tempScript -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-HostFlatpakBuilder {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$RepoDir,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$BuildDir,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ManifestPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$hasFlathubBuilder = $false
|
||||||
|
try {
|
||||||
|
Invoke-ExternalCommand -Name flatpak -ArgumentList @('info', 'org.flatpak.Builder') -ThrowOnError:$false | Out-Null
|
||||||
|
$hasFlathubBuilder = ($global:LASTEXITCODE -eq 0)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if (-not (Test-FlatpakBuilderMissingException -ErrorRecord $_)) {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasFlathubBuilder) {
|
||||||
|
$stageRoot = [System.IO.Path]::GetDirectoryName($ManifestPath)
|
||||||
|
Invoke-ExternalCommand -Name flatpak -ArgumentList @(
|
||||||
|
'run',
|
||||||
|
'--filesystem=host',
|
||||||
|
'--share=network',
|
||||||
|
"--cwd=$stageRoot",
|
||||||
|
'org.flatpak.Builder',
|
||||||
|
'--disable-rofiles-fuse',
|
||||||
|
"--repo=$RepoDir",
|
||||||
|
'--force-clean',
|
||||||
|
$BuildDir,
|
||||||
|
$ManifestPath
|
||||||
|
) | Out-Null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ExternalCommand -Name flatpak-builder -ArgumentList @(
|
||||||
|
'--disable-rofiles-fuse',
|
||||||
|
"--repo=$RepoDir",
|
||||||
|
'--force-clean',
|
||||||
|
$BuildDir,
|
||||||
|
$ManifestPath
|
||||||
|
) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Plugin {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
$Settings
|
||||||
|
)
|
||||||
|
|
||||||
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
|
||||||
|
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
|
||||||
|
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-WslFlatpakBuildScript"
|
||||||
|
|
||||||
|
$pluginSettings = $Settings
|
||||||
|
$sharedSettings = $Settings.context
|
||||||
|
$scriptDir = $sharedSettings.scriptDir
|
||||||
|
$version = [string]$sharedSettings.version
|
||||||
|
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||||
|
|
||||||
|
$appId = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appId')
|
||||||
|
$appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName')
|
||||||
|
if ([string]::IsNullOrWhiteSpace($appId)) {
|
||||||
|
throw "FlatpakPack plugin requires appId (reverse-DNS, e.g. com.maks_it.Wvc210)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-FlatpakAppId -AppId $appId
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($appName)) {
|
||||||
|
throw "FlatpakPack plugin requires appName."
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'command')
|
||||||
|
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
|
||||||
|
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'linux-x64')
|
||||||
|
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
|
||||||
|
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
|
||||||
|
$summary = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'summary' -Default $appName)
|
||||||
|
$projectLicense = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'projectLicense' -Default 'MIT')
|
||||||
|
$categories = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'categories' -Default 'Utility;')
|
||||||
|
$runtime = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtime' -Default 'org.freedesktop.Platform')
|
||||||
|
$runtimeVersion = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeVersion' -Default '24.08')
|
||||||
|
$sdk = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sdk' -Default 'org.freedesktop.Sdk')
|
||||||
|
$whenBuilderMissing = Get-FlatpakWhenBuilderMissing -PluginSettings $pluginSettings
|
||||||
|
$wslDistro = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'wslDistro' -Default 'Debian')
|
||||||
|
$useWslSetting = Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'useWsl' -Default $null
|
||||||
|
$useWsl = if ($null -eq $useWslSetting) { [bool]$IsWindows } else { [bool]$useWslSetting }
|
||||||
|
$buildBundle = $true
|
||||||
|
if ($pluginSettings.PSObject.Properties['buildBundle'] -and $null -ne $pluginSettings.buildBundle) {
|
||||||
|
$buildBundle = [bool]$pluginSettings.buildBundle
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachSourceZip = $false
|
||||||
|
if ($pluginSettings.PSObject.Properties['attachSourceZip'] -and $null -ne $pluginSettings.attachSourceZip) {
|
||||||
|
$attachSourceZip = [bool]$pluginSettings.attachSourceZip
|
||||||
|
}
|
||||||
|
|
||||||
|
$finishArgs = @(Get-DefaultFlatpakFinishArgs)
|
||||||
|
if ($pluginSettings.PSObject.Properties['finishArgs'] -and $null -ne $pluginSettings.finishArgs) {
|
||||||
|
$finishArgs = @($pluginSettings.finishArgs | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
|
||||||
|
throw "FlatpakPack plugin requires an artifacts directory in the shared context."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $artifactsDirectory -PathType Container)) {
|
||||||
|
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$publishDirectory = Resolve-DesktopPublishDirectory `
|
||||||
|
-Context $sharedSettings `
|
||||||
|
-RuntimeIdentifier $runtimeIdentifier `
|
||||||
|
-PublishDir $publishDirSetting `
|
||||||
|
-ScriptDir $scriptDir
|
||||||
|
|
||||||
|
$executablePath = Resolve-DesktopExecutablePath `
|
||||||
|
-PublishDirectory $publishDirectory `
|
||||||
|
-ExecutableName $executableName
|
||||||
|
$executableFileName = [System.IO.Path]::GetFileName($executablePath)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($command)) {
|
||||||
|
$command = $executableFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
$moduleName = ($appId.Split('.') | Select-Object -Last 1).ToLowerInvariant()
|
||||||
|
$stageRoot = Join-Path $artifactsDirectory "flatpak-$moduleName"
|
||||||
|
if (Test-Path -LiteralPath $stageRoot) {
|
||||||
|
Remove-Item -LiteralPath $stageRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$filesRoot = Join-Path $stageRoot 'files'
|
||||||
|
$libDir = Join-Path $filesRoot "lib\$moduleName"
|
||||||
|
$binDir = Join-Path $filesRoot 'bin'
|
||||||
|
$shareAppDir = Join-Path $filesRoot 'share\applications'
|
||||||
|
$shareMetaDir = Join-Path $filesRoot 'share\metainfo'
|
||||||
|
$shareIconDir = Join-Path $filesRoot 'share\icons\hicolor\256x256\apps'
|
||||||
|
New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $shareIconDir -Force | Out-Null
|
||||||
|
|
||||||
|
Write-Log -Level "STEP" -Message "Staging Flatpak layout from $publishDirectory"
|
||||||
|
Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $libDir -Recurse -Force
|
||||||
|
|
||||||
|
$launchScript = New-FlatpakLaunchScript -ModuleName $moduleName -ExecutableFileName $executableFileName
|
||||||
|
$launchPath = Join-Path $binDir $command
|
||||||
|
[System.IO.File]::WriteAllText($launchPath, $launchScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$desktopPath = Join-Path $shareAppDir "$appId.desktop"
|
||||||
|
[System.IO.File]::WriteAllText(
|
||||||
|
$desktopPath,
|
||||||
|
(New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"),
|
||||||
|
[System.Text.UTF8Encoding]::new($false)
|
||||||
|
)
|
||||||
|
|
||||||
|
$metaPath = Join-Path $shareMetaDir "$appId.metainfo.xml"
|
||||||
|
[System.IO.File]::WriteAllText(
|
||||||
|
$metaPath,
|
||||||
|
(New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -ProjectLicense $projectLicense).Replace("`r`n", "`n"),
|
||||||
|
[System.Text.UTF8Encoding]::new($false)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($iconSetting)) {
|
||||||
|
$iconPath = if ([System.IO.Path]::IsPathRooted($iconSetting)) {
|
||||||
|
$iconSetting
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $iconSetting))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
|
||||||
|
throw "FlatpakPack iconPath not found: $iconPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([System.IO.Path]::GetExtension($iconPath) -ine '.png') {
|
||||||
|
Write-Log -Level "WARN" -Message " Flatpak icon should be PNG; skipping copy of $iconPath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $shareIconDir "$appId.png") -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildCommands = @(
|
||||||
|
'mkdir -p /app/lib /app/share',
|
||||||
|
"cp -a lib/$moduleName /app/lib/$moduleName",
|
||||||
|
'cp -a share /app/share',
|
||||||
|
"install -Dm755 bin/$command /app/bin/$command",
|
||||||
|
"chmod +x /app/lib/$moduleName/$executableFileName"
|
||||||
|
)
|
||||||
|
|
||||||
|
$manifest = New-FlatpakManifestObject `
|
||||||
|
-AppId $appId `
|
||||||
|
-Command $command `
|
||||||
|
-ModuleName $moduleName `
|
||||||
|
-Runtime $runtime `
|
||||||
|
-RuntimeVersion $runtimeVersion `
|
||||||
|
-Sdk $sdk `
|
||||||
|
-FinishArgs $finishArgs `
|
||||||
|
-BuildCommands $buildCommands
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $stageRoot 'manifest.json'
|
||||||
|
$manifestJson = $manifest | ConvertTo-Json -Depth 8
|
||||||
|
[System.IO.File]::WriteAllText($manifestPath, $manifestJson, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$bundleNamePattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'bundleNamePattern' -Default '{name}-{version}.flatpak')
|
||||||
|
$safeName = ($appName -replace '[^A-Za-z0-9._-]', '-').Trim('-')
|
||||||
|
$bundleFileName = $bundleNamePattern.Replace('{version}', $version).Replace('{id}', $appId).Replace('{name}', $safeName)
|
||||||
|
$bundlePath = Join-Path $artifactsDirectory $bundleFileName
|
||||||
|
|
||||||
|
$builderBlock = (Get-FlatpakBuilderShellLines) -join "`n"
|
||||||
|
$buildScript = @"
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "`$(dirname "`$0")"
|
||||||
|
$builderBlock
|
||||||
|
flatpak build-bundle "`$ROOT/repo" "../$bundleFileName" "$appId"
|
||||||
|
"@
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $stageRoot 'build-flatpak.sh'), $buildScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$assetPaths = [System.Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
|
if ($attachSourceZip) {
|
||||||
|
$sourceZipPattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sourceZipNamePattern' -Default '{id}-{version}-flatpak-src.zip')
|
||||||
|
$sourceZipName = $sourceZipPattern.Replace('{version}', $version).Replace('{id}', $appId).Replace('{name}', $safeName)
|
||||||
|
$sourceZipPath = Join-Path $artifactsDirectory $sourceZipName
|
||||||
|
if (Test-Path -LiteralPath $sourceZipPath -PathType Leaf) {
|
||||||
|
Remove-Item -LiteralPath $sourceZipPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Compress-Archive -Path $stageRoot -DestinationPath $sourceZipPath -CompressionLevel Optimal -Force
|
||||||
|
Write-Log -Level "OK" -Message " Flatpak source archive: $sourceZipPath"
|
||||||
|
$assetPaths.Add($sourceZipPath)
|
||||||
|
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'flatpakSourcePath' -Value $sourceZipPath -Overwrite Replace
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buildBundle) {
|
||||||
|
Write-Log -Level "STEP" -Message "Building Flatpak bundle..."
|
||||||
|
$buildDir = Join-Path $stageRoot 'build-dir'
|
||||||
|
$repoDir = Join-Path $stageRoot 'repo'
|
||||||
|
$built = $false
|
||||||
|
try {
|
||||||
|
Invoke-HostFlatpakBuilder -RepoDir $repoDir -BuildDir $buildDir -ManifestPath $manifestPath
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $bundlePath -PathType Leaf) {
|
||||||
|
Remove-Item -LiteralPath $bundlePath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ExternalCommand -Name flatpak -ArgumentList @(
|
||||||
|
'build-bundle',
|
||||||
|
$repoDir,
|
||||||
|
$bundlePath,
|
||||||
|
$appId
|
||||||
|
) | Out-Null
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) {
|
||||||
|
throw "flatpak build-bundle completed but bundle was not produced: $bundlePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$built = $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if (-not (Test-FlatpakBuilderMissingException -ErrorRecord $_)) {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log -Level "INFO" -Message " Native Flatpak builder not available (Flathub org.flatpak.Builder or distro flatpak-builder)."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $built -and $useWsl -and $IsWindows) {
|
||||||
|
try {
|
||||||
|
Write-Log -Level "STEP" -Message "Building Flatpak bundle via WSL distro $wslDistro (Linux filesystem, not Docker Desktop)..."
|
||||||
|
if (Test-Path -LiteralPath $bundlePath -PathType Leaf) {
|
||||||
|
Remove-Item -LiteralPath $bundlePath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-FlatpakBundleViaWsl `
|
||||||
|
-Distro $wslDistro `
|
||||||
|
-WindowsStageRoot $stageRoot `
|
||||||
|
-WindowsBundlePath $bundlePath `
|
||||||
|
-AppId $appId
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) {
|
||||||
|
throw "WSL flatpak build-bundle completed but bundle was not produced: $bundlePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$built = $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($whenBuilderMissing -eq 'skip' -and (Test-WslFallbackSkippable -ErrorRecord $_)) {
|
||||||
|
Write-Log -Level "WARN" -Message " WSL Flatpak builder not available ($($_.Exception.Message)); skipped bundle."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($built) {
|
||||||
|
Write-Log -Level "OK" -Message " Flatpak bundle ready: $bundlePath"
|
||||||
|
$assetPaths.Add($bundlePath)
|
||||||
|
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'flatpakBundlePath' -Value $bundlePath -Overwrite Replace
|
||||||
|
}
|
||||||
|
elseif ($whenBuilderMissing -eq 'fail') {
|
||||||
|
throw "Flatpak builder is not available (Flathub org.flatpak.Builder, distro flatpak-builder, or WSL distro $wslDistro)."
|
||||||
|
}
|
||||||
|
elseif (-not $useWsl -or -not $IsWindows) {
|
||||||
|
Write-Log -Level "WARN" -Message " Flatpak builder not available; skipped bundle. Install Flathub org.flatpak.Builder (preferred) or distro flatpak-builder."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
||||||
|
if ($assetPaths.Count -gt 0) {
|
||||||
|
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($assetPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Invoke-Plugin
|
||||||
294
utils/plugins/Desktop/WindowsInstaller.psm1
Normal 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
|
||||||
139
utils/plugins/Desktop/brand/HyperlinkSidebarTheme.xml
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. -->
|
||||||
|
|
||||||
|
|
||||||
|
<Theme xmlns="http://wixtoolset.org/schemas/v4/thmutil">
|
||||||
|
<Font Id="0" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
|
||||||
|
<Font Id="1" Height="-24" Weight="500" Foreground="windowtext">Segoe UI</Font>
|
||||||
|
<Font Id="2" Height="-22" Weight="500" Foreground="graytext">Segoe UI</Font>
|
||||||
|
<Font Id="3" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
|
||||||
|
|
||||||
|
<Window Width="600" Height="450" HexStyle="100a0000" FontId="0" Caption="#(loc.Caption)">
|
||||||
|
<Page Name="Help">
|
||||||
|
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
|
||||||
|
<Label X="11" Y="80" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.HelpHeader)</Label>
|
||||||
|
<Label X="11" Y="121" Width="-11" Height="-35" FontId="3" DisablePrefix="yes">#(loc.HelpText)</Label>
|
||||||
|
<Button Name="HelpCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.HelpCloseButton)</Text>
|
||||||
|
<CloseWindowAction />
|
||||||
|
</Button>
|
||||||
|
</Page>
|
||||||
|
<Page Name="Loading">
|
||||||
|
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes" Visible="no" Name="CheckingForUpdatesLabel" />
|
||||||
|
</Page>
|
||||||
|
<Page Name="Install">
|
||||||
|
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
|
||||||
|
<Label X="185" Y="50" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.InstallHeader)</Label>
|
||||||
|
<Label X="185" Y="91" Width="-11" Height="40" FontId="3" DisablePrefix="yes">
|
||||||
|
<Text Condition="WixStdBASuppressOptionsUI">#(loc.InstallMessage)</Text>
|
||||||
|
<Text Condition="NOT WixStdBASuppressOptionsUI">#(loc.InstallMessageOptions)</Text>
|
||||||
|
</Label>
|
||||||
|
<Label X="185" Y="138" Width="-11" Height="17" FontId="3">#(loc.OptionsLocationLabel)</Label>
|
||||||
|
<Editbox Name="InstallFolder" X="185" Y="160" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" />
|
||||||
|
<Button Name="InstallFolderBrowseButton" X="-11" Y="159" Width="75" Height="23" TabStop="yes" FontId="3">
|
||||||
|
<Text>#(loc.OptionsBrowseButton)</Text>
|
||||||
|
<BrowseDirectoryAction VariableName="InstallFolder" />
|
||||||
|
</Button>
|
||||||
|
<Checkbox Name="InstallDesktopShortcut" X="185" Y="192" Width="-11" Height="17" TabStop="yes" FontId="3">Place a shortcut on the desktop</Checkbox>
|
||||||
|
<Hypertext Name="EulaHyperlink" X="185" Y="-111" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallLicenseLinkText)</Hypertext>
|
||||||
|
<Label Name="InstallVersion" X="185" Y="-81" Width="-11" Height="17" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBAShowVersion">#(loc.InstallVersion)</Label>
|
||||||
|
<Checkbox Name="EulaAcceptCheckbox" X="185" Y="-51" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallAcceptCheckbox)</Checkbox>
|
||||||
|
<Button Name="InstallUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
|
||||||
|
<Button Name="OptionsButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" VisibleCondition="NOT WixStdBASuppressOptionsUI">
|
||||||
|
<Text>#(loc.InstallOptionsButton)</Text>
|
||||||
|
<ChangePageAction Page="Options" />
|
||||||
|
</Button>
|
||||||
|
<Button Name="InstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.InstallInstallButton)</Button>
|
||||||
|
<Button Name="InstallCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.InstallCancelButton)</Text>
|
||||||
|
<CloseWindowAction />
|
||||||
|
</Button>
|
||||||
|
</Page>
|
||||||
|
<Page Name="Options">
|
||||||
|
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
|
||||||
|
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.OptionsHeader)</Label>
|
||||||
|
<RadioButtons Name="WixStdBAScope">
|
||||||
|
<RadioButton X="11" Y="180" Height="20" Width="-11" Value="PerUser" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerUserScopeText)</RadioButton>
|
||||||
|
<RadioButton X="11" Y="200" Height="20" Width="-11" Value="PerMachine" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerMachineScopeText)</RadioButton>
|
||||||
|
</RadioButtons>
|
||||||
|
<Button Name="OptionsOkButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.OptionsOkButton)</Text>
|
||||||
|
<ChangePageAction Page="Install" />
|
||||||
|
</Button>
|
||||||
|
<Button Name="OptionsCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.OptionsCancelButton)</Text>
|
||||||
|
<ChangePageAction Page="Install" Cancel="yes" />
|
||||||
|
</Button>
|
||||||
|
</Page>
|
||||||
|
<Page Name="Progress">
|
||||||
|
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
|
||||||
|
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ProgressHeader)</Label>
|
||||||
|
<Label X="11" Y="141" Width="70" Height="17" FontId="3" DisablePrefix="yes">#(loc.ProgressLabel)</Label>
|
||||||
|
<Label Name="OverallProgressPackageText" X="85" Y="141" Width="-11" Height="17" FontId="3" DisablePrefix="yes">#(loc.OverallProgressPackageText)</Label>
|
||||||
|
<Progressbar Name="OverallCalculatedProgressbar" X="11" Y="163" Width="-11" Height="20" />
|
||||||
|
<Button Name="ProgressCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.ProgressCancelButton)</Button>
|
||||||
|
</Page>
|
||||||
|
<Page Name="Modify">
|
||||||
|
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
|
||||||
|
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ModifyHeader)</Label>
|
||||||
|
<Button Name="ModifyUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
|
||||||
|
<Button Name="RepairButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyRepairButton)</Button>
|
||||||
|
<Button Name="UninstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyUninstallButton)</Button>
|
||||||
|
<Button Name="ModifyCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.ModifyCancelButton)</Text>
|
||||||
|
<CloseWindowAction />
|
||||||
|
</Button>
|
||||||
|
</Page>
|
||||||
|
<Page Name="Success">
|
||||||
|
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
|
||||||
|
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
|
||||||
|
<Text>#(loc.SuccessHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 2">#(loc.SuccessLayoutHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 3">#(loc.SuccessUnsafeUninstallHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 4">#(loc.SuccessUninstallHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 5">#(loc.SuccessCacheHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 6">#(loc.SuccessInstallHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 7">#(loc.SuccessModifyHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 8">#(loc.SuccessRepairHeader)</Text>
|
||||||
|
</Label>
|
||||||
|
<Button Name="LaunchButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessLaunchButton)</Button>
|
||||||
|
<Label X="185" Y="-51" Width="400" Height="34" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">
|
||||||
|
<Text>#(loc.SuccessRestartText)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 3">#(loc.SuccessUninstallRestartText)</Text>
|
||||||
|
</Label>
|
||||||
|
<Button Name="SuccessRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessRestartButton)</Button>
|
||||||
|
<Button Name="SuccessCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.SuccessCloseButton)</Text>
|
||||||
|
<CloseWindowAction />
|
||||||
|
</Button>
|
||||||
|
</Page>
|
||||||
|
<Page Name="Failure">
|
||||||
|
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
|
||||||
|
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
|
||||||
|
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
|
||||||
|
<Text>#(loc.FailureHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 2">#(loc.FailureLayoutHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 3">#(loc.FailureUnsafeUninstallHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 4">#(loc.FailureUninstallHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 5">#(loc.FailureCacheHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 6">#(loc.FailureInstallHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 7">#(loc.FailureModifyHeader)</Text>
|
||||||
|
<Text Condition="WixBundleAction = 8">#(loc.FailureRepairHeader)</Text>
|
||||||
|
</Label>
|
||||||
|
<Hypertext Name="FailureLogFileLink" X="185" Y="121" Width="-11" Height="68" FontId="3" TabStop="yes" HideWhenDisabled="yes">#(loc.FailureHyperlinkLogText)</Hypertext>
|
||||||
|
<Hypertext Name="FailureMessageText" X="185" Y="-115" Width="-11" Height="80" FontId="3" TabStop="yes" HideWhenDisabled="yes" />
|
||||||
|
<Label X="185" Y="-57" Width="-11" Height="80" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">#(loc.FailureRestartText)</Label>
|
||||||
|
<Button Name="FailureRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.FailureRestartButton)</Button>
|
||||||
|
<Button Name="FailureCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
|
||||||
|
<Text>#(loc.FailureCloseButton)</Text>
|
||||||
|
<CloseWindowAction />
|
||||||
|
</Button>
|
||||||
|
</Page>
|
||||||
|
</Window>
|
||||||
|
</Theme>
|
||||||
BIN
utils/plugins/Desktop/brand/logo-64.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
utils/plugins/Desktop/brand/mark.ico
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
utils/plugins/Desktop/brand/mark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
27
utils/plugins/Desktop/brand/mark.svg
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="MAKS.IT">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="hi" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#33A5CF"/>
|
||||||
|
<stop offset="1" stop-color="#006199"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="mid" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0795C5"/>
|
||||||
|
<stop offset="1" stop-color="#003D7A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="deep" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#00427E"/>
|
||||||
|
<stop offset="1" stop-color="#002A6A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="navy" x1="1" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#00306F"/>
|
||||||
|
<stop offset="1" stop-color="#001428"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Origami M: sharp facets, light from upper-left. No wordmark. -->
|
||||||
|
<polygon fill="url(#hi)" points="28,40 78,40 62,216 12,216"/>
|
||||||
|
<polygon fill="url(#navy)" points="78,40 118,128 102,216 62,216"/>
|
||||||
|
<polygon fill="url(#mid)" points="118,128 158,40 174,216 102,216"/>
|
||||||
|
<polygon fill="url(#hi)" points="158,40 228,40 244,216 174,216"/>
|
||||||
|
<polygon fill="url(#deep)" points="28,40 78,40 70,128 20,128"/>
|
||||||
|
<polygon fill="url(#navy)" points="158,40 228,40 220,128 166,128"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
utils/plugins/Desktop/brand/sidebar.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@ -9,6 +9,10 @@
|
|||||||
This plugin compresses .NET release artifact inputs prepared by an earlier
|
This plugin compresses .NET release artifact inputs prepared by an earlier
|
||||||
DotNet plugin (DotNetPack or DotNetPublish) into a zip file
|
DotNet plugin (DotNetPack or DotNetPublish) into a zip file
|
||||||
and exposes the resulting release assets for later publisher plugins.
|
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)) {
|
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 "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$sharedSettings = $Settings.context
|
$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) {
|
if ($archiveInputs.Count -eq 0) {
|
||||||
throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first."
|
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"
|
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')
|
$packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile')
|
||||||
if ($null -ne $packageFile) {
|
if ($null -ne $packageFile) {
|
||||||
$releaseAssetPaths += $packageFile.FullName
|
$newAssets.Add($packageFile.FullName)
|
||||||
}
|
}
|
||||||
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
|
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
|
||||||
if ($null -ne $symbolsPackageFile) {
|
if ($null -ne $symbolsPackageFile) {
|
||||||
$releaseAssetPaths += $symbolsPackageFile.FullName
|
$newAssets.Add($symbolsPackageFile.FullName)
|
||||||
}
|
}
|
||||||
|
|
||||||
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
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 '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
|
Export-ModuleMember -Function Invoke-Plugin
|
||||||
|
|||||||
@ -6,9 +6,14 @@
|
|||||||
.NET publish plugin for producing application release artifacts.
|
.NET publish plugin for producing application release artifacts.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
This plugin publishes the configured .NET project into a release output
|
This plugin publishes configured .NET projects into the artifacts directory
|
||||||
directory and exposes that published directory to the shared release
|
and appends those publish folders to shared archive inputs so later plugins
|
||||||
context so later release-stage plugins can archive and publish it.
|
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)) {
|
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 {
|
function Invoke-Plugin {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@ -28,48 +55,145 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
||||||
|
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
|
||||||
|
|
||||||
|
$pluginSettings = $Settings
|
||||||
$sharedSettings = $Settings.context
|
$sharedSettings = $Settings.context
|
||||||
$projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
$scriptDir = $sharedSettings.scriptDir
|
||||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
$projectFiles = @()
|
||||||
$publishProjectPath = $null
|
|
||||||
|
|
||||||
Assert-Command dotnet
|
Assert-Command dotnet
|
||||||
|
|
||||||
if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
|
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
|
||||||
throw "DotNetPublish plugin requires project files in the shared context."
|
$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)) {
|
if (!(Test-Path $artifactsDirectory)) {
|
||||||
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
# The first configured project remains the canonical release artifact source.
|
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
|
||||||
$publishProjectPath = $projectFiles[0]
|
$archiveInputs = [System.Collections.Generic.List[object]]::new()
|
||||||
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
|
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) {
|
if (Test-Path $publishDir) {
|
||||||
Remove-Item -Path $publishDir -Recurse -Force
|
Remove-Item -Path $publishDir -Recurse -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Log -Level "STEP" -Message "Publishing release artifact..."
|
$ridLabel = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) { 'current runtime' } else { [string]$runtimeIdentifier }
|
||||||
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
|
Write-Log -Level "STEP" -Message "Publishing release artifact ($projectName / $ridLabel)..."
|
||||||
if ($LASTEXITCODE -ne 0) {
|
$dotnetPublishArguments = @(
|
||||||
throw "dotnet publish failed for $publishProjectPath."
|
'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)
|
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
|
||||||
if ($publishedItems.Count -eq 0) {
|
if ($publishedItems.Count -eq 0) {
|
||||||
throw "dotnet publish completed, but no files were produced in: $publishDir"
|
throw "dotnet publish completed, but no files were produced in: $publishDir"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
||||||
|
$archiveInputs.Add($publishDir)
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile'
|
$publishOutputs.Add([pscustomobject]@{
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
|
projectName = $projectName
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
runtimeIdentifier = [string]$runtimeIdentifier
|
||||||
|
directory = $publishDir
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Export-ModuleMember -Function Invoke-Plugin
|
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, Get-DotNetPublishRuntimeIdentifiers
|
||||||
|
|||||||
@ -7,12 +7,15 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
|
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`,
|
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
|
||||||
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
||||||
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
|
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
|
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.
|
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)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
|
|||||||
@ -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"]
|
|
||||||
@ -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
|
|
||||||