This file provides guidance to AI coding agents when working with code in this repository.
A Windows taskbar widget: a self-drawn overlay embedded in the taskbar (three stacked groups — CPU/内存, 磁盘/GPU, 网络 ↑/↓) that pops up a fluent acrylic detail window on click. WPF on .NET Framework 4.8. No main window; the only persistent UI is the taskbar overlay. The process stays alive via ShutdownMode="OnExplicitShutdown"; exit is via the overlay's right-click menu.
dotnet build -c Debug # only toolchain on this machine — no VS MSBuild / nuget.exe
bin\Debug\net48\task_monitor.exeSDK-style csproj, net48, UseWPF=true. No tests.
Kill any running instance before rebuilding, relaunch right after a successful build — a live exe locks the output, so dotnet build fails at the copy step with error MSB3021 (looks like a compile failure; it isn't). The agent shell on this machine is elevated:
taskkill //F //IM task_monitor.exe 2>/dev/null # ok if it wasn't running
dotnet build -c Debug && (bin/Debug/net48/task_monitor.exe &) # elevated shell inherits elevation — no UAC prompt(Non-elevated fallback: touch bin/Debug/net48/shutdown.sentinel and the overlay's 1s tick exits on its own.)
The app always runs elevated, self-managed (manifest asInvoker): App.RunElevationGate (src/App.xaml.cs) runs at the top of OnStartup, BEFORE the single-instance mutex — an exiting unelevated launcher must never hold the mutex, or the elevated child takes itself for a second instance. Unelevated + consented → runas self-relaunch; never asked → ConsentDialog (允许 persists elevationConsent: true); 不允许/UAC-cancel exits — the process never runs degraded (SRUM per-process net needs admin). Consent persists to settings.yaml in the exe directory (AppSettings, YamlDotNet — the single store for ALL settings; the file + .tmp/.bad siblings are runtime artifacts). The manifest's dpiAware=true keeps the overlay's D2D text sharp — don't drop it.
Launches from the elevated agent shell inherit elevation and SKIP the gate. Exercising the consent dialog needs an unelevated launch (explorer.exe "...task_monitor.exe"); the UAC prompt lives on the secure desktop and cannot be automated. Any verification needing a screenshot or visual confirmation is the user's job.
Packages: iNKORE.UI.WPF + iNKORE.UI.WPF.Modern (control theming, merged in App.xaml), DirectN (overlay rendering), FluentWpfCore (popup acrylic + SmoothScrollViewer), YamlDotNet, framework System.Drawing (icon fallback only). Build-only: Fody + Costura.Fody — the build emits ONE exe (~6.1 MB), all managed DLLs woven in as compressed resources. Costura, not ILRepack, deliberately — ILRepack merges assemblies and would break the cross-assembly pack:// URIs (rationale in task_monitor.csproj comments and the FodyWeavers.xml header).
Single-instance: a named Global\ mutex right after the gate (Global\TaskMonitor.exe__<guid>, un-owned, existence test only; Global\ so the guard holds across sessions — reachable only elevated, so SeCreateGlobalPrivilege is in hand). A second instance exits silently (the overlay is always visible anyway); a consented second launch still UAC-prompts first — inherent to the design.
Legacy-OS warning: right after the mutex, a pre-Win11 first launch pops LegacyOsWarningDialog once (TaskbarWindow.IsWin11OrLater — the raw OS check, no taskbar-shape test) saying Win10 compatibility issues are expected and won't be fixed; legacyOsWarningShown: true in settings.yaml suppresses it thereafter. Win10 support is deprioritized — the classical taskbar path below still ships, but new work targets Win11 only.
GitHub 是主仓库,CNB 是镜像。 Remotes: origin = GitHub(SSH,git@github.com:linesoft2/TaskMonitor.git),cnb = CNB(HTTPS——CNB 不支持 SSH(官方明示),HTTPS git 认证 = 固定用户名 cnb + 访问令牌)。日常提交只推 origin;两个 GitHub Actions workflow(.github/workflows/)负责同步和发布:
sync-cnb.yml(main 分支 + v* tag 推送)→docker://tencentcom/git-sync把代码和 tag force 推到 CNB(CNB 侧是纯镜像、从无独有提交,force 保证一致)。release.yml(v* tag 推送)→ windows-latest 上先校验 tag 与 csproj<Version>一致(不一致直接 fail——版本号的唯一来源是task_monitor.csproj的<Version>,SDK 据它生成 Assembly/File/Informational 三个版本属性,关于页显示与更新检测读生成的AssemblyInformationalVersion)→dotnet build -c Release(托管镜像自带 net48 目标包)→gh release create建 GitHub Release →tools/publish-cnb-release.ps1调 CNB OpenAPI 建 release 并上传task_monitor-<tag>-x64.exe(附件名带架构;先把 tag 推到 CNB——CNB 的 release 必须挂在已存在的 tag 上;release notes = 自上一个 tag 的git log)。- Secret:
CNB_TOKEN,需要 repo-code 读写(推代码/tag)+ repo-contents 读写(release/附件)两个 scope。
发布 = 先改 task_monitor.csproj 的 <Version>,再 git tag vX.Y.Z && git push origin vX.Y.Z,之后全自动(tag 与版本号不一致流水线会 fail,这是刻意的)。CNB 云端构建集群全是 Linux Docker 节点,跑不了 net48 WPF —— 这就是构建放在 GitHub 托管 Windows runner 的原因。
publish-cnb-release.ps1 的三个坑(勿回退,详见脚本注释):CNB API 必须显式 Accept: application/json(否则 406);verify_url 的 asset_path 段是 %2F 编码,.NET System.Uri 会把它解码回 / 再发送、路径变形导致 500 —— PUT/确认必须走 curl.exe,不能 Invoke-RestMethod;脚本含中文注释,必须保持 UTF-8 BOM,否则本机 Windows PowerShell 5.1 按 GBK 误读会把解析搞坏(pwsh 7 不受影响)。
Two threads, one process:
- Taskbar overlay — a native Win32 window owned by
TaskbarWindowon a dedicated STA thread with its own message loop, rendered with DirectN, embedded into the taskbar viaSetParent. 3 visual groups → 5 hit slots (0=CPU 1=内存 2=磁盘 3=GPU 4=网络);WndProcdoes the 2D hit-test (left-click toggles a popup, right-click the menu). Slot geometry is DYNAMIC (ComputeLayoutfrom the sampling mask): the visible stacked metrics pack into the two-row grid in their fixed order — a hidden metric leaves no hole (later metrics shift forward, 空缺补齐; only the grid's last row may stay empty on an odd count), an emptied group frees its width and the window shrinks (ResizeForLayout; slot IDs stay stable). Two taskbar families, detected perStart()run (TrafficMonitor'sCheckWindows11Taskbar: Win11 version AND the XAMLDesktopWindowContentBridgechild — anything else, Windows 10 or an ExplorerPatcher-restored classic taskbar on Win11, is "classical"):- Win11: parent =
Shell_TrayWnd; placement left of the tray (default) or the left side — far-left corner / snapped left of Start, honored only while the taskbar is centre-aligned (comments atCalcPosition), TrafficMonitor's Win11 path including the 160px Widgets reserve. - Classical (Win10): parent =
ReBarWindow32(WorkerWfallback); there is no free anchor — theMSTaskSwWClasstask-buttons band is SHRUNK/SHIFTED to carve out the slot (ClassicalReposition, a 1:1 port of TrafficMonitor'sCClassicalTaskbarDlg), re-checked on a 100ms timer (TIMER_ID_POS), and RESTORED on exit (RestoreMinWindow; invariants in the gotchas). 靠左显示 picks the band's Start-side end vs tray-side end;overlaySnapToStartis Win11-only (the settings page hides it there). Side-docked (vertical) taskbars transpose the grid into full-width strips (ComputeLayout(mask, vertical)/DrawVertical/HitTestSlot),DetailWindowanchors its popup to the taskbar's screen edge (GetTaskbarEdge), and dragging the taskbar to another edge re-docks live (ReconfigureOrientation). Lifetime:Start()first waits for a real taskbar (the logon task can beat explorer at boot), and ANY return fromStart()makes App re-enter it after 2s to recreate the overlay (invariants in the gotchas).
- Win11: parent =
- WPF UI thread — on-demand
DetailWindows (one transient + any pinned), the right-clickContextMenu, andSettingsWindow(at most one, re-activated). Startup pre-warms the WPF stack with one throwawayDetailWindow.Prewarm()— the overlay is native, so the first popup would otherwise eat the whole cold-start tax (rationale in App.xaml.cs). The right-click menu host stays lazily created at first right-click — creating it at startup regressed the menu.
Cross-thread contract (all on TaskbarWindow, set by App, marshaled to the UI thread via Dispatcher.BeginInvoke):
Action<int> ToggleCallback(slot 0–4 or -1),RightClickRequested,SnapshotChanged,LatestSnapshot.- UI→taskbar:
RequestDeselect(int)(only if that column is still selected),RequestSelect(int),SetColumnClickEnabled(int, bool),SetPlacement(onLeft, snapToStart),SetSampleInterval(ms),SetMetricSamplingMask(mask),SetMergeSamePathProcesses(bool),OverlayHwnd.
Single source of truth (push, not poll): TaskbarWindow owns the only SystemSampler (1s tick by default — configurable 0.5/1/2s via 设置→采样间隔, re-armed over WM_APP_SET_INTERVAL; volatile publish, SnapshotChanged). Popups read LatestSnapshot and have no timer. The cadence is stamped onto every snapshot (SampleIntervalMs) so the "N 秒前" chart tooltips scale correctly. (CPU% also needs an old baseline — a fresh sampler reads ~0%.) Each metric's sampling can be switched off (设置→采样 toggles → mask on the sampler, SystemSampler.Mask* in overlay-slot order): the metric's samplers are skipped entirely, its overlay slot is HIDDEN (the group/window reflows to fill the space — ComputeLayout/ResizeForLayout), its column press is suppressed, and a just-re-enabled metric primes its delta baselines for one tick before showing values.
DetailWindow = shell + per-metric view: a borderless acrylic shell (FluentWpfCore WindowMaterial, UseWindowComposition=True) hosting one IDetailView (Cpu/Ram/Disk/Gpu/Net). Fresh window+view per open, destroyed on dismiss (never hidden-and-reused — born active, no acrylic flash). Closes on focus loss. Placed next to the taskbar's screen edge (PositionNearTaskbar: above a bottom taskbar, below a top one, beside a side-docked one, centred on the column via ColumnCenter/ColumnCenterY) — anchored off the OVERLAY's rect, not the taskbar's, so the overlay's reserved-band bottom-alignment (TaskbarBand, see the boot-resilience gotcha) automatically keeps the popup hugging the VISIBLE taskbar when a taskbar-height mod inflates Shell_TrayWnd. Unpinned on a bottom taskbar, the flyout's bottom edge stays anchored (SizeChanged shifts Top; other edges grow downward from a fixed Top; mechanism + the pin-band exemption in DetailWindow comments).
Pinned-mode invariants (implementation in DetailWindow, comments at the sites):
- The shell owns the single pin
ToggleButton, parked in each fresh view'sPinSlot; it never moves on screen across the transition. - Pinning grows a
TitleBarBandout of the top (composed fromTitleBarButton, NOT iNKORE'sTitleBarControl— standalone it NREs; the ✕ works only because DetailWindow's ctor registers theSystemCommands.CloseWindowCommandbinding). Unpin restores the saved pre-pin position. - While pinned, the overlay column is press-disabled (re-enabled on unpin AND close) and deselected; unpin re-selects ("flyout open ⟺ column selected").
Window_Closingrequests the column-aware deselect so a newer selection is never wiped.
All source lives under src/, grouped by layer; everything else (csproj, slnx, bin/, obj/, FodyWeavers.xml) stays at the repo root. assets/ holds the artwork: the logo trio — logo.ico (the exe icon, referenced from the csproj), logo.svg/logo.png (source artwork; the Settings 关于 page inlines logo.svg as XAML shapes, no raster is embedded) — plus README artwork: header.png (the README banner). All files share the single task_monitor namespace — folders are physical only, so XAML x:Class/xmlns:local never reference a folder.
src/
App.xaml(.cs) entry: elevation gate → mutex → taskbar thread; prewarm; menu host; idle trim
Logger.cs file log — logs/task_monitor-yyyy-MM-dd.log next to the exe; see the crash/logging gotcha
AppSettings.cs settings.yaml store — elevationConsent · legacyOsWarningShown · overlay placement · theme · sampleInterval · per-metric sampling switches · mergeSamePathProcesses · 磁盘/GPU 显示方式 (diskDisplay/gpuDisplay + 对应 index) · 网络适配器 (netAdapterId/Name) · 公网IP (publicIpEnabled) · Clash/Mihomo (clashEnabled/clashApiAddress/Secret) · 更新检测 (updateCheckEnabled/updateSource/ignoredUpdateVersion)
VersionInfo.cs 版本号读取口(读 SDK 按 csproj `<Version>` 生成的 AssemblyInformationalVersion)—— 关于页/启动日志/更新检测共用
UpdateChecker.cs 启动时检查更新(见 gotchas 的更新检测条目)
StartupTask.cs 开机自启动 logon task (schtasks /XML) — the task itself is the state
Sampling/ per-metric samplers + the SystemSampler facade (its header lists the 12) + ServiceHostMap (svchost→服务命名)
Interop/ P/Invoke boundary per native API (each file's header says what it covers)
UI/
TaskbarWindow.cs overlay + WndProc; owns the SystemSampler
DetailWindow.xaml(.cs) acrylic shell hosting one IDetailView
ConsentDialog first-run UAC-consent prompt (iNKORE modern window, unelevated)
LegacyOsWarningDialog one-time pre-Win11 compatibility warning (iNKORE modern window, elevated)
UpdateAvailableDialog 发现新版本提醒 (iNKORE modern window — 立即更新/不再提醒/稍后)
Common/ UsageColors · formatters · ProcessListTip · PivotNavButtonFix · FollowTagPanel
Details/ the five IDetailViews
Charts/ hand-drawn DrawingContext charts
Settings/ SettingsWindow (ONE scroll page, 类别 sections via TextBlock headers: 通用 / 外观 / 采样 / 关于 — SettingsCard/SettingsExpander)
App.xaml under src/ needs the explicit <Page Remove> + <ApplicationDefinition Include> pair in the csproj — it must stay, or the build loses Main.
Adding a metric: new XxxSampler (Sampling/) + XxxDetailView (UI/Details/), wired into SystemSampler.Sample / DetailWindow.ShowColumn — plus a Mask* bit + settings.yaml key + a 采样 card for its sampling switch (SettingsExpander only if the metric has sub-settings — CPU/内存 are plain cards).
All WPF UI targets Fluent 2; before adding any control/layout/icon/interaction, first check whether iNKORE.UI.WPF.Modern ships a component for it. Local checkouts (use these first): D:\ai-ref\UI.WPF.Modern (source + samples), D:\ai-ref\Documentation.
The project tracks NuGet 0.10.2.1 and the checkout matches — beware version drift when that stops being true (git tags vX.Y.Z give the packaged API). Quirks, each documented at its site:
- TabControl's strip "+" and per-tab "×" both default VISIBLE — hide them for non-document tabs.
- Plain switchers use
TabControlPivotStyle/TabItemPivotStyle— the default is an opaque TabView lookalike, wrong on acrylic. Compact-metric overrides: see DiskDetailView/GpuDetailView XAML. - Pivot hit-test flaw (invisible PreviousButton swallows first-tab clicks): both pivot views call
PivotNavButtonFix.Apply(its header explains the mechanism).
Window families — never mix iNKORE's UseModernWindowStyle/SystemBackdropType with FluentWpfCore on the same window: DetailWindow = borderless FluentWpfCore acrylic; SettingsWindow = iNKORE modern window + Mica; ConsentDialog/LegacyOsWarningDialog/UpdateAvailableDialog = iNKORE modern window (plain). iNKORE also styles the taskbar right-click menu (standard ContextMenu via SetResourceReference, by key — FluentWpfCore's later dictionary merge would shadow keyless defaults). Not iNKORE's MenuFlyout (can't place at the cursor; NREs unless owned).
Each rule's full rationale lives in code comments at the cited site — this list is the "don't regress" version:
- Classical (Win10) taskbar invariants — the shrunk
MSTaskSwWClassband is ours to restore: before ANY re-measure triggered by OUR size/DPI/settings change (ClassicalReposition(force)runsRestoreMinWindowFIRST, or the shrink accumulates — explorer only re-expands the band on its own layout events), in WM_DESTROY (explorer-restart deaths skip viaIsWindow), before an orientation flip (ReconfigureOrientationundoes the OLD axis, then transposes), and on app exit (App's OnExit posts WM_CLOSE to the overlay and joins the taskbar thread ≤1s — it's a background thread, so process death could otherwise leave the band narrowed). TheReBarWindow32/MSTaskSwWClassclass names are undocumented explorer internals (stable 7→10) — keep theWorkerW/MSTaskListWClassfallbacks and Start()'s 10s-probe → Win11-style-anchors degradation for shells without the chain. The 100msTIMER_ID_POSre-dock exists because the sample tick (up to 2s) would leave a re-expanded band overlapping the overlay too long. - Boot / explorer-restart resilience:
Start()waits, then App recreates.TaskbarWindow.Start()POLLS for a realShell_TrayWnd(exists + non-zero rect) before creating anything — the scheduled-task logon trigger can fire before explorer lays the taskbar out; the old silentreturnleft the process running with no overlay and no retry (and a zero-rect taskbar would have meant a permanently invisible 0-height overlay —RepositionOverlay's height tracking skips a 0-height band). The Win11 overlay sizes and positions against the taskbar band explorer actually RESERVES (TaskbarBand: for a bottom taskbar, the rcWork.bottom→rcMonitor.bottom strip ∩ the window rect; height from it, bottom-aligned viabandY) — a taskbar-height mod can leaveShell_TrayWndpermanently TALLER than the reservation (the 2026-08-01 report: window 90px @ y990-1080, rcWork.bottom 1020 → reserved band 60px; the surplus top strip floats over the desktop where maximized windows cover it, so a full-height top-aligned overlay loses its top rows there — and the DetailWindow anchored to the overlay's rect floats just as high). On a stock taskbar the band == the whole window rect.RepositionOverlayre-measures the band every tick and resizes/moves on change (same dance asHandleDpiChange, DPI unchanged; the throttle comparesLastXRelativeANDLastBandY). Placement diagnostics:LogGeometrydumps taskbar window/client rects, client origin, monitor/work area, the reserved band and the overlay rect (WARN when the overlay falls outside the monitor) at embed and whenever either rect changes (change-gated — the tick hits it every second). ANY return fromStart()(an explorer restart destroyed theShell_TrayWndparent and our child with it, or init threw) → theApp.StartTaskbarloop re-enters it after 2s (_stoppinggates shutdown). Recreate invariants:RegisterClassWtoleratesERROR_CLASS_ALREADY_EXISTS— and the WndProc delegate is a process-lifetimestatic readonlyprecisely because the class keeps the FIRST thunk on re-entry (a per-Start()delegate would orphan it: GC frees the thunk → next message AVs in the reverse-pinvoke stub → a stack-less NRE, the 2026-07-30 crash),OverlayHwndis zeroed between runs, and the 1s tick self-destructs viaDestroyWindowwhenIsWindow(taskbarHwnd)goes false (coversSetParentlosing an explorer-restart race). - Nothing escapes
WndProc— on x64 a managed exception can't unwind across the user32 callback boundary; escaping it is0xC000041D(fatal), and the ~20s WER dump freeze then hangs explorer on our child window.WndProcwrapsWndProcCorein a per-message try/catch that reports the fault to the globalCrashReporter(fire-and-forget — the loop must never block on the crash dialog, or explorer's SendMessage hangs the taskbar) and swallows. The catch only covers the managed body downward — an entry-glue AV (dead thunk) precedes the frame's existence; the static delegate, not the catch, closes that hole. - Global crashes =
CrashReporter→ 日志文件 + code-onlyCrashDialog(src/UI/CrashReporter.cs) — every unhandled managed exception is FIRST written to the file log (Logger.Error with the full stack, duplicates included), then pops a dialog showing it: AppDomain hooks in App's static ctor (covers even App.xaml BAML failures),DispatcherUnhandledExceptionat the very top of OnStartup,UnobservedTaskExceptionSetObserved + reported, plus the WndProc guard above. A fatal (IsTerminating) report BLOCKS the dying thread on the dialog (30s Invoke timeout → MessageBox fallback) so the user sees why the process vanished; everything else is BeginInvoke. At most one dialog, dups counted/dropped. The dialog is XAML-free so a XAML/resource crash can't kill the reporter. - File log =
Logger(src/Logger.cs),logs/task_monitor-yyyy-MM-dd.lognext to the exe — one rolling file per day, files older than 7 days pruned at startup and at each day rollover. Levels DEBUG/INFO/WARN/ERROR, ALL written: the volume is bounded by design — hot per-tick paths never log, only state changes/decisions/degradations/failures do, and per-tick failure paths useWarnOnce(key, …)(first occurrence only). Thread-safe across the UI/taskbar/SRU-callback threads, AutoFlush per line (a fatal crash never loses its own stack), NEVER throws (5 consecutive I/O failures → silently off for the run; a read-only install dir → no log at all). Init runs in App's static ctor before Main, so early startup crashes land in the file. Coverage concentrates on the compatibility-prone spots: taskbar probe/family-detection/embed/SetParent/reposition/DPI/orientation flips (TaskbarWindow), the undocumented-API degradations (SRUM register + callback, DXCore factory/enum/query, IOCTL_DISK_PERFORMANCE open/query, NtQuerySystemInformation walk, SCM enum, PDH GPU counters, disk hot-plug add/remove), the elevation gate's decisions, the mutex second-instance exit, the overlay-recreate loop, and every crash report. When touching any of those sites, keep the log line truthful. - The overlay must never steal focus: full no-activate set, including
SWP_NOACTIVATEon everySetWindowPos(TaskbarWindow). SetWindowPos(HWND_TOPMOST)silently no-ops when not foreground —ShowColumnactivates +SetForegroundWindows FIRST;EnsureTopmostverifies the exstyle bit, never trusts the return value (DetailWindow).- Acrylic unfocused: FluentWpfCore's
UseWindowComposition=Truedrives it — don't P/Invoke DWM/Accent directly. The menu's invisible host isActivate()d, closes onDeactivated, and getsWS_EX_TOOLWINDOWatSourceInitialized(without it the host shows in Alt+Tab; App.xaml.cs). NetSamplernever callsNetworkInterface.GetAllNetworkInterfaces()per tick (~275ms — was ~97% of idle CPU); the NIC is cached, re-enumerated only inSelectAdapter/TrySelect(reselect events + the 30-tick pinned-adapter return probe), and byApp.EnumerateNetAdapterson settings open (one-time) — never per tick (NetSampler.cs).- Live CPU speed = PDH
% Processor Performance× base clock, notCallNtPowerInformation(caps at base, no turbo; SystemSummarySampler.cs). - Per-process CPU/RAM/disk come from ONE
NtQuerySystemInformationwalk (ProcessCpuSampler.cs); the disk column reads the 24H2+ per-entryPROCESS_DISK_COUNTERStrailer — Task Manager's actual disk-column source, NOT SRUM (layout + fallback in the file). Icons:IShellItemImageFactory,ExtractAssociatedIconis the blurry fallback only. - Disk and GPU metrics replicate Taskmgr exactly (
WdcDiskMonitorviaIOCTL_DISK_PERFORMANCE,WdcGpuMonitorvia DXCore COM — both reversed from Taskmgr.exe; math, quirks, and the net48 COM traps are in the DiskSampler / GpuSampler / DxCoreInterop headers). Disk handles are opened per query and closed immediately, never held (a retained handle vetoes USB safe-eject). GPU adapter-level metrics never touch PDH; dxcore.dll missing (pre-1903) degrades to the--overlay.DiskInfo/GpuInfoare long-livedINotifyPropertyChangedobjects mutated from the taskbar thread — the tabs bind once and keep selection. - Per-process GPU% is PDH, not DXCore (
\GPU Engine(*)\Utilization PercentageviaPdhAddEnglishCounterW; instance-name encoding, MAX aggregation, andNormalizeEngineNamein ProcessGpuSampler.cs). No elevation needed, unlike SRUM. - Process-row tooltip = view-owned
Popupdriven by the list'sMouseMove, never a rowToolTip(the per-tick rebind cancels real ToolTips; ProcessListTip.cs remarks). - Per-process net = the undocumented SRU real-time API (srumapi.dll; admin mandatory — the reason the app always runs elevated). Calibration constants sit at the top of SrumInterop.cs — re-dump a record's bytes if the list reads wrong. The callback fires on SRU's thread (lock accumulators); never
SruFreeRecordSetthe callback's set. The SRU engine pushes frames on its own ~1s cadence, decoupled from the sampling tick —ProcessNetSampler.Samplere-diffs only when the callback's frame version advances and HOLDS the last rates between frames (dt = the frames' true arrival gap); diffing every tick at 0.5s alternated all-zero lists with doubled rates. The emitted list covers EVERY walked PID (显示所有进程): traffic-active and recently-retained ones first, the rest at 0 B/s below — so with 合并相同程序 on, a merged net row's count is the number of RUNNING same-path instances, not just the traffic-active ones (Idle/System/Memory Compression excluded; SRUM-unavailable still degrades to empty). - Wi-Fi wlanapi calls are on-demand, NOT per-tick — they're location-sensitive and light the taskbar location indicator. The poll thread does only location-insensitive work; Wi-Fi details are queried on panel open via
_wifiDetailsRequested, cached by adapter Id (flow + rationale in NetInfoSampler.cs). Don't move wlanapi back onto the per-tick path. - Memory breakdown = three sources, not
GetPerformanceInfo(GlobalMemoryStatusEx+ NTQSI class-2/class-80; this build's offsets documented at each read in MemoryDetailSampler.cs). "(compressed)" = the "Memory Compression" process's working set, not a counter; 已缓存 = standby + modified. - Charts are hand-drawn WPF — hover =
HitTest+ a realPopupre-rendered perRefresh. - 平滑滚动 = 自研
TouchDragScrollViewer(src/UI/Common/,FluentWpfCoreSmoothScrollViewer的子类)+ 自研SnappyScrollPhysics— 设置页 ONE scroll page 与五个详情视图的进程列表都从ScrollViewer换成了它。SmoothScrollViewer 本体是 v3 视觉/逻辑分离(RenderTransform 视觉满帧 + 逻辑 offset 低频同步);六处都用Physics属性挂上SnappyScrollPhysics而非库默认物理,但速率常量与库默认严格等价(滚轮 k≈9.55/s、精确模式 k≈18.4/s,用户要求回默认手感)—— 保留它的理由是修库 1.0.5 的两个 bug(均不改手感):精确模式下IsStable永假(渲染循环只能撞边界停 —— 页面中部触控板滚动后IsHitTestVisible=false卡死点击);越界累积(滚到底/顶后继续滚,剩余距离在界外堆积,反向滚要"还清"才动 ——Update每帧把目标钳进[0, MaxOffset],MaxOffset由TouchDragScrollViewer.OnScrollChanged推送ScrollableHeight,库默认物理同病)。触屏拖拽 =TouchDragScrollViewer的延迟捕获(头注有完整设计),两条现成路都不能用:库的 manipulation(IsEnableSmoothManipulating)在 TouchDown 即捕获触摸点,tap 不再晋升鼠标点击、页面内开关/按钮触屏全失灵(2026-08-01 实测);原生 PanningMode 虽 tap 正常,但它是布局驱动 —— 每个触摸 move(90~120Hz)都整页布局,弱 GPU 触屏机掉帧(BitmapCache 消掉重绘后仍卡,2026-08-02 日志证实)。延迟捕获 = TouchDown 只观察(tap 正常晋升;起点在 ScrollBar 上的手势不跟踪,滚动条保持原生拖拽)→ 过阈值才Mouse.Capture(null)平衡已晋升的 MouseLeftButtonDown + 捕获触摸点 → move 增量直接喂 physics(AnimatedScrollToVerticalOffset(VerticalOffset - dy, true)逐帧投递,无累积误差)→ 松手按采样速度自跑惯性衰减循环。已知取舍:physics 渲染期间内容IsHitTestVisible=false(滚动停稳后 ~0.1s 内的 tap 会被吞)。六个滚动器都还挂了ScrollCacheDuringTouch.Enabled="True"(窗口式 BitmapCache:拖拽过阈值才栅格化、惯性落定 600ms 后还原;订阅必须 handledEventsToo —— 拖拽会把 move/up 标 handled,否则拿不到事件;绝不能在常驻模式下用 BitmapCache,否则 hover/展开动画每帧整页重栅格化反而更卡)。TouchDragScrollViewer 不会继承 iNKORE 的 ScrollViewer 隐式样式 —— WPF 无 key 样式只精确匹配 TargetType、不惠及派生控件(实测 dump:未指 Style 时 Style==null,回退 Aero 经典条,滚动条不自动隐藏),六处都显式Style="{DynamicResource {x:Type ScrollViewer}}"(DynamicResource 以跟随深浅色 scheme 切换),模板由此继续含PART_VerticalScrollBar/PART_HorizontalScrollBar— 控件在滚动期间清掉 ScrollBar.Value 的绑定手动赋值、滚停后重建。滚动渲染循环期间内容IsHitTestVisible=false(行 tooltip/hover 暂停,滚停即恢复),不要把依赖 hover 的关键交互假设成滚动中可用。 - Idle trim (
SystemInfo.TrimMemory) at event points only, never on a timer — after the prewarm and when the last detail window closes, deferred to Background priority (App.ScheduleIdleTrimcomments explain why inline would be wrong). - 开机自启动 = a Task Scheduler logon task, never the Run key —
RunLevel=HighestAvailablefrom an already-elevated process is what avoids the per-boot UAC prompt. Registered from generated XML (StartupTask.cs), NOT schtasks switches: the defaults would kill the app after 72h (ExecutionTimeLimit→ PT0S) and skip auto-start on battery (both battery flags → false). The task itself is the state — no settings.yaml key. - 深浅色 theme =
ThemeManager.Current.ApplicationTheme(null = 跟随系统, tracked live by iNKORE). Open DetailWindows are repainted via theActualApplicationThemeChangedhook →DetailWindow.ApplyTheme→IDetailView.ApplyTheme(acrylic tint + tooltip/chart colors are NOT DynamicResource-driven). DetailWindow's root setsForegroundtoTextFillColorPrimaryBrushso unset TextBlocks/FontIcons inherit the theme (WPF's built-in default is hard Black); explicit local values (Secondary labels etc.) still win. Never read the theme offGetActualTheme(window)on DetailWindow — that attached property is only pushed to IsThemeAware windows, so it reads Light forever; useThemeManager.Current.ActualApplicationTheme. The native overlay instead tracks the SYSTEM theme itself (it sits on the taskbar, which follows the system, not this setting): the 1s tick readsHKCU\...\Themes\Personalize\SystemUsesLightThemeandApplyTaskbarThemere-tints the D2D brushes in place (SetColor, no recreation) — black text on a light taskbar, white on dark, same alphas. - Never
<StaticResource ResourceKey="…"/>-alias a theme brush in Window.Resources — it snapshots the STARTUP scheme's brush instance and shadows the app-level key through every later scheme swap (a live light→dark switch then paints new foregrounds over the frozen old background; a fresh window parses fine, which is why "reopen fixes it"). Rely on the scheme defaults (iNKORE already maps e.g.NavigationViewContentBackground→LayerFillColorDefaultBrushper scheme) or forward with<DynamicResource …/>. - 采样间隔 is not a compile-time constant — the taskbar timer is re-armed via
SetSampleInterval/WM_APP_SET_INTERVAL; rate samplers must normalize over REAL elapsed time (NetSampler does; CPU/GPU/disk already divide by real deltas — never assume 1 tick = 1s). - Per-metric sampling switches (设置→采样) use a mask SEPARATE from the pinned-window click mask —
TaskbarWindow._samplingEnabledMask(bit per hit slot,SystemSampler.Mask*) vs_clickDisabledMask; unpinning a window must never re-enable a sampling-disabled slot's press. A toggle pushes the mask to the sampler AND postsWM_APP_SET_METRICS, which re-lays-out and resizes the overlay immediately (hidden slots free their space —ResizeForLayoutdoes the width-only swap-chain dance; a 0-metric overlay collapses to a 0-width stub since DXGI can't size to 0). App closes the column's open windows on disable;Start()re-applies the mask to every freshSystemSampler(the explorer-restart recreate path would otherwise silently re-enable metrics). A re-enabled metric PRIMES its delta baselines for one tick before publishing values (a stale baseline would average the whole disabled span into one bogus tick —SystemSampler.Sample). - 合并相同程序 (设置→采样项目, 默认启用) merges in the per-process samplers BEFORE the top-8 cut —
ProcessListMerger.MergeByPathgroups rows by exe path (path-less protected processes group by name), sums every value field, re-ranks by the summed key, then cuts; merging after the cut would under-count groups whose members rank below it. Off = the exact old code path (the earlyTopNbreak stays). Plumbing mirrors the sampling mask:AppSettings.MergeSamePathProcesses(null = on, only the disabled state is written) →TaskbarWindow.SetMergeSamePathProcesses→SystemSampler.SetMergeByPath(volatile, next tick; no WM_APP — the overlay layout doesn't change), re-applied to every fresh sampler inStart(). Rows carryProcessInfo.Count, shown as a "×N" tag chip in the row templates (TagText/TagVisibility— the old "name (N)"DisplayNamesuffix is gone) (the merged row's GPU 引擎 follows its biggest member; 服务宿主 svchost.exe 不合并 — 各实例承载不同服务,保留独立行单独排名,随后由 ServiceHostMap 改名为服务/组名). - svchost → 服务命名 (ServiceHostMap, applied AFTER the merge) — one
EnumServicesStatusExW(SC_ENUM_PROCESS_INFO)RPC per tick maps every running Win32 service to its hosting PID (tasklist /svc's and Task Manager's source; ServiceControlManager.cs);SystemSampler.Samplethen renames each final svchost.exe row to the single service's display name (tag 服务) or the-kgroup name (tag 服务组) parsed offQueryServiceConfigW's lpBinaryPathName. The rename must stay AFTERProcessListMerger— it exempts svchost by row name, and renaming earlier would collapse all same-path instances into one merged row. Group-name queries run only for multi-service rows (a handful per tick — Win10 1703+ splits most services); the hover 描述 isQueryServiceConfig2W(SERVICE_CONFIG_DESCRIPTION)+SHLoadIndirectStringfor "@…" indirect strings, lazily resolved ONCE per service on the UI thread (ProcessListTip), never per tick. Row templates bindName+ theTagText/TagVisibilitychip (服务 / 服务组 / ×N — 镂空: transparent fill, hairlineControlStrokeColorDefaultBrushborder, secondary text) — there is noProcessInfo.DisplayName. The chip hugs the name's end viaUI/Common/FollowTagPanel.cs(chip measured first, name ellipsizes into what remains — a fixed MaxWidth would trim untagged rows early). - 磁盘/GPU 显示方式 (设置→采样项目→对应 expander) — the headline (overlay slot + detail header/chart) is 平均 / 最高利用率 / 特定设备 per
AppSettings.DiskDisplay(null = 平均, the disk default) /GpuDisplay(null = 最高, the GPU default — Task Manager's sidebar rule) + the specific pick (DiskDisplayIndex= the PhysicalDrive N;GpuDisplayIndex= the "GPU N" tab number — both can shift when the device set changes, and both are kept when the mode leaves Specific so switching back restores them). A missing specific device falls back to the metric's default aggregate (disk → the remaining disks' mean, GPU → the remaining adapters' max) — the pick survives, and its own values resume when it returns. The samplers query EVERY device each tick regardless (the per-device tabs need them all) — the mode only picks the headline, and a mode/index change clears the history so the chart never mixes semantics. Plumbing mirrors the merge toggle:TaskbarWindow.SetDiskDisplay/SetGpuDisplay→SystemSamplervolatile pair → per-tick hand-off toSample(mode, index), re-applied to every fresh sampler inStart()(no WM_APP — the layout doesn't change). The settings pickers' items come fromLatestSnapshot.Disks/Gpus(empty while that metric's sampling is off → a (未连接) placeholder item keeps the stored pick visible). - 网络适配器 (设置→采样项目→网络, 默认自动) —
AppSettings.NetAdapterId(a NetworkInterface.Id GUID; null = 自动, the sampler's max-cumulative-traffic Up/non-virtual pick) +NetAdapterName(display-only, for the picker's (未连接) placeholder). A pinned adapter is used while present AND Up — the virtual-adapter filter is NOT applied to an explicit pick (it's how the user watches a VPN); gone/down falls back to 自动, probed every 30 ticks so it resumes when back, and silent-for-30s never re-selects a pinned adapter (auto mode keeps the old silent reselect). Plumbing mirrors the merge toggle (TaskbarWindow.SetNetAdapter→SystemSamplervolatile → per-tickNetSampler.Sample(id), re-applied inStart()). The picker's items are enumerated by App on settings open (EnumerateNetAdapters, non-loopback, sorted — a one-time ~275ms cost, never on the per-tick path). - 公网 IP 开关 (设置→采样项目→网络, 默认开) —
AppSettings.PublicIpEnabled(null = 开, 仅写关闭态) gates ALL of NetInfoSampler's public-internet traffic: the what-is-my-ip HTTP lookups (v4 + v6) AND the 公网延迟 ICMP probe (target www.baidu.com — a hostname, DNS resolved insidePing.Sendon the poll thread; the LAN-only 本地延迟 gateway ping is unaffected). Plumbing mirrors the merge toggle (TaskbarWindow.SetPublicIpLookup→SystemSamplervolatile → per-tick_netInfo.PublicIpLookupEnabled, re-applied inStart(); no WM_APP). Off: the poll thread drops its cached address and resets the next-try timestamp, so the panel's 公网 IPv4 / 公网延迟 cells go "—" / the v6 row collapses on the next tick, and re-enabling fetches immediately instead of waiting out the refresh cadence. - Clash/Mihomo 代理流量 (设置→采样项目→网络, 开关默认开, 地址缺省 = 127.0.0.1:9090) —
ClashSamplerREST-轮询 external-controllerGET /connections~1s(独立后台线程,NetInfoSampler 模式;数据与 sparkle 的 WS/connections推送等价 —— 同一份累计计数器快照 —— REST 版免重连/分包且零新 NuGet 包:net48 自带 HttpWebRequest + DataContractJsonSerializer,后者只需框架引用 System.Runtime.Serialization,Costura 不受影响)。速率 = 按连接 id 差分累计字节 ÷ 实测间隔(sparkle 的原算法),按metadata.processPath聚合;find-process-mode=off时 processPath 全空 → 整体发布空。这些行进ProcessNetSampler.Sample后作为独立行附加(用户要求:不去重不叠加,与同路径 SRUM 行并存),ProcessListMerger对其 solo 豁免(同 svchost),行 tag "Clash"(ProcessInfo.ViaClash,TagText 最高优先;行 Pid=0 故 ServiceHostMap 的 PID 查找天然免疫)。请求显式req.Proxy = null(用户多半设了系统代理,绝不能绕回代理去连本机核心);secret →Authorization: Bearer。设置管线照网络适配器链:AppSettings.ClashEnabled(null = 开,仅写关闭态)+ClashApiAddress/ClashApiSecret(地址 null =ClashSampler.DefaultAddress127.0.0.1:9090 惯例默认值,在 SystemSampler 交接处替换 —— 原生配置开箱即用;密钥 null = 无)→TaskbarWindow.SetClashApi(enabled, addr, secret)→SystemSamplervolatile 三元组 → 每 tick_clash.SetEndpoint(无 WM_APP;Start()re-apply);开关关闭或网络采样关闭都 SetEndpoint(null) 让轮询线程完全休眠(ClashSampler 内 null=off 的语义服务这两条路径)。设置页卡片:开关即时上报、两个 TextBox 500ms 防抖上报,测试连接按钮走ClashSampler.TestConnection(GET /version 一次性探测,Task.Run 离 UI 线程;空地址探测默认地址,与轮询实际所用一致;401→认证失败 / 超时 / 拒连分别给出中文原因,结果行用 SystemFillColorSuccess/CriticalBrush 着色)。轮询失败 ≤5 次保留上次发布、之后归零;地址/密钥变化即重置差分基线。 - DISPOSE the back-buffer wrappers before
ResizeBuffers— DXGI rejects it withDXGI_ERROR_INVALID_CALLwhile any reference to a back buffer is alive, and a DirectN wrapper releases only on Dispose/finalization (GC timing is not a plan: under Debug JIT aStart()-local wrapper stayed rooted forever — the message loop never returns — and bricked the D2D target on the first resize, the 2026-07-30 crash.log flood). All resizes go throughResizeBackBuffer; the wrappers live onRenderState, never in locals.SetTarget(null)alone drops only the context's reference. - 更新检测(启动时一次,设置→通用 可关/可换源) —
UpdateChecker.CheckOnce在 OnStartup 尾部发起:线程池拉取 → UI 线程弹UpdateAvailableDialog(iNKORE modern window,同 LegacyOsWarning 家族)。版本号唯一来源 = csproj<Version>(VersionInfo.Current读 SDK 生成的AssemblyInformationalVersion;release.yml 强制 tag 与 csproj 一致)。两个更新源:github= 匿名api.github.com/.../releases/latest(必须带 User-Agent 否则 403);cnb(默认)= 读 WEB 层的/-/releases/latest307 重定向(Location 头直接带/-/releases/tag/<tag>,GitHub 同款惯例——单个 HEAD 请求,不解析 HTML;重定向消失则回退刮 releases 列表页 tag 链接取最大版本)——CNB OpenAPI 匿名 401(官方 swagger 全部 releases 端点都声明 BearerAuth),所以不走 API。系统代理默认生效(不能像 ClashSampler 那样Proxy=null——更新检测面向公网,用户代理是帮不是害)。settings.yaml 三键:updateCheckEnabled(null=开)、updateSource(null=cnb,仅写 "github")、ignoredUpdateVersion。"不再提醒" = 只跳过该具体版本——更新的版本照样提醒;三按钮:立即更新(打开发布页)/不再提醒/稍后。全部异常只记日志不崩溃,检测失败静默。 - net48's
Run.Textis not a dependency property — a{Binding}on a Run throwsXamlParseExceptionat startup. Named Runs are set in code; DataTemplates use two TextBlocks (RamDetailView.xaml.cs).
- iNKORE.UI.WPF.Modern: the local checkouts above; github.com/iNKORE-NET/UI.WPF.Modern.
- SRU real-time API: reversed from
Taskmgr.exeinC:\Users\l\SRUM-RealTime-API.md— the canonical reference forProcessNetSampler/SrumInterop.
Keep AGENTS.md in sync with the code — update it in the same change, not later. Stale guidance is worse than none; fix or delete any claim that no longer matches the code on the spot. Keep it a map: this file carries the rules and pointers; the derivations and calibration details live in code comments at the site (they usually already are) — don't duplicate them here.