窗口与导航
主窗口是 Windows 端的外壳:窗口框架怎么搭、左侧导航怎么生成、点进去是哪个页面。
无边框自绘 + 自定义亚克力
窗口没有系统标题栏。构造时做三件事:
SystemBackdrop = new TransparentAcrylicBackdrop();
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);ExtendsContentIntoTitleBar 让内容延伸到标题栏高度,SetTitleBar 指定 AppTitleBar 那一格是拖拽区;系统 caption 用 SetBorderAndTitleBar(true, false) 藏掉但保留边框(边框留着,拖边缩放才有)。最小化/最大化/关闭是 XAML 里内联 SVG 路径自己画的——PathIcon.Data 不能放 ResourceDictionary 再 StaticResource 引用,会在运行期抛 XamlParseException。
亚克力是自己实现的 TransparentAcrylicBackdrop,用 DesktopAcrylicController 直接控 Tint:浅色 Tint 近白 #FCFCFC、深色近黑 #141414,默认不透明度 0.25 / 0.45。
内容区铺半透明层让壁纸透出来;侧栏和标题栏则刻意是不透明实色(浅 #F6F6F6 / 深 #161616)——内容区要透气,侧栏不能透,否则导航文字压在花壁纸上读不出来。两者之间不画分割线,靠明度分界。
导航完全由贡献点驱动
这是外壳设计上最关键的一条:外壳只认贡献点,不认页面类型。
以前外壳里写死两处:XAML 的 NavigationViewItem 列表、代码里 tag → Page 类型的 switch。加一页要改两处,插件想挂页面还得另走一条路。现在内置页面自己也降级成了一个内置插件(BuiltinShellPlugin),Kind.Page 贡献点的 Payload 就是页面类型(内核不引用任何 UI 类型,只存 object)。
BuildNavFromContributions() 按贡献点重建整棵导航树,ResolvePageType(tag) 也只查贡献点:
private static Type ResolvePageType(string? tag) =>
AppServices.Instance.Kernel.Contributions.Of(ContributionKind.Page)
.FirstOrDefault(c => c.PageId == tag)?.Payload as Type
?? typeof(HomePage);于是「加一个插件页」不用碰外壳一行代码——插件声明 contributes.navItems,宿主就多一个导航项,点进去落到统一的 PluginNavPage 按 (插件 id, 导航 id) 渲染声明式视图。停用内置外壳插件等于导航清空,这条有单测兜着。
插件导航有上限
插件一级导航项最多显示 3 个(MaxPluginNavItems),按加载顺序取前三个,其余不显示并写一行日志——一个插件塞满导航栏会把内置项挤没。
最小尺寸 900×620 的由来
presenter.PreferredMinimumWidth = 900;
presenter.PreferredMinimumHeight = 620;不是拍脑袋的整数。首页是「左版本库 + 右 360px 启动卡」的双栏,再窄右侧卡片就把版本列表挤到只剩几个字。版本页的行又是固定列结构(图标 / 260px 名称列 / 芯片 / 数据列),同样需要下限才能保证每一列都读得出来。
关窗必须停掉所有循环定时器
DispatcherQueueTimer 的 Interval 必须显式设置。默认值是 0,含义是「尽最快速度反复触发」:
// Interval 必须显式设置:DispatcherQueueTimer 默认间隔为 0,会「尽最快速度」自旋。
_taskRefresh.Interval = TimeSpan.FromMilliseconds(300);自旋本身无害(RefreshTaskRows 有脏标志挡着),但 tick 里只要出现毫秒级开销(读会话锁文件、探测 java 进程),UI 线程就被占满、再也泵不动 Dispatcher 队列——表现是「启动器一启动整个界面卡死」。
更要命的是关窗时不停掉它。WinUI 3 的退出流程要等 DispatcherQueue 停下来,而循环定时器会让它一直有活可干:窗口已关闭、消息循环永不返回,进程长期残留(没有窗口但内存还占着),还锁住 Starcloud.App.exe,导致后续 dotnet build 报 MSB3021。所以 Closed 里必须收尾:
private void StopBackgroundTimers()
{
_taskRefresh.Stop();
AppServices.Instance.NovaRuntime.StopTimers();
var link = AppServices.Instance.DeviceLink;
_ = Task.Run(() => link.Stop()); // 不等收尾:Stop 里有同步 Wait
}设备链路的 Stop() 特意放后台线程:它内部有同步 Wait,在 UI 线程上等就是「关窗卡一下」,而 beacon / TCP 循环本来就在后台线程上,进程退出即随之结束。
定时器收尾别漏
常驻的循环定时器只有两处(主窗口的 300ms 调度中心刷新、Nova 的游戏内观察循环),但两处都要停。「不阻塞关闭」和「必须停掉」是两回事。