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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ jobs:
run: node scripts/generate-update-manifest.test.mjs

# 不把 build/test/publish 合并成并行 matrix 或后台任务, 避免共享 obj 文件锁。
- name: 准备 UniDesktop 原生库
run: node scripts/prepare-unidesktop.mjs ${{ matrix.rid }}

- name: 后端门 · 构建
run: dotnet build Nori.slnx --configuration Release

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ app/desktop/data/
app/desktop/data.staging-*/
.nori-storage.json
.rpiv/
app/desktop/UniDesktop/native/

*_TODO.md
120 changes: 120 additions & 0 deletions app/desktop/Nori.Core.Tests/UniDesktopIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Nori.Core.Platform;

namespace Nori.Core.Tests;

public sealed class UniDesktopIntegrationTests
{
[Theory]
[InlineData("accent")]
[InlineData("wallpaper")]
[InlineData("mediaStatus")]
[InlineData("mediaInfo")]
public async Task 原生只读接口返回结果或明确的会话错误(string operation)
{
UniDesktopIntegration integration = UniDesktopIntegration.Current;
if (!integration.IsAvailable) return;
Exception? error = await Task.Run(() => Record.Exception(() =>
{
switch (operation)
{
case "accent": integration.GetAccentColor(); break;
case "wallpaper": integration.GetWallpaper(); break;
case "mediaStatus": Assert.True(Enum.IsDefined(integration.GetMediaStatus())); break;
case "mediaInfo": integration.GetMediaInfo(); break;
default: throw new ArgumentOutOfRangeException(nameof(operation));
}
}));
if (error is null) return;
Assert.True(error is InvalidOperationException or PlatformNotSupportedException, error.ToString());
Assert.Contains("UniDesktop", error.Message);
}

[Fact]
public async Task 已准备的原生库可以通过CSharp绑定读取主题()
{
string? name = OperatingSystem.IsLinux() ? "libuda_ffi.so" : OperatingSystem.IsWindows() ? "uda_ffi.dll" : null;
if (name is null || !File.Exists(Path.Combine(AppContext.BaseDirectory, name))) return;
UniDesktopIntegration integration = new(AppContext.BaseDirectory);
Assert.True(integration.IsAvailable);
DesktopTheme theme = await Task.Run(integration.DetectTheme);
Assert.True(Enum.IsDefined(theme));
SessionCapabilities capabilities = await Task.Run(integration.GetSessionCapabilities);
Assert.True(capabilities.HasFlag(SessionCapabilities.Management));
Assert.True(capabilities.HasFlag(SessionCapabilities.Lock));
}

[Fact]
public void 缺少原生库时明确降级且不会搜索系统库()
{
UniDesktopIntegration integration = new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
Assert.False(integration.IsAvailable);
Assert.Throws<PlatformNotSupportedException>(() => integration.DetectTheme());
Assert.Throws<PlatformNotSupportedException>(() => integration.GetWallpaper());
Assert.Throws<PlatformNotSupportedException>(() => integration.Notify("标题", "正文"));
Assert.Throws<PlatformNotSupportedException>(() => integration.GetAccentColor());
Assert.Throws<PlatformNotSupportedException>(() => integration.GetMediaInfo());
Assert.Throws<PlatformNotSupportedException>(() => integration.GetMediaStatus());
Assert.Throws<PlatformNotSupportedException>(() => integration.SendMediaCommand(MediaCommand.Pause));
Assert.Throws<PlatformNotSupportedException>(() => integration.GetSessionCapabilities());
Assert.Throws<PlatformNotSupportedException>(() => integration.AcquireWakeLock(WakeLockType.System, "测试"));
Assert.Throws<PlatformNotSupportedException>(() => integration.PerformSessionAction(SessionAction.Lock));
}

[Fact]
public void 原生调用前拒绝空字符和非法填充枚举()
{
UniDesktopIntegration integration = new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
Assert.Throws<ArgumentException>(() => integration.Notify("标题\0后缀", "正文"));
Assert.Throws<ArgumentException>(() => integration.SetWallpaper("图片\0.png"));
Assert.Throws<ArgumentOutOfRangeException>(() => integration.SetWallpaper("图片.png", (WallpaperFillMode)99));
Assert.Throws<ArgumentOutOfRangeException>(() => integration.SendMediaCommand((MediaCommand)99));
Assert.Throws<ArgumentOutOfRangeException>(() => integration.AcquireWakeLock((WakeLockType)99, "测试"));
Assert.Throws<ArgumentException>(() => integration.AcquireWakeLock(WakeLockType.System, "测试\0"));
Assert.Throws<ArgumentOutOfRangeException>(() => integration.PerformSessionAction((SessionAction)99, true));
}

[Theory]
[InlineData(SessionAction.Logout)]
[InlineData(SessionAction.Suspend)]
[InlineData(SessionAction.Hibernate)]
[InlineData(SessionAction.Reboot)]
[InlineData(SessionAction.Shutdown)]
public void 未确认的会话操作在探测和原生调用前被拒绝(SessionAction action)
{
UniDesktopIntegration integration = new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => integration.PerformSessionAction(action));
Assert.Contains("明确确认", error.Message);
}

[Fact]
public void 强调色保持四通道字节序且无值不冒充黑色()
{
Assert.Null(UniDesktopIntegration.DecodeAccentColor(0));
Assert.Equal(new DesktopAccent(0xAA, 0xBB, 0xCC, 0xDD), UniDesktopIntegration.DecodeAccentColor(0xDDCCBBAA));
Assert.Equal(new DesktopAccent(0, 0, 0, 255), UniDesktopIntegration.DecodeAccentColor(0xFF000000));
}

[Fact]
public void 常亮锁并发释放只调用一次原生接口()
{
int calls = 0;
using UniDesktopWakeLock lease = new(() => Interlocked.Increment(ref calls));
Parallel.For(0, 30, _ => lease.Dispose());
Assert.Equal(1, calls);
}

[Fact]
public void 线程绑定的常亮锁拒绝异线程释放后仍能由原线程释放()
{
int calls = 0;
using UniDesktopWakeLock lease = new(() => calls++, requiresOwnerThread: true);
Exception? error = null;
Thread thread = new(() => error = Record.Exception(lease.Dispose));
thread.Start();
thread.Join();
Assert.IsType<InvalidOperationException>(error);
Assert.Equal(0, calls);
lease.Dispose();
Assert.Equal(1, calls);
}
}
11 changes: 11 additions & 0 deletions app/desktop/Nori.Core/Nori.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@
<PackageReference Include="System.Numerics.Tensors" Version="10.0.11"/>
</ItemGroup>

<PropertyGroup>
<NoriUdaRid>$(RuntimeIdentifier)</NoriUdaRid>
<NoriUdaRid Condition="'$(NoriUdaRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</NoriUdaRid>
<NoriUdaPath Condition="'$(NoriUdaRid)' == 'linux-x64'">../UniDesktop/native/linux-x64/libuda_ffi.so</NoriUdaPath>
<NoriUdaPath Condition="'$(NoriUdaRid)' == 'win-x64'">../UniDesktop/native/win-x64/uda_ffi.dll</NoriUdaPath>
</PropertyGroup>
<ItemGroup Condition="'$(NoriUdaPath)' != '' And Exists('$(NoriUdaPath)')">
<None Include="$(NoriUdaPath)" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="../UniDesktop/LICENSE-MIT" Link="UniDesktop.LICENSE-MIT.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
<!-- 人格提示词编译进程序集:修改后必须重新构建才生效 -->
<EmbeddedResource Include="Chat/nori-system-prompt.md" LogicalName="Nori.Core.Chat.nori-system-prompt.md"/>
Expand Down
2 changes: 1 addition & 1 deletion app/desktop/Nori.Core/Notifications/ApprovalNotice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public interface INativeNotifier
void Hide(string requestId);
}

/// <summary>不弹。非 Windows 平台与测试用它,省掉调用方到处判空。</summary>
/// <summary>不弹。不支持系统通知的平台与测试用它,省掉调用方到处判空。</summary>
public sealed class NullNativeNotifier : INativeNotifier
{
public static readonly NullNativeNotifier Instance = new();
Expand Down
41 changes: 41 additions & 0 deletions app/desktop/Nori.Core/Platform/IDesktopIntegration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Nori.Core.Platform;

/// <summary>UniDesktop 桌面集成;原生库可用不代表当前桌面会话支持所有操作。</summary>
public interface IDesktopIntegration
{
bool IsAvailable { get; }
DesktopTheme DetectTheme();
DesktopAccent? GetAccentColor();
string? GetWallpaper();
void SetWallpaper(string path, WallpaperFillMode mode = WallpaperFillMode.Fill);
uint Notify(string title, string body);
IDisposable AcquireWakeLock(WakeLockType type, string reason);
MediaPlaybackStatus GetMediaStatus();
DesktopMediaInfo? GetMediaInfo();
void SendMediaCommand(MediaCommand command);
SessionCapabilities GetSessionCapabilities();
/// <summary>非锁屏动作必须显式确认;能力位仅代表后端存在,不保证当前账户权限。</summary>
void PerformSessionAction(SessionAction action, bool confirmed = false);
}

public enum DesktopTheme { Unknown, Dark, Light }
public enum WallpaperFillMode { Crop, Fill, Fit, Stretch }
public sealed record DesktopAccent(byte Red, byte Green, byte Blue, byte Alpha);
public enum WakeLockType { Display, System }
public enum MediaPlaybackStatus { Playing, Paused, Stopped, Unknown }
public enum MediaCommand { Play, Pause, Toggle, Next, Previous, Stop }
public sealed record DesktopMediaInfo(string? Title, string? Artist, string? Album, ulong DurationMilliseconds, ulong PositionMilliseconds);
public enum SessionAction { Lock, Logout, Suspend, Hibernate, Reboot, Shutdown }

[Flags]
public enum SessionCapabilities : uint
{
None = 0,
Management = 0x00010000,
Lock = 0x00020000,
Logout = 0x00040000,
Suspend = 0x00080000,
Hibernate = 0x00100000,
Reboot = 0x00200000,
Shutdown = 0x00400000,
}
3 changes: 3 additions & 0 deletions app/desktop/Nori.Core/Platform/IPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public sealed record PlatformCapabilities
/// </summary>
public interface IPlatformServices
{
/// <summary>系统主题、壁纸和通知的 UDA 接口;macOS 与缺失原生库时明确降级。</summary>
IDesktopIntegration DesktopIntegration => UniDesktopIntegration.Current;

/// <summary>当前会话类型</summary>
SessionType Session { get; }

Expand Down
Loading
Loading