Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/dotnet-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ jobs:
shell: pwsh
run: |
dotnet build-server shutdown
dotnet restore UniGetUI.Avalonia/UniGetUI.Avalonia.csproj `
--runtime win-x64 `
/p:Configuration=Release `
/p:PublishAot=true `
/p:SelfContained=true
dotnet publish UniGetUI.Avalonia/UniGetUI.Avalonia.csproj `
--no-restore `
--configuration Release `
Expand All @@ -113,3 +118,6 @@ jobs:
/p:TrimmerSingleWarn=false `
/p:UseSharedCompilation=false `
--verbosity minimal
if (-not (Test-Path "${{ runner.temp }}\unigetui-nativeaot-publish\UniGetUI.exe")) {
throw "NativeAOT publish did not produce UniGetUI.exe."
}
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ dotnet publish src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj /p:Configuration=R
- Self-contained, publish-trimmed (partial), Windows App SDK self-contained
- Tests use **xUnit** (`[Fact]`, `Assert.*`)

## NativeAOT and Trim Safety

Release packages are self-contained, fully trimmed NativeAOT binaries on every supported RID. Treat NativeAOT safety as a non-negotiable production requirement: changes must work without runtime-generated code or metadata that the trimmer cannot prove is required.

- Consider every `IL2xxx` trim warning and `IL3xxx` AOT warning a defect. Do not blanket-suppress warnings or use a broad `RequiresUnreferencedCode`, `RequiresDynamicCode`, `DynamicDependency`, `DynamicallyAccessedMembers`, linker descriptor, or root assembly as a workaround. An exception must be narrowly scoped, explain the concrete runtime requirement, and have a publish-path test that proves it is safe.
- Use `JsonSerializerContext`/`JsonTypeInfo<T>` source-generated metadata for every production JSON serialization or deserialization path, including generic helpers, HTTP payloads, settings, IPC, telemetry, and persisted state. Do not call reflection-based serializer overloads or retain a reflection fallback for unknown application types. Add the closed type to a context and test the persisted or transported path.
- Do not introduce production reflection-driven activation or discovery (`Assembly.Load`, `Type.GetType`, `Activator`, member lookup/invocation), runtime generic construction, expression compilation, runtime code generation, or dynamically loaded plugins. Prefer explicit registrations, direct constructors, generated metadata, and compiled XAML bindings. If a framework-owned dynamic boundary cannot be removed, isolate it and add published-NativeAOT runtime coverage.
- New shipping COM interop must use source-generated interfaces and wrappers (`GeneratedComInterface`, `StrategyBasedComWrappers`) or raw ABI calls; do not add `ComImport`, `CoClass`, RCW activation, or legacy marshal-to-object APIs to a NativeAOT path. Define the ABI precisely, balance COM reference ownership, and test both x64 and arm64 where applicable.
- Prefer `LibraryImport` for new P/Invokes. Existing `DllImport` declarations are acceptable only when their marshalling is supported by NativeAOT; keep platform guards, calling conventions, ownership, callback lifetimes, and x64/arm64 struct layouts explicit and covered.
- Audit all new or changed startup, XAML/view-loading, dependency-injection, serialization, COM/WinRT, P/Invoke, and package-manager code against the publish output, not only a Debug build. Run the relevant NativeAOT publish profile, for example:

```shell
dotnet publish src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj --configuration Release --runtime win-x64 --self-contained true /p:Platform=x64 /p:PublishProfile=Win-x64-NativeAot
```

- When modifying a shared or portable path, retain the same safety guarantees for all checked-in NativeAOT publish profiles: `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, and `osx-arm64`. Record unsupported runtime-matrix coverage as a gap, never as a pass.

## Avalonia DevTools (Developer-Only)

Use these rules when changing Avalonia diagnostics/devtools behavior:
Expand Down
165 changes: 2 additions & 163 deletions src/UniGetUI.Core.Settings.Tests/SettingsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,18 +297,7 @@ string[] ls3Array
Assert.False(Settings.RemoveFromList(SettingName, ls2[0]));
Assert.False(Settings.ListContains(SettingName, ls2[0]));

Settings.SetList(SettingName, ls3);
Assert.Equal(ls3.Count, Settings.GetList<SerializableTest>(SettingName)?.Count);
Assert.Equal(
ls3[1].sub.sub,
Settings.GetListItem<SerializableTest>(SettingName, 1)?.sub.sub
);
Assert.True(Settings.RemoveFromList(SettingName, ls3[0]));
Assert.False(Settings.RemoveFromList(SettingName, ls3[0]));
Assert.Equal(
ls3[1].sub.sub,
Settings.GetListItem<SerializableTest>(SettingName, 0)?.sub.sub
);
Assert.Throws<InvalidOperationException>(() => Settings.SetList(SettingName, ls3));
Settings.ClearList(SettingName); // Cleanup
Assert.Empty(
Settings.GetList<object>(SettingName)
Expand Down Expand Up @@ -383,157 +372,7 @@ string[] strArray

Settings.SetDictionaryItem(SettingName, "key", 12);
Assert.Equal(12, Settings.GetDictionaryItem<string, int>(SettingName, "key"));
Settings.SetDictionary(SettingName, test);
Assert.Equal(
JsonSerializer.Serialize(test, Settings.SerializationOptions),
File.ReadAllText(
Path.Join(
CoreData.UniGetUIUserConfigurationDirectory,
$"{Settings.ResolveKey(SettingName)}.json"
)
)
);
Assert.Equal(
test[keyArray[0]]?.sub.count,
Settings
.GetDictionary<string, SerializableTest?>(SettingName)
?[keyArray[0]]?.sub.count
);
Assert.Equal(
test[keyArray[1]]?.sub.count,
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[1])
?.sub.count
);
Settings.SetDictionaryItem(SettingName, keyArray[0], test[keyArray[1]]);
Assert.Equal(
test[keyArray[1]]?.sub.count,
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[0])
?.sub.count
);
Assert.NotEqual(
test[keyArray[0]]?.sub.count,
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[0])
?.sub.count
);
Assert.Equal(
test[keyArray[1]]?.sub.count,
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[1])
?.sub.count
);
Assert.Equal(
test[keyArray[1]]?.count,
Settings
.SetDictionaryItem(
SettingName,
keyArray[0],
new SerializableTest(
"this is not test data",
-12000,
new SerializableTestSub("this sub is not test data", -13000)
)
)
?.count
);
Assert.Equal(
-12000,
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[0])
?.count
);
Assert.Equal(
"this is not test data",
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[0])
?.title
);
Assert.Equal(
-13000,
Settings
.GetDictionaryItem<string, SerializableTest?>(SettingName, keyArray[0])
?.sub.count
);
Settings.SetDictionaryItem(
SettingName,
"this is not a test data key",
test[keyArray[0]]
);
Assert.Equal(
test[keyArray[0]]?.title,
Settings
.GetDictionaryItem<string, SerializableTest?>(
SettingName,
"this is not a test data key"
)
?.title
);
Assert.Equal(
test[keyArray[0]]?.sub.count,
Settings
.GetDictionaryItem<string, SerializableTest?>(
SettingName,
"this is not a test data key"
)
?.sub.count
);
Assert.True(
Settings.DictionaryContainsKey<string, SerializableTest?>(
SettingName,
"this is not a test data key"
)
);
Assert.True(
Settings.DictionaryContainsValue<string, SerializableTest?>(
SettingName,
test[keyArray[0]]
)
);
Assert.NotNull(
Settings.RemoveDictionaryKey<string, SerializableTest?>(
SettingName,
"this is not a test data key"
)
);
Assert.Null(
Settings.RemoveDictionaryKey<string, SerializableTest?>(
SettingName,
"this is not a test data key"
)
);
Assert.False(
Settings.DictionaryContainsKey<string, SerializableTest?>(
SettingName,
"this is not a test data key"
)
);
Assert.False(
Settings.DictionaryContainsValue<string, SerializableTest?>(
SettingName,
test[keyArray[0]]
)
);
Assert.True(
Settings.DictionaryContainsValue<string, SerializableTest?>(
SettingName,
test[keyArray[2]]
)
);

Assert.Equal(
JsonSerializer.Serialize(
Settings.GetDictionary<string, SerializableTest>(SettingName),
Settings.SerializationOptions
),
File.ReadAllText(
Path.Join(
CoreData.UniGetUIUserConfigurationDirectory,
$"{Settings.ResolveKey(SettingName)}.json"
)
)
);
Assert.Throws<InvalidOperationException>(() => Settings.SetDictionary(SettingName, test));

Settings.ClearDictionary(SettingName); // Cleanup
Assert.Empty(
Expand Down
18 changes: 13 additions & 5 deletions src/UniGetUI.Core.Settings/SettingsEngine_Dictionaries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ private static readonly ConcurrentDictionary<
);
return value;
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
Logger.Error($"Could not load dictionary name {setting}");
Expand All @@ -99,18 +103,22 @@ Dictionary<KeyT, ValueT> value
where KeyT : notnull
{
string setting = ResolveKey(settingsKey);
_dictionarySettings[settingsKey] = value.ToDictionary(
kvp => (object)kvp.Key,
kvp => (object?)kvp.Value
);

var file = Path.Join(CoreData.UniGetUIUserConfigurationDirectory, $"{setting}.json");
try
{
if (value.Count != 0)
File.WriteAllText(file, SettingsJson.SerializeDictionary(value));
else if (File.Exists(file))
File.Delete(file);

_dictionarySettings[settingsKey] = value.ToDictionary(
kvp => (object)kvp.Key,
kvp => (object?)kvp.Value
);
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception e)
{
Expand Down
11 changes: 10 additions & 1 deletion src/UniGetUI.Core.Settings/SettingsEngine_Lists.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public static partial class Settings
listSettings[setting] = value.Cast<object>().ToList();
return value;
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
Logger.Error($"Could not load list {setting} from settings");
Expand All @@ -78,14 +82,19 @@ public static partial class Settings

public static void SetList<T>(string setting, List<T> value)
{
listSettings[setting] = value.Cast<object>().ToList();
var file = Path.Join(CoreData.UniGetUIUserConfigurationDirectory, $"{setting}.json");
try
{
if (value.Count != 0)
File.WriteAllText(file, SettingsJson.SerializeList(value));
else if (File.Exists(file))
File.Delete(file);

listSettings[setting] = value.Cast<object>().ToList();
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception e)
{
Expand Down
Loading
Loading