Skip to content

Commit a8f05f5

Browse files
mamoreau-devolutionsCopilotGabrielDuf
authored
Reduce bundled package size for Avalonia and pinget (#4753)
* Make Avalonia publish trim-safe Publish the Avalonia app as an isolated self-contained trimmed multi-file artifact and remove publish PDBs from that output. Replace reflection-heavy Avalonia, IPC, settings, icon, integrity, and Cargo JSON paths with static mappings or source-generated metadata so the trimmed publish remains usable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use partial trim for Avalonia notifications Restore the Windows notification bridge and switch the Avalonia publish path back to TrimMode=partial while keeping the isolated multi-file trimmed artifact and PDB removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use prebuilt pinget package Resolve bundled Avalonia launches back to the installation root so root-level tools and assets are found when UniGetUI.Avalonia.exe runs from the Avalonia subfolder. Replace the nested UniGetUI.Pinget.Cli publish step with the Devolutions.Pinget.Cli.Rust package and remove the obsolete C# pinget project from the solution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix whitespace in IpcServer.cs * Fix import ordering in IpcServer and WinGet interop factory Sort using directives alphabetically in IpcServer.cs and WindowsPackageManagerStandardFactory.cs so `dotnet format style` no longer reports IMPORTS errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Gabriel Dufresne <gabrielduf@hotmail.com>
1 parent 90dad55 commit a8f05f5

34 files changed

Lines changed: 1456 additions & 3177 deletions

File tree

src/UniGetUI.Avalonia/App.axaml.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics;
2+
using System.Diagnostics.CodeAnalysis;
23
using System.IO;
34
using Avalonia;
45
using Avalonia.Controls.ApplicationLifetimes;
@@ -22,6 +23,10 @@ namespace UniGetUI.Avalonia;
2223

2324
public partial class App : Application
2425
{
26+
[UnconditionalSuppressMessage(
27+
"Trimming",
28+
"IL2026",
29+
Justification = "Platform theme dictionaries are Avalonia resources included in the app package; only the resource URI is selected dynamically.")]
2530
public override void Initialize()
2631
{
2732
AvaloniaXamlLoader.Load(this);

src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,20 @@
2121
<ApplicationIcon>..\UniGetUI\icon.ico</ApplicationIcon>
2222
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
2323
<ApplicationManifest Condition="$([MSBuild]::IsOSPlatform('Windows'))">app.manifest</ApplicationManifest>
24+
<PublishTrimmed>true</PublishTrimmed>
25+
<TrimMode>partial</TrimMode>
26+
<PublishSingleFile>false</PublishSingleFile>
27+
<IncludeAvaloniaPublishSymbols>false</IncludeAvaloniaPublishSymbols>
28+
<AppxGeneratePriEnabled>false</AppxGeneratePriEnabled>
29+
<AppxGeneratePrisForPortableLibrariesEnabled>false</AppxGeneratePrisForPortableLibrariesEnabled>
2430
</PropertyGroup>
2531

2632
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
27-
<PingetCliProject>$(MSBuildThisFileDirectory)..\UniGetUI.Pinget.Cli\UniGetUI.Pinget.Cli.csproj</PingetCliProject>
2833
<PingetCliRuntimeIdentifier Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</PingetCliRuntimeIdentifier>
2934
<PingetCliRuntimeIdentifier Condition="'$(PingetCliRuntimeIdentifier)' == '' and '$(Platform)' == 'arm64'">win-arm64</PingetCliRuntimeIdentifier>
3035
<PingetCliRuntimeIdentifier Condition="'$(PingetCliRuntimeIdentifier)' == ''">win-x64</PingetCliRuntimeIdentifier>
31-
<PingetCliPublishDir>$(MSBuildProjectDirectory)\obj\$(Platform)\$(Configuration)\BundledPinget\$(PingetCliRuntimeIdentifier)\</PingetCliPublishDir>
32-
<PingetCliExecutablePath>$(PingetCliPublishDir)pinget.exe</PingetCliExecutablePath>
33-
<PingetCliNativeSqlitePath>$(PingetCliPublishDir)e_sqlite3.dll</PingetCliNativeSqlitePath>
36+
<PingetCliPackageNativePath>$(PkgDevolutions_Pinget_Cli_Rust)\runtimes\$(PingetCliRuntimeIdentifier)\native</PingetCliPackageNativePath>
37+
<PingetCliExecutablePath>$(PingetCliPackageNativePath)\pinget.exe</PingetCliExecutablePath>
3438
</PropertyGroup>
3539

3640
<PropertyGroup Condition="'$(EnableAvaloniaDiagnostics)' == 'true'">
@@ -42,18 +46,13 @@
4246
AfterTargets="Build;Publish"
4347
Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipBundledPingetCli)' != 'true' and $([MSBuild]::IsOSPlatform('Windows'))"
4448
>
45-
<MSBuild
46-
Projects="$(PingetCliProject)"
47-
Targets="Restore;Publish"
48-
Properties="Configuration=$(Configuration);Platform=$(Platform);TargetFramework=net10.0;RuntimeIdentifier=$(PingetCliRuntimeIdentifier);SelfContained=true;PublishSingleFile=true;PublishTrimmed=true;TrimMode=partial;JsonSerializerIsReflectionEnabledByDefault=true;PublishDir=$(PingetCliPublishDir);AppendRuntimeIdentifierToOutputPath=false"
49+
<Error
50+
Condition="!Exists('$(PingetCliExecutablePath)')"
51+
Text="NuGet pinget executable not found at '$(PingetCliExecutablePath)'. Ensure package restore has completed for Devolutions.Pinget.Cli.Rust."
4952
/>
5053
<ItemGroup>
5154
<PingetCliOutputFiles Include="$(PingetCliExecutablePath)" Condition="Exists('$(PingetCliExecutablePath)')" />
52-
<PingetCliOutputFiles Include="$(PingetCliNativeSqlitePath)" Condition="Exists('$(PingetCliNativeSqlitePath)')" />
53-
<PingetCliOutputFiles Include="$(TargetDir)runtimes\$(PingetCliRuntimeIdentifier)\native\e_sqlite3.dll" Condition="!Exists('$(PingetCliNativeSqlitePath)') and Exists('$(TargetDir)runtimes\$(PingetCliRuntimeIdentifier)\native\e_sqlite3.dll')" />
5455
<PingetCliPublishOutputFiles Include="$(PingetCliExecutablePath)" Condition="Exists('$(PingetCliExecutablePath)')" />
55-
<PingetCliPublishOutputFiles Include="$(PingetCliNativeSqlitePath)" Condition="Exists('$(PingetCliNativeSqlitePath)')" />
56-
<PingetCliPublishOutputFiles Include="$(PublishDir)runtimes\$(PingetCliRuntimeIdentifier)\native\e_sqlite3.dll" Condition="!Exists('$(PingetCliNativeSqlitePath)') and Exists('$(PublishDir)runtimes\$(PingetCliRuntimeIdentifier)\native\e_sqlite3.dll')" />
5756
</ItemGroup>
5857
<Copy
5958
SourceFiles="@(PingetCliOutputFiles)"
@@ -69,6 +68,19 @@
6968
/>
7069
</Target>
7170

71+
<Target
72+
Name="RemoveAvaloniaPublishSymbols"
73+
AfterTargets="ComputeFilesToPublish"
74+
Condition="'$(IncludeAvaloniaPublishSymbols)' != 'true'"
75+
>
76+
<ItemGroup>
77+
<ResolvedFileToPublish
78+
Remove="@(ResolvedFileToPublish)"
79+
Condition="'%(ResolvedFileToPublish.Extension)' == '.pdb'"
80+
/>
81+
</ItemGroup>
82+
</Target>
83+
7284
<ItemGroup>
7385
<PackageReference Include="Avalonia" Version="12.0.3" />
7486
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
@@ -82,6 +94,7 @@
8294
<PackageReference Include="Octokit" Version="14.0.0" />
8395
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
8496
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
97+
<PackageReference Include="Devolutions.Pinget.Cli.Rust" Version="0.4.0" GeneratePathProperty="true" ExcludeAssets="build;buildTransitive;native" />
8598
</ItemGroup>
8699

87100
<ItemGroup>
Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,33 @@
1-
using System;
2-
using System.Diagnostics.CodeAnalysis;
31
using Avalonia.Controls;
42
using Avalonia.Controls.Templates;
53
using UniGetUI.Avalonia.ViewModels;
4+
using UniGetUI.Avalonia.Views;
65

76
namespace UniGetUI.Avalonia;
87

98
/// <summary>
10-
/// Given a view model, returns the corresponding view if possible.
9+
/// Given a view model, returns the corresponding view if possible without reflection.
1110
/// </summary>
12-
[RequiresUnreferencedCode(
13-
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
14-
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
1511
public class ViewLocator : IDataTemplate
1612
{
1713
public Control? Build(object? param)
1814
{
1915
if (param is null)
2016
return null;
2117

22-
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
23-
var type = Type.GetType(name);
24-
25-
if (type != null)
18+
if (param is SidebarViewModel sidebar)
2619
{
27-
return (Control)Activator.CreateInstance(type)!;
20+
return new SidebarView
21+
{
22+
DataContext = sidebar,
23+
};
2824
}
2925

30-
return new TextBlock { Text = "Not Found: " + name };
26+
return new TextBlock { Text = "Not Found: " + param.GetType().Name };
3127
}
3228

3329
public bool Match(object? data)
3430
{
35-
return data is ViewModelBase;
31+
return data is SidebarViewModel;
3632
}
3733
}

src/UniGetUI.Avalonia/Views/Controls/UserAvatarControl.axaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@
8787
</Flyout>
8888
</Button.Flyout>
8989

90-
<!-- Avatar / placeholder (reflection bindings: compiled bindings lose scope inside Button content) -->
91-
<Panel Width="32" Height="32" ClipToBounds="True" x:CompileBindings="False">
90+
<!-- Avatar / placeholder -->
91+
<Panel Width="32" Height="32" ClipToBounds="True" x:DataType="vm:UserAvatarViewModel">
9292
<Ellipse Fill="{DynamicResource SettingsCardBackground}"
9393
Width="32" Height="32"/>
9494

src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ or nameof(PackagesPageViewModel.SortAscending))
6969
{
7070
var reloadBtn = ViewModel.AddToolbarButton("reload", CoreTools.Translate("Reload"),
7171
ViewModel.TriggerReload);
72-
reloadBtn.Bind(ToolTip.TipProperty,
73-
new global::Avalonia.Data.Binding(nameof(PackagesPageViewModel.ReloadButtonTooltip)) { Source = ViewModel });
72+
UpdateReloadButtonTooltip(reloadBtn);
7473
ViewModel.AddToolbarSeparator();
7574
}
7675

@@ -136,6 +135,16 @@ or nameof(PackagesPageViewModel.SortAscending))
136135
// ─── UI-only: focus the package list ─────────────────────────────────────
137136
private void OnFocusListRequested() => PackageList.Focus();
138137

138+
private void UpdateReloadButtonTooltip(Button reloadButton)
139+
{
140+
ToolTip.SetTip(reloadButton, ViewModel.ReloadButtonTooltip);
141+
ViewModel.PropertyChanged += (_, args) =>
142+
{
143+
if (args.PropertyName is nameof(PackagesPageViewModel.ReloadButtonTooltip))
144+
ToolTip.SetTip(reloadButton, ViewModel.ReloadButtonTooltip);
145+
};
146+
}
147+
139148
public void FocusPackageList()
140149
{
141150
if (ViewModel.MegaQueryBoxEnabled)

src/UniGetUI.Core.Data.Tests/CoreTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,36 @@ public void CheckOtherAttributes()
3939
);
4040
}
4141

42+
[Fact]
43+
public void ResolveInstallationDirectoryReturnsParentForBundledAvaloniaDirectory()
44+
{
45+
string installDirectory = Path.GetFullPath(Path.Join("install-root"));
46+
string avaloniaDirectory = Path.Join(installDirectory, "Avalonia");
47+
string classicExecutable = Path.Join(installDirectory, "UniGetUI.exe");
48+
49+
string resolvedDirectory = CoreData.ResolveInstallationDirectory(
50+
avaloniaDirectory,
51+
filePath => filePath == classicExecutable,
52+
static _ => false
53+
);
54+
55+
Assert.Equal(installDirectory, resolvedDirectory);
56+
}
57+
58+
[Fact]
59+
public void ResolveInstallationDirectoryKeepsStandaloneAvaloniaDirectory()
60+
{
61+
string avaloniaDirectory = Path.GetFullPath(Path.Join("standalone", "Avalonia"));
62+
63+
string resolvedDirectory = CoreData.ResolveInstallationDirectory(
64+
avaloniaDirectory,
65+
static _ => false,
66+
static _ => false
67+
);
68+
69+
Assert.Equal(avaloniaDirectory, resolvedDirectory);
70+
}
71+
4272
[Theory]
4373
[InlineData("3.3.7", "3.3.7")]
4474
[InlineData("2026.1.2", "v2026.1.2")]

src/UniGetUI.Core.Data/CoreData.cs

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ public static class CoreData
88
{
99
private const string GitHubReleasePageBaseUrl = "https://github.com/Devolutions/UniGetUI/releases/tag/";
1010
private const string GitHubReleaseApiBaseUrl = "https://api.github.com/repos/Devolutions/UniGetUI/releases/tags/";
11+
private const string BundledModernAppDirectoryName = "Avalonia";
12+
private const string ClassicExecutableName = "UniGetUI.exe";
13+
private const string BundledPingetExecutableName = "pinget.exe";
1114

1215
private static int? __code_page;
1316
public static int CODE_PAGE
@@ -326,25 +329,49 @@ public static string UniGetUIExecutableDirectory
326329
{
327330
get
328331
{
329-
string? dir = Path.GetDirectoryName(
330-
System.Reflection.Assembly.GetExecutingAssembly().Location
331-
);
332-
if (dir is not null)
332+
string dir = NormalizeDirectoryPath(AppContext.BaseDirectory);
333+
if (!string.IsNullOrEmpty(dir))
333334
{
334-
return dir;
335+
return ResolveInstallationDirectory(dir);
335336
}
336337

337-
Logger.Error(
338-
"System.Reflection.Assembly.GetExecutingAssembly().Location returned an empty path"
339-
);
338+
Logger.Error("AppContext.BaseDirectory returned an empty path");
340339

341-
return AppContext.BaseDirectory.TrimEnd(
342-
Path.DirectorySeparatorChar,
343-
Path.AltDirectorySeparatorChar
344-
);
340+
return ResolveInstallationDirectory(NormalizeDirectoryPath(AppContext.BaseDirectory));
345341
}
346342
}
347343

344+
public static string ResolveInstallationDirectory(
345+
string executableDirectory,
346+
Func<string, bool>? fileExists = null,
347+
Func<string, bool>? directoryExists = null
348+
)
349+
{
350+
fileExists ??= File.Exists;
351+
directoryExists ??= Directory.Exists;
352+
353+
string normalizedDirectory = NormalizeDirectoryPath(executableDirectory);
354+
if (!string.Equals(
355+
Path.GetFileName(normalizedDirectory),
356+
BundledModernAppDirectoryName,
357+
StringComparison.OrdinalIgnoreCase
358+
))
359+
{
360+
return normalizedDirectory;
361+
}
362+
363+
string? parentDirectory = Path.GetDirectoryName(normalizedDirectory);
364+
if (string.IsNullOrEmpty(parentDirectory))
365+
{
366+
return normalizedDirectory;
367+
}
368+
369+
parentDirectory = NormalizeDirectoryPath(parentDirectory);
370+
return IsInstallRoot(parentDirectory, fileExists, directoryExists)
371+
? parentDirectory
372+
: normalizedDirectory;
373+
}
374+
348375
/// <summary>
349376
/// A path pointing to the executable file of the app
350377
/// </summary>
@@ -599,6 +626,14 @@ private static string GetUserHomeDirectory()
599626
return Environment.GetEnvironmentVariable("HOME") ?? AppContext.BaseDirectory;
600627
}
601628

629+
private static string NormalizeDirectoryPath(string path)
630+
{
631+
return Path.GetFullPath(path).TrimEnd(
632+
Path.DirectorySeparatorChar,
633+
Path.AltDirectorySeparatorChar
634+
);
635+
}
636+
602637
private static string NormalizeExecutablePath(string path)
603638
{
604639
if (
@@ -611,5 +646,18 @@ private static string NormalizeExecutablePath(string path)
611646

612647
return path;
613648
}
649+
650+
private static bool IsInstallRoot(
651+
string directory,
652+
Func<string, bool> fileExists,
653+
Func<string, bool> directoryExists
654+
)
655+
{
656+
return fileExists(Path.Join(directory, ClassicExecutableName))
657+
|| fileExists(Path.Join(directory, BundledPingetExecutableName))
658+
|| fileExists(Path.Join(directory, "IntegrityTree.json"))
659+
|| directoryExists(Path.Join(directory, "Assets", "Utilities"))
660+
|| directoryExists(Path.Join(directory, "Assets", "Data"));
661+
}
614662
}
615663
}

src/UniGetUI.Core.IconStore/IconDatabase.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,8 @@ public async Task LoadFromCacheAsync()
8888
"Icon Database.json"
8989
);
9090
IconScreenshotDatabase_v2 JsonData =
91-
JsonSerializer.Deserialize<IconScreenshotDatabase_v2>(
92-
await File.ReadAllTextAsync(IconsAndScreenshotsFile),
93-
SerializationHelpers.DefaultOptions
91+
IconStoreJson.DeserializeIconDatabase(
92+
await File.ReadAllTextAsync(IconsAndScreenshotsFile)
9493
);
9594
if (JsonData.icons_and_screenshots is not null)
9695
{
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using System.Text.Json.Serialization.Metadata;
4+
5+
namespace UniGetUI.Core.IconEngine;
6+
7+
internal static class IconStoreJson
8+
{
9+
public static IconScreenshotDatabase_v2 DeserializeIconDatabase(string json)
10+
{
11+
return JsonSerializer.Deserialize(json, GetTypeInfo<IconScreenshotDatabase_v2>());
12+
}
13+
14+
private static JsonTypeInfo<T> GetTypeInfo<T>()
15+
{
16+
return (JsonTypeInfo<T>?)IconStoreJsonContext.Default.GetTypeInfo(typeof(T))
17+
?? throw new InvalidOperationException(
18+
$"Icon store JSON metadata for {typeof(T).FullName} was not generated."
19+
);
20+
}
21+
}
22+
23+
[JsonSourceGenerationOptions(AllowTrailingCommas = true, WriteIndented = true)]
24+
[JsonSerializable(typeof(IconScreenshotDatabase_v2))]
25+
internal sealed partial class IconStoreJsonContext : JsonSerializerContext;

0 commit comments

Comments
 (0)