The root AGENTS.md carries each rule below as a one-line index.
This file is the un-abridged version, loaded only when an agent actually touches that
area. The canonical statement of each rule still lives in a comment at the code
site named in the heading — when the two disagree, the code comment wins and this file
(and the index) must be fixed.
Cross-cutting companions: architecture.md (thread ownership, the
cross-thread contract, the detail/pin model, startup/elevation, layout),
settings-plumbing.md (every 设置→sampler chain) and
scroll-stack.md (the three-file scrolling implementation).
task_monitor.csproj … src/UI/TaskbarWindow.cs: ClassicalReposition, RestoreMinWindow,
ReconfigureOrientation, TIMER_ID_POS
The shrunk MSTaskSwWClass task-buttons band is ours to restore. ClassicalReposition(force)
runs RestoreMinWindow FIRST, before any re-measure triggered by OUR size/DPI/settings
change — otherwise the shrink accumulates, because explorer only re-expands the band on
its own layout events. The restore must also run:
- in
WM_DESTROY(explorer-restart deaths skip it viaIsWindow), - before an orientation flip (
ReconfigureOrientationundoes the OLD axis, then transposes), - on app exit — App's
OnExitpostsWM_CLOSEto the overlay and joins the taskbar thread (≤1s); it is a background thread, so a raw process death could otherwise leave the band narrowed.
ReBarWindow32 / MSTaskSwWClass are undocumented explorer internals (stable 7→10) —
keep the WorkerW / MSTaskListWClass fallbacks and Start()'s 10s-probe →
Win11-style-anchors degradation for shells without the chain.
The 100 ms TIMER_ID_POS re-dock exists because the sample tick (up to 2s) would leave
a re-expanded band overlapping the overlay for a whole interval.
src/UI/TaskbarWindow.cs: Start, RepositionOverlay, TaskbarBand, LogGeometry
TaskbarWindow.Start() polls for a real Shell_TrayWnd (exists + non-zero rect)
before creating anything — the scheduled-task logon trigger can fire before explorer
lays the taskbar out; an earlier silent return left 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 via bandY). A taskbar-height
mod can leave Shell_TrayWnd permanently 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.
RepositionOverlay re-measures the band every tick and resizes/moves on change (the same
dance as HandleDpiChange, DPI unchanged); the throttle compares LastXRelative AND
LastBandY. LogGeometry dumps 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, because the tick hits
it every second.
ANY return from Start() (parent death or init failure) → the manager's PER-TARGET loop
re-resolves the display and re-enters after 2s (_stopping/desired gates shutdown and selection
changes). The wait is cancellable, a deselected entry cannot revive, and overlay teardown never
creates or destroys the shared sampler. Recreate invariants:
RegisterClassWtoleratesERROR_CLASS_ALREADY_EXISTS;- the WndProc delegate is a process-lifetime
static readonlyprecisely because the class keeps the FIRST thunk on re-entry — a per-Start()delegate would be orphaned on the next GC, and the next dispatched message would jump into freed memory and AV inside the reverse-pinvoke stub (a stack-less NRE; the 2026-07-30 crash); OverlayHwndis zeroed between runs;- the 1s tick self-destructs via
DestroyWindowwhenIsWindow(taskbarHwnd)goes false (coversSetParentlosing an explorer-restart race).
src/UI/TaskbarWindow.cs: WndProc / WndProcCore
On x64 a managed exception cannot unwind across the user32 callback boundary; escaping it
is 0xC000041D (fatal), and the ~20s WER dump freeze then hangs explorer on our child
window. WndProc wraps WndProcCore in a per-message try/catch that reports the fault to
the global CrashReporter (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 (§2), not the catch, closes that hole.
src/UI/CrashReporter.cs, hooked in src/App.xaml.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. Hooks: AppDomain
UnhandledException in App's static ctor (covers even App.xaml BAML failures),
DispatcherUnhandledException at the very top of OnStartup, UnobservedTaskException
(SetObserved + reported), plus the WndProc guard (§3).
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; duplicates counted and dropped. The dialog is
XAML-free so a XAML/resource crash cannot kill the reporter.
src/Logger.cs
logs/task_monitor-yyyy-MM-dd.log next 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 use
WarnOnce(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.
src/UI/TaskbarWindow.cs
Full no-activate set, including SWP_NOACTIVATE on every SetWindowPos.
One deliberate exception — 悬浮模式 with 置顶显示 OFF, which must be activatable for a click to raise it (§36). Every other form keeps the full set.
src/UI/DetailWindow.xaml.cs: ShowColumn, EnsureTopmost
ShowColumn activates + SetForegroundWindows FIRST; EnsureTopmost verifies the exstyle
bit and never trusts the return value.
src/App.xaml.cs, src/UI/DetailWindow.xaml.cs, src/Interop/WindowBackdropInterop.cs
FluentWpfCore's UseWindowComposition=True drives acrylic on every WPF window — do not
hand-roll DWM/Accent P/Invoke for one of those (the one carve-out: DetailWindow on Win10
carries NO material at all — §40). The 悬浮模式 widget is the exception that
proves the rule: it is a NATIVE DirectComposition window, so FluentWpfCore (a WPF attached
property) cannot reach it — WindowBackdropInterop makes the identical accent call with the
identical tint constants instead (§36). The right-click menu's invisible host is Activate()d,
closes on Deactivated, and gets WS_EX_TOOLWINDOW at SourceInitialized (without it the host
shows in Alt+Tab). Before EVERY open, ShowTaskbarMenu moves that host to the physical cursor
and calls UpdateLayout: WPF Popup picks its initial DPI from the PlacementTarget's location,
not from Placement=MousePoint, so an offscreen/stale host gives a secondary-screen menu the
primary DPI.
src/Sampling/NetSampler.cs
NetworkInterface.GetAllNetworkInterfaces() costs ~275ms — it was ~97% of idle CPU. The NIC
is cached and re-enumerated only in SelectAdapter/TrySelect (reselect events + the
30-tick pinned-adapter return probe) and by App.EnumerateNetAdapters on settings open
(one-time). Never per tick.
src/Sampling/SystemSummarySampler.cs
Not CallNtPowerInformation — that caps at base and reports no turbo.
src/Sampling/ProcessCpuSampler.cs
The disk column reads the 24H2+ per-entry PROCESS_DISK_COUNTERS trailer — Task Manager's
actual disk-column source, NOT SRUM (layout + fallback in the file). Icons:
IShellItemImageFactory; ExtractAssociatedIcon is the blurry fallback only.
src/Sampling/DiskSampler.cs, GpuSampler.cs, src/Interop/DxCoreInterop.cs
WdcDiskMonitor via IOCTL_DISK_PERFORMANCE, WdcGpuMonitor via DXCore COM — both reversed
from Taskmgr.exe; math, quirks and the net48 COM traps live in those headers.
Disk handles are opened per query and closed immediately, never held — a retained handle
vetoes USB safe-eject. A missing dxcore.dll (pre-1903) degrades to the -- overlay.
DiskInfo/GpuInfo are long-lived INotifyPropertyChanged objects mutated from the
taskbar thread — the tabs bind once and keep selection.
Win10 GPU reality (measured on 22H2 + VMware SVGA 3D, the 2026-09-30 "未检测到 GPU"):
dxcore.dll exists since 1903 but Win10's build implements only the base IDXCoreAdapter
(IDXCoreAdapter1 QI → E_NOINTERFACE — SDK docs claim 2004+, wrong) and matches only the
runtime attributes (D3D11/D3D12 GRAPHICS, D3D12 CORE_COMPUTE) in CreateAdapterList, never
the Win11 hardware-type GPU attribute. So: always GetAdapter with the base IID, QI
IDXCoreAdapter1 per adapter with as; enumerate four attribute passes deduped by LUID.
Older drivers (VMware) also report the running-time/memory-usage states unsupported —
utilization then falls back to the per-process PDH engine map (§13, summed per engine),
memory to the AdapterMemoryBudget state's currentUsage. All gates are capability-based
(IsQueryStateSupported / the QI result), never OS-version checks.
src/Sampling/ProcessGpuSampler.cs
\GPU Engine(*)\Utilization Percentage via PdhAddEnglishCounterW; instance-name encoding,
MAX aggregation and NormalizeEngineName are in the file. No elevation needed, unlike SRUM.
The same collect also builds the per-(adapter LUID, phys, eng) map (EngineMap, on request
via GpuSampler.NeedsPdhEngineData) that backs §12's Win10 utilization/name fallback —
PDH eng ordinals and DXCore engine indices are both D3DKMT node ordinals, matching 1:1.
src/UI/Common/ProcessListTip.cs
The per-tick rebind cancels real ToolTips; the popup is driven by the list's MouseMove.
src/Sampling/ProcessNetSampler.cs, src/Interop/SrumInterop.cs
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 SruFreeRecordSet the
callback's set.
The SRU engine pushes frames on its own ~1s cadence, decoupled from the sampling tick —
ProcessNetSampler.Sample re-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).
A registration OUTLIVES its sampler — unregister on every SAMPLER rebuild, never on a mere
overlay rebuild. SruRegisterRealTimeStats gives srumapi a raw pointer to a managed
callback stub. If the sampler becomes unreachable while the native registration remains, GC
frees that stub and the next SRU frame calls freed memory (0xC0000005, often no managed
frame). ProcessNetSampler.Shutdown() therefore unregisters AND roots the retired instance
for any callback already in flight.
The 2026-09-22 incident happened because every floating-mode/Explorer rebuild constructed a
new SystemSampler without releasing the old one. The 2026-10-17 pre-loop failure added the
same leak through Start() retries and once produced ~124 live registrations in four minutes;
a force-kill then wedged the engine for the rest of the boot session. These were the old
single-overlay ownership model, not permission to repeat that coupling.
Current owner (multi-display): SamplingController alone creates and retires the sampler;
TeardownOverlay never unregisters SRUM. srumapi.dll has ONE process-level module reference,
never a per-sampler LoadLibrary. Successful retirement unregisters once. A failed unregister
KEEPS its original handle and retries at most 3 times; if still failing, the controller stops
rebuilding rather than adding another native registration. _keepRetired protects the callback
stub on that failure path; it is not a license to keep creating samplers.
The same retirement STOPS NetInfoSampler and ClashSampler with stop + wake + bounded join;
a stop timeout also BLOCKS reconstruction rather than stacking a fresh poller pair. GpuSampler.Shutdown()
releases its three marshaling buffers and DXCore references.
ProcessNetSampler.Retired holds an instance only while a callback is in flight (or an
unregister failed); normal completion removes it again. Do not move shutdown back into an HWND
path or let N displays create N registrations/poller pairs.
The engine-wedge fingerprint remains useful: SRUM 实时 API 注册成功 followed by
SRUM 注册成功但 30 秒未收到任何帧. A clean app restart does not clear that engine state (only a
reboot did in the incident); SRUM 首帧已到达 is the positive proof in the log.
src/Sampling/NetInfoSampler.cs
They are 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. Do not move wlanapi back onto the per-tick
path.
src/Sampling/MemoryDetailSampler.cs
GlobalMemoryStatusEx + NTQSI class-2/class-80; this build's offsets are documented at each
read. "(compressed)" = the "Memory Compression" process's working set, not a counter;
已缓存 = standby + modified.
src/UI/Charts/*
Hover = HitTest + a real Popup re-rendered per Refresh.
src/UI/Common/TouchDragScrollViewer.cs, SnappyScrollPhysics.cs,
ScrollCacheDuringTouch.cs
Full design: scroll-stack.md. The one rule that bites: the nine
scrollers must set Style="{DynamicResource {x:Type ScrollViewer}}" explicitly — WPF
keyless styles match TargetType exactly and do NOT reach derived controls, so without it
the control silently falls back to the Aero scrollbar.
src/App.xaml.cs: ScheduleIdleTrim, src/Interop/SystemInfo.cs: TrimMemory
SystemInfo.TrimMemory runs after the prewarm and when the last WPF window (detail or
settings) closes, deferred to Background priority (the comments at the site explain why
inline would be wrong). It performs ONE plain GC.Collect and nothing else.
Never restore SetProcessWorkingSetSize(-1,-1) here. Forcing every not-recently-touched
page out of the working set right after prewarm (or a close) also evicts the warm WPF
JIT/BAML/style/render pages, so the next Detail open pays soft faults and can stall briefly.
Keeping those pages resident is the point of prewarm; Windows remains responsible for normal
paging under actual memory pressure. Closing a window still drops its App references and the
rate-limited GC still collects the unreachable managed tree — this is not a hidden-window
cache.
It must also NOT burst or wait for finalizers (the 2026-09-22 卡死 investigation). Rapid
切换 detail 窗口 closes one popup per switch, so the old code ran a forced full GC per close;
it now refuses a second collection within TrimMinIntervalMs (30 s) — the log line states
the limit and the actual gap. And it no longer calls GC.WaitForPendingFinalizers():
waiting meant every unreachable DirectN/COM wrapper was released ON THE SPOT while the
taskbar thread was concurrently inside its own COM calls (Draw), which is precisely the
window a use-after-free shows up in. Without the wait those wrappers are still released — by
the finalizer thread, in its own time, exactly as during normal operation. (The real crash
of that night was the leaked SRUM registration behind the same trigger — §15 — but this is
the same "don't force finalization under a live drawer" rule.)
src/StartupTask.cs
RunLevel=HighestAvailable from an already-elevated process is what avoids the per-boot UAC
prompt. Registered from generated XML, 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.
src/App.xaml.cs, src/UI/DetailWindow.xaml.cs, src/UI/TaskbarWindow.cs
ThemeManager.Current.ApplicationTheme (null = 跟随系统, tracked live by iNKORE). Open
DetailWindows are repainted via the ActualApplicationThemeChanged hook →
DetailWindow.ApplyTheme → IDetailView.ApplyTheme (acrylic tint + tooltip/chart colors
are NOT DynamicResource-driven).
DetailWindow's root sets Foreground to TextFillColorPrimaryBrush so 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 off GetActualTheme(window) on DetailWindow — that attached
property is only pushed to IsThemeAware windows, so it reads Light forever; use
ThemeManager.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 reads
HKCU\...\Themes\Personalize\SystemUsesLightTheme and ApplyTaskbarTheme re-tints the D2D
brushes in place (SetColor, no recreation) — black text on a light taskbar, white on dark,
same alphas.
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 →
LayerFillColorDefaultBrush per scheme) or forward with <DynamicResource …/>.
src/UI/TaskbarWindow.cs: SetSampleInterval / WM_APP_SET_INTERVAL
The taskbar timer is re-armed at runtime; rate samplers must normalize over REAL elapsed time (NetSampler does; CPU/GPU/disk already divide by real deltas — never assume 1 tick = 1s).
src/UI/TaskbarWindow.cs, src/Sampling/SystemSampler.cs
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 ONCE to SamplingController and broadcasts each overlay's local mask
(the manager replays it to fresh entries). Every overlay re-lays-out/resizes over
WM_APP_SET_METRICS; the controller coalesces immediate re-sampling. App closes the column's
open windows on disable. Sampler configuration is replayed only when the controller itself
reconstructs a failed sampler.
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).
src/Sampling/ProcessListMerger.cs, src/Sampling/SystemSampler.cs
ProcessListMerger.MergeByPath groups 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
early TopN break stays).
Rows carry ProcessInfo.Count, shown as a "×N" tag chip in the row templates
(TagText/TagVisibility — there is no ProcessInfo.DisplayName). The merged row's GPU
引擎 follows its biggest member; 服务宿主 svchost.exe 不合并 — each instance hosts
different services, so they keep independent rows and their own rank, and are renamed
afterwards by ServiceHostMap (§26).
Plumbing: settings-plumbing.md §1.
src/Sampling/ServiceHostMap.cs, src/Interop/ServiceControlManager.cs
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). SystemSampler.Sample
then renames each final svchost.exe row to the single service's display name (tag 服务) or
the -k group name (tag 服务组) parsed off QueryServiceConfigW's lpBinaryPathName.
The rename must stay AFTER ProcessListMerger — it exempts svchost by row name, so
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 描述 is QueryServiceConfig2W(SERVICE_CONFIG_DESCRIPTION) + SHLoadIndirectString
for "@…" indirect strings, lazily resolved ONCE per service on the UI thread
(ProcessListTip), never per tick. The chip hugs the name's end via
src/UI/Common/FollowTagPanel.cs (chip measured first, name ellipsizes into what remains — a
fixed MaxWidth would trim untagged rows early).
src/Sampling/DiskSampler.cs, GpuSampler.cs
The headline (overlay slot + detail header/chart) is 平均 / 最高利用率 / 特定设备 per
AppSettings.DiskDisplay (null = 平均) / GpuDisplay (null = 最高 — Task Manager's sidebar
rule), plus 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.
The settings pickers' items come from LatestSnapshot.Disks/Gpus (empty while that
metric's sampling is off → a (未连接) placeholder item keeps the stored pick visible).
Plumbing: settings-plumbing.md §3.
src/Sampling/NetSampler.cs
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 is 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:
settings-plumbing.md §4.
src/Sampling/NetInfoSampler.cs
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 inside Ping.Send on the poll thread; the
LAN-only 本地延迟 gateway ping is unaffected).
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. Plumbing:
settings-plumbing.md §5.
src/Sampling/ClashSampler.cs
ClashSampler REST-polls the external-controller GET /connections at ~1s on its own
background thread (the NetInfoSampler pattern). The data is equivalent to sparkle's WS
/connections push — the same cumulative-counter snapshot — but REST avoids
reconnect/fragmentation and needs zero new NuGet packages: net48 ships HttpWebRequest +
DataContractJsonSerializer (the latter needs only the framework reference
System.Runtime.Serialization, so Costura is unaffected).
Rate = per-connection-id diff of cumulative bytes ÷ measured interval (sparkle's original
algorithm), aggregated by metadata.processPath; with find-process-mode=off every
processPath is empty → the whole thing publishes empty.
These rows are appended as standalone rows after ProcessNetSampler.Sample (per the
user's requirement: no de-dup, no summing — they coexist with same-path SRUM rows), and
ProcessListMerger exempts them solo (same as svchost). Row tag "Clash"
(ProcessInfo.ViaClash, highest TagText priority; the row's Pid=0 makes it naturally
immune to ServiceHostMap's PID lookup).
Requests set req.Proxy = null explicitly — the user very likely has a system proxy
configured, and the request must never be routed back through the proxy to reach the local
core. The secret goes out as Authorization: Bearer.
Polling failures: ≤5 keep the last publish, after that it zeroes; an address/secret change
resets the diff baseline. The settings card's 测试连接 button calls
ClashSampler.TestConnection (a one-shot GET /version on Task.Run, off the UI thread;
an empty address probes the default one, matching what the poller actually uses; 401 →
认证失败, timeout / refused connection get their own Chinese reasons, and the result line is
colored with SystemFillColorSuccess/CriticalBrush). Plumbing:
settings-plumbing.md §6.
src/UI/TaskbarWindow.cs: ResizeBackBuffer, RenderState
DXGI rejects it with DXGI_ERROR_INVALID_CALL while 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 a Start()-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 through ResizeBackBuffer; the wrappers live on RenderState, never in
locals. SetTarget(null) alone drops only the context's reference.
src/UpdateChecker.cs, src/VersionInfo.cs
UpdateChecker.CheckOnce is kicked off at the tail of OnStartup: thread-pool fetch → UI
thread UpdateAvailableDialog (iNKORE modern window, same family as ConsentDialog).
版本号唯一来源 = csproj <Version> (VersionInfo.Current reads the SDK-generated
AssemblyInformationalVersion; release.yml enforces tag == csproj). Two sources:
github= anonymousapi.github.com/.../releases/latest(must send a User-Agent or 403);cnb(default) = read the WEB layer's/-/releases/latest307 redirect (theLocationheader carries/-/releases/tag/<tag>directly — the GitHub convention, a single HEAD request, no HTML parsing; if the redirect disappears, fall back to scraping the releases list page's tag links for the max version). The CNB OpenAPI is anonymously 401 (every releases endpoint in the official swagger declares BearerAuth), so the API is not an option.
The system proxy applies (do NOT set Proxy=null the way ClashSampler does — the update check
faces the public internet, and the user's proxy helps rather than hurts).
A first attempt that cannot REACH the host is retried once, 5 minutes later
(RetryDelay / ScheduleRetry: a one-shot System.Threading.Timer, never a sleeping pool
thread). The logon launch happens before Wi-Fi/VPN/DHCP is up often enough that a transient
miss would otherwise cost the whole session's prompt. The gate is IsNetworkFailure — the
DNS/connect/timeout/TLS-handshake WebExceptionStatus family plus a nested
SocketException/IOException — deliberately NOT an HTTP answer we cannot use (403 rate
limit, 404, changed page structure): five minutes changes nothing about those, so
ProtocolError/TrustFailure are not retried. Single-shot by construction: the retry passes
canRetry:false, so a second failure is log-only until the next launch. The retry re-reads the
LIVE config, so 检查更新 OFF during the window cancels it (off must mean zero traffic) and a
源 switch is honored.
settings.yaml keys: updateCheckEnabled (null=开), updateSource (null=cnb, only
"github" is written), ignoredUpdateVersion. "不再提醒" skips only that specific
version — a newer version still prompts. Three buttons: 立即更新 (opens the release page) /
不再提醒 / 稍后. All exceptions are log-only, never fatal; a failed check is silent
(更新检测:… 连接失败,5 分钟后重试一次 / … 读取失败(下次启动重试) are the only traces).
src/UI/Details/RamDetailView.xaml.cs
A {Binding} on a Run throws XamlParseException at startup. Named Runs are set in code;
DataTemplates use two TextBlocks.
src/UI/TaskbarWindow.cs: Draw, IsDeviceLost/DeviceGone/OnDeviceLost,
CreateDeviceResources/ReleaseDeviceResources/RecoverDevice, ResizeBackBuffer
The report (2026-08-08, 崩溃报告 #1..#25 — one per tick, WndProc msg=0x113): a GPU driver
update removed the device under the running overlay, every tick's Draw threw
Win32Exception … 存在可以恢复的演示错误 (D2DERR_RECREATE_TARGET) at EndDraw, and the
WndProc guard funnelled each one into CrashReporter — a crash dialog per second for a
condition D2D itself calls recoverable.
Two separate defects, both fixed here:
-
The code was unreadable by the time it was an exception.
ctx.EndDraw()is DirectN's throwing extension: it raises aWin32Exceptionwhose HResult is E_FAIL (0x80004005) — the actualD2DERR_RECREATE_TARGETis gone, so no handler can separate a driver update from a real bug.DrawandResizeBackBuffernow call the raw interface (ctx.Object.EndDraw(IntPtr.Zero, IntPtr.Zero),s.SwapChain.Object.Present(0, 0),…ResizeBuffers(…)), classify the HRESULT themselves, and onlyThrowOnError()on anything that is not a device loss (a genuineDXGI_ERROR_INVALID_CALLstill reaches the crash log — that path is what found the 2026-07-30 resize bug). Classification is the known D2D/DXGI codes plus a catch-allID3D11Device::GetDeviceRemovedReason()probe (DeviceGone) — D2D answersD2DERR_WRONG_STATEonceBeginDrawhas already failed on a removed device, and a failure that arrives as an exception has no HRESULT left at all. -
Nothing could rebuild the pipeline. Every device resource lived in
Start()locals or onRenderStatewith no construction path other thanStart()— andStart()only runs again after the window dies (explorer restart), so a lost device meant a dead overlay until the user restarted the app. The pipeline now lives entirely onRenderStateand is built byCreateDeviceResources(s, hwnd, w, h, dpi)— the only construction site, called byStart()and byRecoverDevice. A loss latchesRenderState.DeviceLost(OnDeviceLost, logged once),Drawearly-returns, and the 1s tick callsRecoverDevice: release → recreate → re-tint brushes → redraw. Window, taskbar embed, sampler and geometry all survive, so recovery is one frozen frame instead of the explorer-restart dance. Failure is expected while the driver is still coming back (the machine may sit on the basic display driver mid-install): the latch stays set and the next tick retries, logging the full stack on attempt 1 and then every 30th attempt.Start()'s own creation is wrapped the same way — a failed init enters the message loop with the loss latched rather than throwing the window away.
Non-obvious pieces, each verified against a forced loss (a temporary test hook, since a driver update can't be staged):
IDCompositionTarget/IDCompositionVisualmust be released deterministically —Marshal.FinalReleaseComObjectinReleaseDeviceResources(ReleaseRcw). DirectComposition keeps the HWND bound to its target until the target OBJECT dies, and since DirectN generates noIDisposablewrapper for either interface, nulling the field only makes the RCW collectable: the rebuild then fails withDCOMPOSITION_ERROR_WINDOW_ALREADY_COMPOSED. The first in-place recovery attempt hit exactly this.ResizeBackBufferearly-returns whileDeviceLostbut its callers still record the new size/DPI —RecoverDevicebuilds the swap chain from those fields, so a DPI or layout change during the outage is deferred, not lost.HandleDpiChange'sSetDpiand the fours.DComp.Object.Commit()sites are?.-guarded becauseReleaseDeviceResourcesnulls every pipeline field (which is also what makes them safe mid-rebuild).- The DirectWrite formats are device-INDEPENDENT (they come from the DWrite factory) and
are deliberately NOT part of the pipeline:
CreateDeviceResourcesnever touches them. WM_DESTROYtears down through the sameReleaseDeviceResources, so a window destroyed mid-rebuild (explorer restart) or after a failed init releases whatever exists; the state is stashed on the HWND before the pipeline is built precisely so that path is reachable.
src/UI/TaskbarWindow.cs (SetFloatingMode, Start's floating branch, ApplyFloating*,
FloatingReposition, the WM_LBUTTON* drag), src/Interop/WindowBackdropInterop.cs,
src/App.xaml.cs, src/UI/Settings/SettingsWindow.xaml(.cs)
设置 → 外观 → 悬浮模式 turns the overlay into a free-floating desktop widget: no
SetParent, the taskbar keeps no copy, and the same window class, layout, drawing,
hit-testing and sampler are reused — only the background is added. Drag it anywhere (a drag is
not a click: the press only opens a popup if it never left a 4-DIP threshold), and the new home
goes to settings.yaml (floatingX/floatingY, screen px, written from the drag-end callback).
The widget's body is its own drawn CARD, and that is a measured decision. It began as the
DetailWindow's material: WindowBackdropInterop.SetAcrylic is the same accent call
FluentWpfCore's WindowMaterial(UseWindowComposition=True) makes for the popup
(SetWindowCompositionAttribute + ACCENT_ENABLE_ACRYLICBLURBEHIND) with the same tint
constants, verified against the popup with a checkerboard probe + BitBlt statistics (both land
on p50 224 / p90 226 while the raw pattern shows edge energy 22.2 → widget 4.8). The reported
"悬浮窗口四周的阴影" ended it: the blur is drawn by DWM BEHIND the window and can only be
SHAPED by DWM's corner-rounding opt-in (DWMWA_WINDOW_CORNER_PREFERENCE = ROUND) — and that
opt-in is what makes the system draw this frameless popup as a framed window, border and
drop shadow included. Three live probes settled it on 26200: DWMWA_BORDER_COLOR = DWMWA_COLOR_NONE takes the 1px outline but not the shadow (checked by eye); DWMWA_NCRENDERING_POLICY = DWMNCRP_DISABLED
plus SWP_FRAMECHANGED changes nothing at all; and the rounding preference itself, set to
DONOTROUND, removes the shadow AND the rounding together. Rounding it ourselves is no way out
either: SetWindowRgn with a rounded region (verified with WindowFromPoint — the corner
pixels stop belonging to the widget) DOES clip the overlay's own D2D output (the hover fill came
out rounded, and a 40px test radius made it obvious) but leaves the blurred backdrop square.
So the widget paints the material's TINT itself — the same shared constants at the same 0xCC
alpha, no blur — the frame stays off (SetCardFrame), and the corners come from the region
(SetRoundedRegion, radius = the drawn radius + slack, change-gated per tick). The 1px outline
that DWM painted as part of that frame and took away with it is now drawn by the overlay
itself (CardBorderWidthDip in the card pass, alphas in WindowBackdropInterop) — a self-drawn
edge cannot drag the shadow back in, which is the whole point of not asking DWM for a frame. The
modern DWMWA_SYSTEMBACKDROP_TYPE path was already rejected on measurement — flat solid for a
never-activated WS_EX_NOREDIRECTIONBITMAP window (numbers at the code site).
Six things this had to get right, each found by verification rather than reasoning:
-
The form is creation-time state and floating stays a SINGLETON even with N taskbar overlays. Parent, ex-style and backdrop are fixed at HWND creation. The manager stops every embedded entry before creating the floating one, and restores the selected embedded set on the way back; it never broadcasts “become floating” to N windows.
ConsumeQuickRestart()keeps the intentional rebuild backoff short. -
WS_EX_TOPMOSTis decided at creation. It rides inWS_EX_COMPOSITE_EX, and demoting on the first tick would show the widget above every window for up to a second — so置顶显示off clears the bit in the ex-style beforeCreateWindowExW. Promoting later is subject to the foreground lock (§7), henceApplyFloatingTopmostverifies and the tick re-asserts (idempotent: oneGetWindowLongPtr). -
The widget needs its own popup edge.
GetTaskbarEdgerecognises the floating form byGA_ROOT == overlay(note:GetParentreturns 0 for the reparented popup form —GA_PARENTis the one that follows the link) and then picks the roomier side, so the flyout opens below a widget in the upper half and above one near the bottom, using DetailWindow's two existing growth modes. -
置顶显示 OFF must be ACTIVATABLE — the one exception to the no-focus-steal rule (§6). Windows keeps the FOREGROUND window at the top of the non-topmost band, so a click can only bring the widget back above the app the user is working in by ACTIVATING it; with
WS_EX_NOACTIVATE(andMA_NOACTIVATE) it stayed buried for the rest of the session — the reported "取消置顶后窗口始终在最下面,即使点击窗口也不会覆盖在他之上的窗口". So that form dropsWS_EX_NOACTIVATEat creation and answersMA_ACTIVATE; an explicitHWND_TOPwas tried first and is silently clamped while another window holds the foreground (measured with a cover window activated over half the widget: the covered half kept belonging to the cover), and it turned out to be unnecessary — activation alone raises it (re-measured with the raise disabled). The live 置顶显示 toggle moves both ex-style bits (ApplyFloatingTopmost). Consequence to know about: a fully covered non-topmost widget can not be clicked back at all (it has no taskbar button) — Win+D or an app restart brings it back, and the settings card says so. -
The widget draws edge-to-edge.
DrawHorizontalinsets its content vertically (pad) because a taskbar form's window IS the whole band — the pad is what keeps the hover fill and the hairlines off the band's edges. The floating widget has no band: its window edge is the content edge, and since the fill already ran edge-to-edge horizontally (a group's left/right edges are the window's), the same pad read as the reported "左右边缘没有间隙,但是上下有明显的间隙(hover 的变色覆盖不到)". The pad is therefore 0 in the floating form (the constant, and why the mid line is unaffected, are at the code site); the row heights grow by that much, so 悬浮模式's rows are slightly taller than the taskbar form's. Follow-up from the same report: the fill is a plain rect there — with the fill flush to the edge, the 6 DIP radius carved visible arcs out of it where it met the top/bottom edges. The widget's own corners still read as rounded, because the rounded WINDOW REGION clips the fill at the window (the same radius the card is drawn with), so the outer corners follow the widget instead of an arc. The taskbar forms keep the radius: their fill floats inside the band, where the rounding is what makes it read as a taskbar item. -
The widget and its detail windows move as a pair — except when the widget dodges. The popup is placed against the widget (
GetTaskbarEdgepicks the roomier side) and is then CLAMPED into the monitor work area — so on a screen too short to hold it beside the widget it lands on the widget, and pinning grows it further (the band shifts the window up by its own height). The placement is not retried; instead the widget steps aside. App publishes the open detail windows' HWNDs (SetFloatingKeepOut→WM_APP_SET_FLOAT_KEEPOUT: open/close/pin/unpin and everySizeChanged/LocationChanged, coalesced onto one Background-priority push — a synchronous push on the close of a column TOGGLE would send the widget home for the few ms before the replacement popup opens, a visible blink), while the RECTS are read on the taskbar thread, so "same windows, one moved/resized" needs no separate notification and the tick re-derives anyway.ComputeFloatTargetthen picks the position every tick and every push, in this order: home, if no open window covers it (the "关闭/取消固定后归位" half); otherwise stay exactly where it is, if no window covers its current spot; otherwise step vertically clear of the windows covering either spot (nearest side that fits the work area, 8 DIP of daylight, X untouched). The middle rule is load-bearing, and it took a report to find: the popup is placed clear of the widget's live rect while the home stays put, so deriving every open from the home alone re-reads a popup that still clips a few px off the home (it was placed clear of the DISPLACED widget, not of the home) — the reported "反复切换 detail 后悬浮窗不停向一侧移动", one step per column switch until it hit the screen edge. Equally load-bearing: a dodge is never written back as a home (that is_floatingX/Y, moved only by a drag), which is what makes going back need no bookkeeping, andStartrecords the spot it computes as the home (left at-1, the derivation would measure from the LIVE position and ratchet the same way). A HELD button suspends the derivation — the drag writes the live position straight from the cursor and only publishes the new home on release.The other half of the pairing: while the user drags the WIDGET, the detail windows travel with it. The taskbar thread reports every step of the drag (
FloatingDragged, in the same physical px the window itself was moved by — clamp included) and App hands it to every open detail window (DetailWindow.FollowFloatingDrag), which moves byGetWindowRect+ a rawSetWindowPosin that same currency and clamps itself into its own work area. Two things that must stay true: it is a DRAG-only report (a dodge is the one widget move that must not carry them — that move exists to separate the two), and a pinned window's saved pre-pin spot (_prePin*, restored on unpin) shifts by the same delta, or unpinning would land the flyout back where the widget used to be. With out-of-bounds OFF, a formation pushed against an edge deforms there and keep-out settles after the drop; ON removes the widget's ordinary clamp, while detail windows still keep their own work-area clamp.
Home validity and out-of-bounds (current): FloatingX/Y are a nullable pair with an
independent _floatingHomeValid; negative coordinates are VALID, including left/top monitors.
Start selects the work area nearest the saved home instead of the primary taskbar. Creation,
ordinary dragging and ordinary FloatingReposition clamp only when
FloatingAllowOutOfBounds == false. Turning the option off clamps live position AND home and
persists that normalization once; a slide/dodge still never writes home.
透明度 (设置 → 外观 → 悬浮模式 → 透明度) is brush-alpha scaling in ApplyTaskbarTheme,
NOT WS_EX_LAYERED/SetLayeredWindowAttributes: the widget is a DComp-bound flip-model
swap chain, where DWM's layered-window alpha is not a supported combination, while scaling
the alphas of the brushes the pass already owns is deterministic, free, and rides every
existing re-tint path (live update message, the tick's theme flip, device recovery — all of
them call ApplyTaskbarTheme, which reads _floatingOpacity through s.Floating, so
nothing new had to be plumbed per path). Scope — BACKGROUND-only, settled over two wrong
first cuts: the card's alpha IS the value (cut one scaled the popup tint's 0xCC on top of
it, so even 100% read as translucent; the see-through tint look is still reachable at 80%
on the slider), and the card's outline scales with it, while the TEXT, labels,
hover/selection fills and separators keep their FIXED alphas (cut two faded the text with
the card and mid-range numbers washed out — the user's rule: 文字不要有透明度; fixed fills
also keep the hover feedback from vanishing exactly when the widget is subtle). The card
RGB stays the popup's tint (CardRgb*); CardAlpha remains the POPUP's constant,
untouched. The floor is 0.2 (SetFloatingOpacity clamps) — it keeps a hint of card
grounding the solid text. The taskbar-embedded forms keep k = 1 — they must match the
opaque taskbar surface. The slider is settings-page only: a percent value has no
right-click-menu shape, and the menu's Opened re-read rule doesn't apply to it.
Verified end-to-end on 26200 by driving the real UI (right-click → 设置 → switches) with UI
Automation: blur/tint/text/corners, drag = move + persist + no popup, 置顶显示 on/off live,
模式 flip re-embedding into Shell_TrayWnd and returning to the remembered position, and
taskbar mode unchanged.
That run predates the rule in AGENTS.md ("Build & run" — UI testing, automated
driving included, is the USER's job). The findings stay here as the record of why this code is
shaped the way it is; the method is not an instruction to repeat — UI-observable questions go to
the user as a short checklist.
src/Interop/CrashTraceInterop.cs (VEH + message ring + step markers),
src/UI/CrashReporter.cs (installs it), src/UI/TaskbarWindow.cs (the ring/step call sites),
src/App.xaml.cs (CheckOverlayHealth), src/Sampling/SystemSampler.cs (Timed)
The 2026-09-22 卡死 (a ~30 s freeze of a live process — 0 CPU, no messages, no log line, then a
silent recovery) produced nothing in the app log, because the crash was a native
0xC0000005 raised in JIT/stub code: .NET 4+ does not deliver corrupted-state exceptions to
managed catches, so AppDomain.UnhandledException, the Dispatcher hook and the WndProc catch
all stayed silent — the only trace was an Application Error event and a WER dump (which is also
what froze the process: WerFault suspends it, dumps it, and a reflection of it appears as a
second task_monitor.exe process with 0 CPU whose parent is the app — that reflection is NOT a
second instance, do not chase it). What finally named the bug:
CrashTrace's vectored exception handler (logs/native-crash.log, its own file, plainFile.AppendAllText— neverLogger: the fault may be inside the logger holding its lock). It runs before SEH on the faulting thread and records the exception code, the faulting address with module/offset, the access type + touched address, the registers (Rcx= first argument), a raw stack scan of plausible code addresses, and a best-effort managed stack. It returnsEXCEPTION_CONTINUE_SEARCH: pure observation, Windows keeps handling exactly as before. Throttled to 4 records per run, and it filters by exception CODE — managed exceptions travel as0xE0434352and would otherwise drown it.- The message ring (
NoteMessageat the top of WndProc) and step markers (NoteStep, called by everyTimedsampler step, the tick's phases and theStart()steps — the NAMES only, no timings): a fault inside the dispatch path leaves no managed frame to read and a JIT frame cannot be unwound, so "what was this thread doing" has to be recorded as it goes. Both are one array/field write — no allocation, no lock, no logging. - The UI-thread watchdog (
App.CheckOverlayHealth, every 2 s): a stalled tick and a vanished overlay HWND are the two states the taskbar thread cannot report itself. It logs the episode's start and end (覆盖层心跳停滞/恢复,覆盖层窗口已消失…), which is what bounded every stall to the WER window and caught the window-gone zombie. - The sampler's slow-step log (
采样慢步骤,SystemSampler.Timed): fires only when a step exceeds 150 ms, so the hot path stays silent while a stall still leaves the offending step's NAME behind (a USB disk that slept insideIOCTL_DISK_PERFORMANCEis the known suspect). TheStart()per-step MILLISECOND timings that lived here during the hunt are gone: only the phase names remain (they feed the ring), and the baseline those timings produced is kept as a comment at window creation (D3D/D2D/DComp ≈ 0.5 s, everything else single-digit ms).
Two more things that belong to this mechanism: the WndProc catch does not try to handle a
corrupted-state exception — .NET never delivers those to a managed catch, so there is nothing to
catch there and the VEH record is the only account of such a fault. And
HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\task_monitor.exe may be
configured to keep mini dumps in logs/dumps (a machine-level debugging aid, not part of the
app's contract — safe to delete; there is no debugger on this machine, so the VEH record is what
is actually readable).
src/UI/TaskbarWindow.cs (the FloatEdge*/slide helpers after ClampFloatingToWorkArea,
FloatingReposition, the WM_LBUTTON*/WM_MOUSELEAVE/WM_APP_DESELECT/
WM_APP_SET_FLOAT_EDGE_HIDE handlers, Start's floatDockEdge), src/App.xaml.cs,
src/UI/Settings/SettingsWindow.xaml(.cs)
设置 → 外观 → 悬浮模式 → 贴边隐藏 (also the right-click menu's checkable): a widget dropped within 8 DIP of its monitor work area's 左/右/上 edge docks there and slides out, leaving 8 DIP of card visible; hovering the strip slides it back out (peek), the mouse leaving slides it away again. The bottom edge is deliberately not dockable — the taskbar lives there.
The shape to hold onto:
- The dock is derived state, never stored.
FloatEdgeOfuses ABSOLUTE distance ≤8 DIP (left/right before top), so a far negative out-of-bounds home is not accidentally docked. It is evaluated at drag end, the settings/menu toggle andStart()(a docked home reopens hidden). The drag-end callback persists the DROP position untouched; the dock itself is derived, while the separate out-of-bounds setting only controls ordinary clamping. The hidden rest is recomputed from the edge every tick (FloatHiddenPos), and a drop away from every edge releases the dock.FloatingHomeWorkArearesolves the monitor from the HOME POINT for drop/toggle, hidden rest, peek and dodge — NEVER the live rect. At a shared monitor edge, hiding leaves most of that rect on the neighbour;MonitorFromWindowwould therefore re-dock to the neighbour's FAR edge on the next tick (2026-10-29: primary right 2560, hidden x 2548 → secondary right 5110). Ordinary dragging and undocked positioning still use the HWND. - The slide is a first-class owner of the position.
FloatingRepositionearly-returns while a slide is in flight AND while a button is held — the press half matters because a press on the hidden strip lives OUTSIDE the work area until the drag threshold passes, and the reposition clamp would otherwise yank the widget in from under the pointer mid-press. A slide is the widget's OWN move, like the dodge: it never reportsFloatingDragged(the detail windows must not follow a hide/peek) and never writes the home. The animation is a 10 msWM_TIMERstep (StepFloatSlide, ease-out: a third of the remaining distance, 2px floor — ~100–150 ms typical), killed on completion/press/destruction. At a monitor seam the home work area is also a CLIP:FloatingVisibleClipintersects the live rect with it in window-relative coordinates, andWindowBackdropInterop.SetRoundedRegionANDs that rectangle with the rounded region at creation, every slide step and every reposition. Otherwise the hidden body remains plainly VISIBLE on the neighbour even when its coordinates stay stable. A press removes the clip for dragging;CursorOnFloattests only the visible intersection so the clipped-out neighbour rect cannot disarm the peek. - The hidden rest suppresses the dodge (
ComputeFloatTargetis not even consulted): an off-screen widget has nothing to keep clear of, and a pinned window parked on the strip must not walk it along the edge. A PEEK derives the normal target (dodge included — a pinned window may have parked on the home while the widget was away), andWM_MOUSELEAVEre-hides it — suppressed while a column is selected (its flyout is why the widget is out); the flyout's close (WM_APP_DESELECT→TryRehideFloat) re-hides once the cursor is elsewhere. - The peek is armed, not naive. Docking is done with the cursor INSIDE the widget, and it
can land exactly on the exposed strip — the first stray mouse move there would pop the
widget straight back out. So the end of a hide slide arms the peek only when the cursor is
NOT on the widget (
FloatPeekArmed); a cursor that docked onto the strip stays disarmed until oneWM_MOUSELEAVE. A region change can ALSO synthesize that message under a stationary cursor (the 2026-10-29 40–90 ms peek cancellations), so re-hide now requiresCursorOnFloat— the VISIBLE clip — to be false; re-arming on either kind of leave remains safe. - A press during a slide is not a click. The press snaps the slide to its target first
(
FinishFloatSlide— the grab offset needs a stable position) and flagsFloatSwallowClick: the widget was still moving under the cursor, so the slot the release lands on is not the one the user aimed at. Dragging out of the hidden strip works — with out-of-bounds OFF the drag clamp follows the grab point into the work area (ON leaves the drop position alone), and the drop re-evaluates the dock (pull pastEdgeSnapDipinside to release). - Side taskbars (Win10-only, deprioritized): the edges are the WORK area's, so docking at an edge that the taskbar occupies slides the widget under it; with 置顶显示 on the strip then draws over the taskbar. Accepted — Win11 (the supported platform) has no side taskbars.
The card greys out outside the floating form (FloatingEdgeHideCard, the FloatingTopmostCard
rule), the menu item mirrors it with the same Opened re-read, and an open settings page is
pushed back via SyncFloatingEdgeHide. Verified live: dock left/top, peek/re-hide cycles,
drag-away release, re-dock from peek, and a clean log throughout.
src/UI/TaskbarWindow.cs (FullscreenAppOnScreen, UpdateFullscreenHide, the tick's
floating block, FloatingReposition's dockedRest/hiddenRest split, Start's
bornFullscreenHidden), src/Interop/ShellInterop.cs (SHQueryUserNotificationState),
src/Interop/WindowBackdropInterop.cs (DwmGetWindowAttribute), src/App.xaml.cs,
src/UI/Settings/SettingsWindow.xaml(.cs)
设置 → 外观 → 悬浮模式 → 全屏时隐藏 (also the right-click menu's checkable): a fullscreen app
in the FOREGROUND on the widget's own monitor (borderless-windowed game, F11 video,
slideshow) hides the widget until the foreground stops being fullscreen. A maximized
window never counts — that is the whole reason the probe reads
DWMWA_EXTENDED_FRAME_BOUNDS (WindowBackdropInterop): the VISIBLE bounds stop at the work
area, while GetWindowRect overshoots the monitor by the invisible resize borders and would
make every maximized window read as fullscreen. (The no-DWM fallback keeps the raw rect but
skips IsZoomed windows for exactly that reason.) The one case a rect probe cannot see is
EXCLUSIVE-D3D fullscreen — the swapchain bypasses DWM, so the window rect is unreliable —
and that is what SHQueryUserNotificationState's QUNS_RUNNING_D3D_FULL_SCREEN covers,
gated to "the game's window is on the same monitor" so a game on another screen leaves this
one's widget alone. Excluded up front: our own process's windows (the pinned detail popups
are topmost; 设置 maximized must not count) and the desktop — clicking it makes
Progman/WorkerW the foreground window with a rect that IS the whole monitor. Any query
failure fails open (the widget stays visible).
The rules that keep it from tangling with everything else that owns the widget:
- Hiding is
SW_HIDEand onlySW_HIDE. No slide (a slide to some edge would crawl across the screen during the game's opening frames), no dodge, no home write-back — the probe is a THIRD owner beside the dodge and the slide, and it owns visibility alone.UpdateFullscreenHidecallsShowWindowon the state TRANSITION only (SW_HIDEin,SW_SHOWNOACTIVATEout) and logs that transition once (per-tick paths never log, §5). - A hidden widget is an INVISIBLE running one, not a parked one. Timers fire on hidden
windows, so the tick, the watchdog heartbeat (
LastTickTickCount) and the sampling all continue — App's zombie check keys onIsWindow, never visibility — and the charts come back with continuous history.FloatingRepositionkeeps maintaining the position (home, clamped only when out-of-bounds is OFF, plus docked hidden rest) while hidden. Do NOT pause the timer or skip the heartbeat stamp while hidden: that would trip the "覆盖层心跳停滞" watchdog within ~4 s. - The dodge is suppressed while hidden —
FloatingReposition'shiddenRestisdockedRest || FloatFullscreenHidden, the same rule as the docked rest: an invisible widget has nothing to keep clear of, and recovery must land on the home. Note the split: onlydockedRestfeedsFloatHiddenPos— that helper's edge-0 fallthrough would treat a fullscreen-hidden un-docked widget as a TOP dock. - A held button defers the hide (
want && FloatPressedreturns): the drag keeps writing the position from the cursor, and the widget must not vanish out from under an in-progress press. The next tick re-probes. The SHOW direction never waits — restoring over a live press is harmless. Start()opens born hidden when a fullscreen app is already in the foreground (the flip happened mid-game, explorer restarted mid-game): creation already leaves the window hidden, soStartsimply skips itsShowWindowand pre-matchesFloatFullscreenHidden— no one-frame flash over the game, no duplicate transition log. The same shape as a docked home reopening hidden (§38).- Peek/click machinery is untouched by design. A hidden window receives no mouse input, so no peek can fire while hidden; on recovery the window reappears wherever the tick had kept it (home or docked hidden rest — the strip then peeks as usual).
The toggle handler (WM_APP_SET_FLOAT_FULLSCREEN_HIDE) just runs the probe once — ON with
a fullscreen foreground hides immediately, OFF restores unconditionally — and the tick
carries it from there. The card greys out outside the floating form
(FloatingFullscreenHideCard), the menu item mirrors it with the same Opened re-read,
and an open settings page is pushed back via SyncFloatingFullscreenHide.
src/UI/DetailWindow.xaml.cs(ctor 的 Win10 分支、ApplyTheme、ShowColumn 末尾的重呈)
症状(2026-09-29,Win10 实机):detail 弹窗打开后顶部静态内容("CPU" 标题、图标、pin 按钮) 经常缺失,有时整窗空白,要等下一次采样刷新后动态内容才出现;且窗口始终是直角。
根因有两层,都在 Win10 特有的 accent 路径上:
- 首帧被 DWM 丢掉。 FluentWpfCore 的
WindowMaterial在SourceInitialized就一次性调SetWindowCompositionAttribute(ACCENT_ENABLE_ACRYLICBLURBEHIND)+ 透明重定向表面 —— 而此刻窗口 还停在 -10000,-10000 的离屏出生地(防未样式化首帧闪烁的设计)。这个未文档化 API 在 Win10 上 对"离屏创建、随后裸SetWindowPos挪上屏"的窗口有已知竞态:首次合成只出模糊底、WPF 内容不上屏。 之后 WPF 只按脏区域重绘 —— 每 tick 刷新的动态元素(大字百分比、图表、进程行、统计值所在的 TextBlock)逐个恢复,从不 invalidate 的静态元素(标题、图标、pin 按钮)就一直缺失。这精确解释了 "只有表头消失、刷新后数字回来"的截图形态。 - 圆角 API 不存在。
WindowCorner="Round"只是DWMWA_WINDOW_CORNER_PREFERENCE=ROUND,Win11 专有,Win10 上DwmSetWindowAttribute静默失败。 而 region 不能给 blur 塑形(§36 实测)——"保留亚克力 + 圆角"在 Win10 上根本无解。
修复(仅 TaskbarWindow.IsWin11OrLater 为 false 时;Win11 路径逐字节不变):
- ctor 把
windowMaterial.MaterialMode置为None—— FluentWpfCore 的Apply()变成 no-op, accent 整条路径(含其首帧竞态)不复存在;同时开AllowsTransparency(分层窗口)——这是 Win10 上唯一真正的逐像素 alpha 通道。别走"手动复刻 FluentWpfCore 的透明表面" (CompositionTarget.BackgroundColor=Transparent+DwmExtendFrameIntoClientArea)那条路: 第一版就是它,alpha-0 像素在没有 accent 托底时渲染成黑色——整窗像蒙了层黑纱 (0xCC 的 tint 叠在黑底上),圆角外一圈黑边(同日实机反馈)。分层窗口下圆角是带 AA 的 真透明,RootBorder的CornerRadius=8+ 1px 描边直接生效。 - 卡片由 WPF 自绘:Win10 下
RootBorder拿共享 tint 常量(LightAcrylicTint/DarkAcrylicTint, alpha 提到 0xFF —— 不透明:没有模糊托底时半透明只会读成灰纱,2026-09-30 用户拍板), 描边按CardBorderAlpha*规则(浅色卡黑线 20%、深色卡白线 12%)——与悬浮小组件同一套常量、 同一个"无 blur 也要圆角"的取舍(§36)。ApplyTheme按 OS 分流,主题切换两条路径都覆盖。 ShowColumn定位完成后在 Render 优先级补一刀InvalidateVisual()+SWP_FRAMECHANGED: 即使首帧仍被吞,也会在用户察觉前以最终屏上位置自愈(Win11 不补 —— 不碰在用的路径)。
src/UI/DetailWindow.xaml.cs(Prewarm)、src/App.xaml.cs(prewarm 排队的 _stopping 守卫、
ClearStaleShutdownSentinel 调用点)、src/UI/TaskbarWindow.cs(ConsumeShutdownSentinel /
ClearStaleShutdownSentinel)
2026-09-29 实机事故(Win10,崩溃对话框 + logs/native-crash.log 两条 VEH 记录):新实例启动
仅 2s 就"检测到 shutdown.sentinel——构建触发的优雅退出",随后弹 UI 线程未处理异常
(System.NullReferenceException,栈顶是 WPF 框架内部的
DeferredAppResourceReference.GetValue,本进程帧是 DetailWindow.Prewarm 里的
ContentHost.Content = view)。
两层原因,对应两道修复:
- 残留 sentinel 杀死了新实例。 构建流程连跑两次
touch shutdown.sentinel而旧实例只消费了 第一个,剩下的文件躺在原地,被下一次启动的新实例的 1s tick 当成"立即退出"指令。修复:App.OnStartup在拿到单实例 mutex 之后调TaskbarWindow.ClearStaleShutdownSentinel()。 放在 mutex 之后是关键:此刻能证明没有存活兄弟实例,磁盘上的文件只可能属于某个已退出的 上一实例(正常构建流里,旧实例永远在新实例启动前消费掉它自己的 sentinel);而在 explorer-restart 的Start()重入路径上绝不清——那可能删掉一个正当的、针对本实例的 构建退出请求。 - Background 优先级的 prewarm 在退出流程中才执行。
OnExit用Thread.Join等任务栏线程 拆除 overlay,而 STA 线程的 Join 会继续泵消息——排队的 Background 级 DispatcherOperation (启动时排的Prewarm)就这样在退出日志之后 40ms 才被派发,撞上已在拆除的应用资源, WPF 的延迟资源引用解析 NRE。修复是双保险:排队回调开头if (_stopping) return;(常见情形 直接跳过),且Prewarm整体 try/catch 只记Logger.Warn——它是纯优化,永远没有资格弹 崩溃框;try/catch 同时盖住"回调进入时还没_stopping、循环内Dispatcher.Invoke的嵌套帧 泵又把 Shutdown 派发进来"这种重入形态。
同类风险已知仍存在但不处理:若退出时恰好有开着的 detail 窗口,RefreshDetails 的排队刷新
理论上也能在 OnExit 的 Join 泵里踩到同一个框架 NRE——尚未有实机报告,且窗口打开时用户本就
在场,留待真出现再说。
src/UI/TaskbarWindow.cs(DrawHorizontal / DrawVertical 的 CardBrush 全幅填充;
ApplyTaskbarTheme 里 CardBrush 的 taskbar-form 角色注释)
症状(2026-09-30,Win10 实机,200% DPI 虚拟机):覆盖层区域里有时长期显示一个程序图标
样子的残影(半透明、位置固定、不随 tick 刷新变化),Win11 从没有。用户最初被误导的方向:
是某个活动元素"透"了过来——逐一排除:资讯和兴趣(DynamicContent2,窗口虽在但永远隐藏、
且用户已关闭)、托盘图标(位置对不上,托盘从 x=1419 开始)、桌面图标透出(枚举桌面 ListView
全部 8 个图标,没有一个落在任务栏下方)、覆盖层自己画的(网络列只画 ↑↓ 和文本)。
真正的机制是残留像素,而且是我们结构性无法清理的那种:
- 覆盖层身体除文字/分隔线外全透明(DComposition premultiplied,每帧
Clear()透明)。 - 它身下是
ReBarWindow32和Shell_TrayWnd的表面,而这两个窗口都带WS_CLIPCHILDREN:凡是被子窗口盖住的区域,父窗口永远不会在那里重绘。 - 于是覆盖层压住的那块父窗口表面,内容从被盖住的一刻起就被冻住——新表面重建 (explorer 重启)时可能带着表面池里回收来的旧像素(上个 explorer 的任务按钮/图标残片, 亮度不衰减,正好读作"某个程序的图标残影")。"有的时候"= 取决于重建那一刻池子里有什么。
- 没有任何办法擦干净:被 clip 的区域连
RedrawWindow/WM_ERASEBKGND都进不去, 唯一能变它的时机是表面重建——而重建正是带来下一批回收像素的时刻。任务按钮带收缩后、 覆盖层停靠前那个微秒级的空窗里理论上能对 ReBar 补一次重绘,但 Shell_TrayWnd 被 ReBar 整体盖住,那一层的脏永远够不着。
修法因此只有一个:遮盖。经典任务栏形态(!s.Floating && s.Classical)下
Draw 的第一步用 CardBrush(alpha 恒为 1)全幅填满窗口矩形,把身下的一切都盖死——与悬浮
小组件的自绘卡片(§36)、Win10 detail 弹窗的不透明卡片(§40)同一套取舍:没有可靠托底
时就不依赖别人的表面。Win11 路径不受影响,保持全透明。
底色颜色不能写死:第一版用了共享 tint(深色 0x202020),用户当场打回"太黑了,还是要
透明的效果"。移植 TrafficMonitor 的 auto_set_background_color
(TrafficMonitorDlg.cpp):每 tick 在任务按钮带与我们窗口边缘之间那 2px 缝隙处
(rc.left-1/rc.right+1,竖直任务栏取上/下沿)用 GetPixel(GetDC(NULL)) 读合成后的
任务栏真实颜色,实时染给底色画刷(SampleTaskbarBackdropColor;纯黑=重启瞬态,忽略;
颜色没变不动笔)。单像素裸读会被"操作任务栏"污染:explorer 会把按钮带瞬间重展压过缝隙,
那一 tick 读到的是任务按钮的颜色,底色就跟着来回轮换(浅色模式下刺眼)。三重防护:
3 像素平均(抗边缘抗锯齿)、逐通道阈值(近似色不动笔)、稳定期——新颜色必须连续
3 个 tick 一致才采用,亚秒级的按钮带重展永远到不了画刷。观感=无缝融入任务栏("透明"的
效果),实质=不透明遮盖,残影再无可乘。
停靠时在 RepositionOverlay 之后立即采样+重画一帧,第一帧可见画面就是无缝的;主题/壁纸/
透明度变化靠每 tick 的采样自然跟上。
附带事故(同日):explorer 重启还暴露了一个独立 bug——任务栏线程在 explorer 死亡期间 卡在某个阻塞调用里再没醒来(看门狗 17:45:02 报"覆盖层窗口已消失 6s 而 Start() 未返回"), 覆盖层永久丢失且 sentinel 不再被消费,只能任务管理器杀进程。修复见 §43(跨线程探活 + 停滞取证转储 + 45s 僵尸自愈重启)。
src/UI/TaskbarWindow.cs(BandResponsive;ClassicalReposition / RestoreMinWindow 的探活;
拆除路径的 NoteStep)、src/App.xaml.cs(CheckOverlayHealth 的取证转储与 45s 自愈重启)、
src/Interop/CrashTraceInterop.cs(RecentMessages)
事故(2026-09-30,§42 同日):explorer 莫名重启(pid 5128→1380,用户未操作——疑似崩溃后
WER 拉起),任务栏线程随之永久卡住:心跳停、覆盖层 HWND 消失、Start() 不返回、
重建循环死、sentinel 不消费,20+ 分钟无自愈,只能任务管理器杀进程。
机制(最终确认版,取证过程本身就是教材):SetWindowPos/MoveWindow 作用于别的线程
拥有的窗口时会同步等应答,explorer 拆除中永不应答 → 调用永久阻塞——这是最初的理论。
第一版(探活+worker)上线后用户复测依旧卡死,但看门狗新增的 CaptureStackOf(挂起
线程抓原生栈)给出两次 RIP=win32u 同偏移 + 消息环 0x113(WM_TIMER)×7 + 0x82 (WM_NCDESTROY)——再对照 _step(从不清除的残留标记)才看出真相:线程不是卡在调用
里,而是卡在空队列的 GetMessage 上。窗口被 explorer 拆除时,win32k 只派发了裸
WM_NCDESTROY(没有先行的 WM_DESTROY——2026-09-30 19:40 实机复现确认),定时器随
窗口死亡、队列变空,PostQuitMessage(只在我们的 WM_DESTROY 分支里发)从未发出,
Start() 永不返回,重建循环、心跳、sentinel 全灭。此前把 GetWindowRect 当凶手是被
_step 残留值误导(它停在 经典重定位:测带矩形)。
最终四层处置:
- 源头——裸
WM_NCDESTROY也走完整拆除。TeardownOverlay(恢复按钮带 / 杀定时器 / 释放设备管线,全部有界)从 WM_DESTROY 提取出来,两条消息都调用并PostQuitMessage(once-guard 幂等)。共享 SRUM 由 SamplingController 退休,绝不在这条 HWND 路径注销。 - 消息循环不做会同步进别人窗口的调用(硬化,保留)。按钮带移动、对外部窗口的
GetWindowRect/GetClientRect等经每个 CALLING THREAD 独立的NativeCallChannel(后台 worker、队列上限 16)执行,调用方用WaitForSingleObject有界等待(不用 CLR 的 STA 泵等待)。一块屏幕的请求阻塞不能堵住其他屏幕。例外:SetParent必须留在 消息循环上(worker 会死锁)。已知残留洞:悬浮模式的SHQueryUserNotificationState(到 explorer 的 RPC)未守护。 - 看门狗 5s 轻唤醒。窗口消失超 5s:
PostThreadMessage(WM_QUIT)到任务栏线程—— 空队列的GetMessage立即返回 0,Start()返回即重建(~7s);线程若真卡在调用 里,quit 在队列里等它返回,无副作用。 - 45s 僵尸判定 → 静默自愈重启(兜底,已验证)。
Logger.ReleaseHandle()(否则子 进程首批日志被FileShare.Read冲突吃掉)+ 先处置单实例 mutex 句柄(否则子进程 在父进程死前做存在性检测,把自己当第二实例静默退出——PID=368 之死)→Process.Start(UseShellExecute=false)拉起静默提权新实例(父提权→子提权,无 UAC,也不依赖可能正在重启的 explorer 的 shell 启动)→Environment.Exit(0)(不等 挂起的前台线程)。每次进程生命只触发一次。
另:重建间隙是合法的 tick 空窗,探测和 manager 重建循环都打心跳点。看门狗分别观察
采样线程与每个预期存在的覆盖层,取消/等待副任务栏不算 zombie;CrashTrace 的步骤、
消息环和栈也按 native thread id 读取,不能把甲屏的卡死记到乙屏。
src/UI/TaskbarWindow.cs(Start 的方向探测、ComputeLayout / VerticalSlotRect /
ComputeTargetSize、DrawVertical / DrawStripLines、CalcPositionVertical /
TaskbarBandCross、RepositionOverlay、GetBandSize、FindClassChild)
事故(2026-09-30):用户把任务栏切到 Windows 11 26H2 新出的左侧竖直模式,覆盖层仍按
水平网格算 —— Start() 的竖直判定当时写成 vertical = !floating && classical && …,而
Win11 原生竖直任务栏仍是 Win11 族(XAML DesktopWindowContentBridge 在,classical=false),
于是 vertical=false,窗口按「带宽 = 任务栏高度」落成 448×2168px:既高出 96px 的
任务栏列、压到桌面上,又因为子窗口矩形内所有点击都归自己,整条任务栏点不动了
(日志里的证据:任务栏族=Win11 竖直=False … 覆盖层=448x2168px)。
方向探针必须只看任务栏窗口的宽高比(TrafficMonitor 的 CheckTaskbarOnTopOrBottom:
宽 ≥ 高 = 水平),族与方向是两个正交的维度:竖直的可能是经典族(Win10 起),水平的也可能是
Win11 族(26H2 的 设置 → 任务栏位置)。
竖直任务栏的几何(都是「水平那一套转 90°」):
- 尺寸与列数:
ComputeVerticalLayout把「打包后的行」排成列网格——普通列 70 DIP, 网络那一列 84 DIP 且永远是流的最后一列,所以「几列放得下」是精确算术:floor(可用宽/70),有网络时1 + floor((可用宽-84)/70),上限 4 列(GroupWidths)。 宽 = 列宽之和,高 = 最高列的行数 × 行高。可用宽由VerticalAvailWidth从TaskbarBandCross(工作区左边到显示器左边、与任务栏矩形相交的保留带)减去 2 DIP 呼吸边 算出,存在RenderState.AvailWidthDip;ComputeTargetSize/Draw/HitTestSlot/UI 线程的ColumnCenterY全都用这一个值建同一个布局。绝不能用窗口实际宽度反推:69.9 DIP 的带在 100% 下 ceil 成 70 DIP,正好跨过双行阈值,会画出窗口从未按之定尺寸的形态。物理 尺寸仍用DpiScaleCeil向上取整,免得 125%/175% 下裁掉最后一列/行。 - 列高分配:按
ceil(总行数/列数)的顺序填列,并且只在「剩下的项还够填满剩余列」时 才换列;所以 7 行 2 列 = 4+3,而不是 7+0(第一列吃余数)。网络是 2 行一项,不会被拆开; 流可能用不满候选列数(例如 4 行按 4 列分时,2 行的网络块让分配只落在 3 列),窗口就按 实际列数定尺寸。 - 形态:宽度反过来决定列数与条带形态(循环,先夹宽度再建布局):
- 单列且窄于
STRIP_TWO_LINE_MIN_W(且窄于该布局的自然宽 70/84)→ 双行块:11px 标签 居中在上、13px 数值居中在下,速率用紧凑写法FormatCompact("11.4M",完整写法 ~56 DIP 塞不进 46 DIP 的条)。块 =STRIP_BLOCK_H(44 DIP):上下各STRIP_BLOCK_PAD(4) + 两行 +STRIP_LINE_GAP(1),行高由DrawStripLines反推。这个留白不是装饰:最初按 2×STRIP_H=32 DIP 无留白实现,标签和数值挤成一坨、块间分隔线像是两边都不属于,用户直接 反馈「布局不合理」;48 DIP 是宽松版,44 DIP 是随后「稍微紧凑一点」的结果,STRIP_LINE_GAP4→1 则是再之后「文案和数值之间间距少一点点」——STRIP_BLOCK_H(整体 疏密)与STRIP_LINE_GAP(标签↔数值)就是那两个旋钮。 - 单列 ≥ 阈值 → 单行条带(
STRIP_H=16 DIP/行,紧凑是它该有的样子);≥2 列 → 每格 70/84 DIP、一律单行(标签左、数值右——水平格子的成熟形态;70 DIP 若走双行块会 高得多),但行高用GRID_ROW_H=24 DIP,不再沿用单列的 16 DIP:16 DIP 装 13px 的 行盒(~17 DIP)本来就上下无余量,多列是「宽任务栏有地方」的形态,不该继承窄条的紧凑 (2026-10-17「多列的情况下边距就不用这么紧凑了,单列才紧凑」)。分隔线画在 行边界(列内r * RowH)和列边界(跨整格高),所以永远不会把标签和它自己的 数值切开;网络在单行形态是上下两行(↑ / ↓),用完整速率写法。 - 紧凑模式(设置 → 外观,
CompactLayout):上面这些疏密值全部换成紧凑档——水平格 70/84→65/78 DIP、行内 inset 5→3、多列行高 24→18、双行块 44→40(pad 4→2);单行条带的 16 DIP 不动(本来就是紧凑档)。多行块的高度与行距单独处理:GPU(两者)三行块与网络 ↑/↓ 四行块最初都占两个普通块高、按跨度均分行高——紧凑档缩块后富余摊进行间隙 (2026-10-17「GPU和网络里面的那几行之间的边距也要减小」),改自然行高(11px≈14.63、 13px≈17.29 实测,带 15/17.5)紧密堆叠、整簇居中后富余又堆簇边缘、块外边距不齐 (2026-10-19「有的边距大有的边距窄」),于是多行块高度一律缩到内容+本档 pad:紧凑档 GPU 56、网络 72 DIP(自然行堆);默认档 GPU 62.5、网络 81 DIP——跨度推导恰好落回普通块 的 17.5 pitch(原来块内行距 ~10 DIP 比 8 DIP 块距还大、块边缘还堆空间,同日「非紧凑模式 下 GPU、网络那里间距大了」),网络块两个模式都改画四行簇(DrawNetStripCluster,默认档 原来的两个半块之间 8 DIP 无分隔线的间隔是各形态里唯一的 ↑/↓ 额外间隔)。双行条带由此 按项目不等高(StripPosH/StripPosTop取代 行号×RowH,贯穿 rect/命中/分隔线/总高); 紧凑档块间恒 2+2 DIP、默认档恒 4+4 DIP;普通双行块(CPU 等)保持跨度推导。它是ComputeLayout尾部的compact参数(贯穿ComputeTargetSize/Draw/HitTestSlot/UI 线程访问器), 翻转走WM_APP_SET_METRICS即时重设窗口。紧凑档的下限是 13px Segoe UI 的实测文字宽 (最宽行「内存+100%」=57.7 DIP、最宽网速「1192.1 MB/s」≈71.3 DIP),取值与依据在TaskbarWindow常量区注释里——低于下限标签与数值直接相撞(NO_WRAP 不截断)。 - 事故/需求(2026-10-17):用户打开 26H2 加宽后的竖直任务栏(320px @200% = 160 DIP、
显示标签),覆盖层仍是 84 DIP 宽的单列、7 行(112 DIP 高),宽度全浪费。现在同一根
任务栏是 2 列 154×96 DIP(4+3 行 × 24 DIP);加宽/收窄任务栏时 1s tick 重算列数并
重设窗口(日志:
竖直任务栏条带变化 …(2 列网格))。
- 单列且窄于
- 锚点:
CalcPositionVertical=CalcPosition转 90°。靠左显示 off(默认)→ 托盘上方 (通知区那一端);on → 尊重用户选择固定在任务栏顶端,不做图标空间检查或托盘端回退; 遮住开始按钮时由用户自己在设置里改位置(2026-11-07 拍板),不能静默搬到另一端。 贴近开始按钮仍取ClusterTop上方,空间不足时夹到顶端而非回退;ClusterTop只取 Start / ReBar / 搜索宿主中可见且矩形非空的最靠上者,26H2 不可见旧 Start 占位不参与。 Widgets 预留(160px)在竖直任务栏不存在,故不适用。 - 每 tick 维护:
RepositionOverlay现在两族共用——先比宽高比判定方向翻转 (ReconfigureOrientation转置 + 重设缓冲 + 重锚),再纵向重算尺寸(缩放/DPI/任务栏厚度 变化会改变列数与形态)与锚点。Win11 族没有 100ms 的TIMER_ID_POS,1s 采样 tick 是它唯一 的观察者。
顺带修掉一个观测到的坑:FindWindowExW(parent, 0, "Start", NULL) 在 26300 的左侧任务栏上
查不到 Start 窗口(传本地化标题才查得到;TrayNotifyWnd / ReBarWindow32 /
TrayDummySearchControl 则正常),导致「贴近开始按钮」静默失效。FindClassChild 先试
FindWindowEx,失败再手工走 GW_CHILD/GW_HWNDNEXT 比对类名(USER 锁内读,不走 §43 的
跨线程 worker)。
src/UI/TaskbarWindow.cs(Start 的嵌入重试与放弃、DrainThreadQueue、WM_TIMER 的嵌入
不变量)、src/Interop/WindowInterop.cs(PeekMessageW、GA_PARENT)
事故(2026-10-06 日志,用户报告「任务栏图标跑到左上角」):反复勾选/取消悬浮模式时,
「关闭悬浮 → 重新嵌入」这一轮约每三次失败一次:SetParent 嵌入任务栏失败(目标= Shell_TrayWnd)err=87,覆盖层落在屏幕 (569,0) —— 顶层窗口贴在屏幕顶端、挡住那里的
点击,且永远不自愈(19:57:05 失败,19:57:54 仍在那里)。
机制分两层:
- err=87 本身:
SetParent把我们的窗口挂进 explorer 的窗口,是跨线程/跨进程调用; 紧跟一次悬浮切换时,150ms 前 explorer 刚被拆走一个子窗口(旧覆盖层),其收尾(队列 解绑、任务栏重排)还没跑完,win32k 在这窗口期会拒绝新的 attach。开机首嵌没有「刚被 拆走的旧子窗口」,所以从不触发——这就是它「只在反复切换时偶发」的原因。瞬态故障, 重试即愈。 - 滞留才是真 bug:失败后代码只记了一行日志就照常走嵌入流程 ——
MoveWindow的坐标是父窗口相对坐标(xRel, 0),写在顶层窗口上就成了屏幕坐标 (xRel, 0); 而 tick 的自愈只查IsWindow(TaskbarHwnd)(任务栏活着 ≠ 我们还是它的孩子),于是RepositionOverlay每 tick 把父相对坐标再写一遍,窗口被钉死在屏幕顶端。
三层处置(缺一层都有对应的事故路径):
- 就地重试:
SetParentMaxAttempts=5 × 250ms。判据不只看返回值——返回 NULL 对「生来 顶层」的窗口既可能是失败也可能是「成功、无前父」,以GetAncestor(GA_PARENT) == 目标为准。重试期间窗口先SW_HIDE(嵌入形窗口是带WS_VISIBLE创建的,透明的顶层窗口 会吃掉任务栏的点击);目标IsWindow失活则立刻放弃(explorer 重启有自己的 重建路径)。 - 重试耗尽 → 销毁本轮、走重建循环(2s 退避):绝不能「浮为顶层窗口继续跑」。销毁是
同线程
DestroyWindow,同步跑与悬浮切换完全相同的TeardownOverlay(幂等);它PostQuitMessage留下的WM_QUIT必须用DrainThreadQueue()排干——Start()这时 没进消息循环就返回了,残留的 WM_QUIT 会让下一轮GetMessage立刻返回 0,重建 循环变成秒级热循环。 - tick 持有嵌入不变量(兜底):
!Floating && GetAncestor(hwnd, GA_PARENT) != (Classical ? BarHwnd : TaskbarHwnd)→ 自毁重建。一次GetAncestor的代价,覆盖 「任务栏活着但我们已不是它的孩子」的一切冷门路径(含经典族 ReBar 被换掉—— 重建会重新探测整条链,比拿着死 hBar 硬撑正确)。
src/Sampling/SamplingController.cs、src/UI/TaskbarOverlayManager.cs、
src/UI/TaskbarDisplayCatalog.cs、src/UI/TaskbarWindow.cs、src/App.xaml.cs
多选显示器不是给现有 Start() 外面套一个 foreach new TaskbarWindow():那会把 SRUM、
PDH、DXCore、进程枚举和两个后台 poller 全复制一遍。正确边界是 SamplingController
独占一套 SystemSampler;每个选中显示器只有自己的 STA + HWND + RenderState + 渲染管线。
controller 一次发布,管理器逐 HWND 投递 WM_APP_SNAPSHOT,App 的详情/关于只刷新一轮。
不可破坏的约束:
- 身份不是序号。 设置保存显示器设备接口 ID + 名称;
Shell_TrayWnd与Shell_SecondaryTrayWnd通过MonitorFromWindow关联。HWND/HMONITOR、枚举序号和矩形 只用于当次运行,不能写进 YAML。已断开的已选 ID 保留,重连恢复。名称只用于展示:按接口 ID 定位注册表 EDID 并读取型号描述(离线项同样),缺失才回退DeviceString;同名时附加 GDI 显示器序号,绝不把显示名当身份。 - 目标不能被偷换。
Start(target)每轮按 ID 重新解析任务栏;某块已选副屏失效只能等, 不能嵌到主屏。只有“当前没有任何有效已选目标”才临时回退主屏,且不改写用户选择。 设置 UI 禁止取消最后一项,避免没有主窗口的程序失去设置入口。 - 拓扑不是计数。 显示器事件只是去抖提示,还要有低频目录重扫,覆盖副任务栏延迟创建、 Windows 的“在所有显示器上显示任务栏”开关、主屏切换和 Explorer 重建。比较 ID、任务栏 HWND 和 HMONITOR 归属,不能只比较副任务栏数量。
- 生命周期按实例。 desired/generation 失效后不得复活;等待任务栏可取消,建窗前及 消息循环前都检查,退出走统一 DestroyWindow/teardown;每个经典 ReBar 独立恢复。退出先 stop/join 全部覆盖层并移除各自 worker/CrashTrace slot,最后停止 controller 和 pollers。
- DirectWrite 也必须按窗口隔离。 每个 HWND 使用
DWRITE_FACTORY_TYPE_ISOLATED, factory 存在RenderState中直到最终销毁(设备丢失恢复保留);不得共享给多个 STA。Start()任意异常都必须AbortStart:销毁半初始化 HWND、排干消息并释放文本/设备资源, 不能留下持续重绘的空 context。 - 详情分数据与锚点。 controller 提供快照/Wi-Fi,实际被点击的 overlay 负责定位、 select/deselect。pin 仍是每列全局一个,点击禁用广播并随全局 mask 应用到 NEW entries; 移除锚点关闭 transient,pinned 脱离死锚点但继续收数据。
- 坐标是虚拟桌面的,不是主屏原点的。
CalcPosition的 screen-X 下界必须是目标taskbarRect.left,不能写死 0;左侧/上方显示器及悬浮 home 的负坐标都合法。悬浮模式 始终单实例,越界与 home-valid 规则见 §36/§38。
src/UI/TaskbarWindow.cs(RepositionOverlay、FloatingReposition、HandleDpiChange、
RenderState.LastPlaceW/H)
副屏启动时任务栏可能先报告一个临时高度,随后才恢复。旧的重定位节流只比较 X/Y:
back buffer 已按新高度重建,但锚点没变,于是 MoveWindow 被跳过,真实 HWND 保留启动时
的半高,文字被裁掉。设置里切换显示会强制重定位,所以表面上又能“恢复正常”。
规则:RepositionOverlay 只有在 位置与宽高全部不变 时才能跳过 MoveWindow。记录
LastPlaceW/H,任务栏带高或布局尺寸变化时必须实际移动/缩放 HWND,不能只重建交换链。
悬浮窗在 2026-10-25 的跨 DPI 拖动中暴露了同族的两个错误:高度仍取主屏任务栏(从
150% 到 300% 时宽度 441→882,高度却保持 72 而非 144);FloatingReposition 又始终带
SWP_NOSIZE,于是交换链已是 882px,真实 HWND 仍是 441px,右侧 GPU/网络被裁在窗口外。
修复不是给某个截图补一个宽度,而是统一以下约束:
- WPF 与原生 STA 都按 PerMonitorV2 运行。 清单声明
dpiAwareness,App静态构造函数 在首个窗口前设置Switch.System.Windows.DoNotScaleForDpiChanges=false(单 exe 发布没有 外部 .config),让设置窗口和每个 DetailWindow 各自跟随所在显示器,主屏设置窗口不再决定 副屏 detail 的 DPI。TaskbarWindow.Start仍在整个消息循环期间显式调用SetThreadDpiAwarenessContext(PER_MONITOR_AWARE_V2), 返回时恢复原上下文,保证 home DPI 查询和GetMonitorInfo都使用目标显示器的物理值。 - DetailWindow 首次跨屏要定位两次。 HWND 在屏外按旧 DPI 创建,第一次
SetWindowPos到目标屏会同步触发WM_DPICHANGED并改变物理尺寸;ShowColumn必须UpdateLayout后 再跑PositionNearTaskbar,用最终尺寸重算列中心和任务栏间距,不能只定位一次。 - 悬浮窗没有任务栏 band。初始 DPI 按保存 home 所在显示器选择
(
DisplayInterop.TryGetDpiAtPoint),初始逻辑高度只换算一次并保存为FloatingHeightDip;之后ComputeFloatingTargetSize按当前 DPI 和采样布局独立计算, 不再读取主屏 band。全部采样项关闭再开启时也不能把这个自然高度丢成 0。 - 交界贴边期间 DPI 也属于 home。 Windows 按 LIVE HWND 面积最大的显示器发送
WM_DPICHANGED,所以主屏向右收起会被切成副屏 DPI。FloatingEffectiveDpi在WM_DPICHANGED(_AFTERPARENT)和 tick 校正两处统一决策:已停靠且未实际拖动时读取 home point 的 DPI,真实拖动/未停靠才跟随 live 显示器;HandleDpiChange对相同 DPI 直接返回。 这样 hide/peek 不会在 144↔120 之间反复改变细条宽高、文字和交换链。 - DPI/布局变化必须同时更新交换链与真实 HWND。
FloatingReposition用LastPlaceW/H独立判断尺寸变化;位置不变、正在拖动或贴边滑动都只能暂缓位置重算, 不能把 resize 一起跳过。ApplyFloatingBounds应用尺寸并读取真实 rect 记录日志。 - 同一路径立即更新圆角 region,不能等下一 tick 留下旧尺寸的裁剪帧。尺寸为 0 的 全关闭 stub 清 region,恢复采样后再按新尺寸创建。
- 悬浮几何诊断按悬浮窗自己的 HMONITOR 报告;允许越界或贴边隐藏是用户选择,不能再 拿主屏边界误报“部分内容不可见”。
src/App.xaml.cs(静态构造函数)、src/UI/Common/ScrollCacheDuringTouch.cs、iNKORE
ShadowAssist / Window 与 Popup 模板
2026-11-06 的表象是“设置窗口打开时第二次启动会卡住,右键菜单也弹不出来”。关闭新加的 已运行提示框并不恢复,换成普通 WPF 窗口、原生消息框、把位置查询移出 UI 线程都无效。 关键隔离实验:第二次启动时在 UAC 选“否”,完全不发送单实例通知,故障仍然出现。也就是 触发点是 UAC 的安全桌面,不是通知或提示框;其他程序发起的 UAC 同样能触发。
这是 WPF 已知的 multi-window + BitmapCache + display reset 缺陷(dotnet/wpf
#4276、
#8919):UAC、锁屏、Ctrl+Alt+Del 造成的显示
重置后,后续窗口可以停止渲染,而 managed Dispatcher、采样和日志仍继续运行。iNKORE 的
ShadowAssist.UseBitmapCache 默认是 true,其源码注释也明确要求多窗口应用关闭;本项目的
Window / Popup 模板、预热窗口、设置窗口和不可见菜单宿主正好组成多窗口场景,所以设置不
重绘与菜单弹层失效会同时出现。
修复是在 App 静态构造函数、任何 WPF 窗口创建之前设置
ShadowAssist.UseBitmapCache = false。ScrollCacheDuringTouch 也必须服从同一开关,避免
触屏拖拽重新引入一条临时 BitmapCache 路径。保留硬件渲染,不切 SoftwareOnly,不杀 DWM,
最终也没有保留任何“第二次启动跳过 UAC”的规避逻辑。验证用外部 cmd.exe /c exit 的 runas
触发 UAC 并取消:设置滚动/切换与任务栏右键均保持正常。不得仅为阴影或动画性能重新打开
此开关;要恢复必须先证明当前 WPF/runtime 已解决该 display-reset 缺陷。