-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTrayIconPromoter.cs
More file actions
184 lines (169 loc) · 8.29 KB
/
Copy pathTrayIconPromoter.cs
File metadata and controls
184 lines (169 loc) · 8.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
using System;
using System.Collections.Generic;
using Microsoft.Win32;
namespace MWBToggle;
/// <summary>
/// On Windows 11, new tray icons default to hidden-in-overflow until the user
/// manually flips "Show icon in taskbar" under Settings → Personalization →
/// Taskbar → Other system tray icons. For a tray-only app like MWBToggle that
/// delivers no value while hidden, that's a painful first-run experience.
///
/// Windows 11 22H2+ stores per-icon visibility at
/// <c>HKCU\Control Panel\NotifyIconSettings\<hash></c> with a DWORD value
/// named <c>IsPromoted</c> (1 = visible in taskbar, 0 = hidden, missing =
/// Explorer's default, which shows as hidden). The per-icon subkey normally
/// carries <c>ExecutablePath</c>, <c>InitialTooltip</c>, <c>UID</c>, and
/// <c>IconSnapshot</c> fields — Explorer populates them on <c>NIM_ADD</c>
/// when the caller passes <c>NIF_TIP</c>.
///
/// Two-phase identification so we survive Explorer edge cases:
/// Phase 1: match a subkey whose <c>ExecutablePath</c> equals ours — the
/// normal case, works on reruns and whenever Explorer wrote the
/// full schema.
/// Phase 2: if Phase 1 found nothing and exactly one "orphan" subkey
/// (IconSnapshot present but ExecutablePath missing) has appeared
/// since the baseline we captured before NIM_ADD, claim it —
/// write BOTH our <c>ExecutablePath</c> AND <c>IsPromoted=1</c>.
/// Defers when multiple orphans appear (Windows-login race);
/// later ticks disambiguate as other apps' subkeys fill in.
///
/// We never override an explicit <c>IsPromoted=0</c> — that's the user
/// having deliberately hidden us, and we respect it.
///
/// ──────────────────────────────────────────────────────────────────
/// This mechanism is undocumented. It has been stable across Win11
/// 22H2, 23H2, 24H2, and 25H2. All registry interaction is wrapped in
/// try/catch so a schema change in a future build silently no-ops
/// instead of crashing.
/// </summary>
internal static class TrayIconPromoter
{
private const string KeyPath = @"Control Panel\NotifyIconSettings";
private const int MinWin11Build = 22000;
/// <summary>
/// Capture the set of existing subkey names BEFORE the tray icon
/// registers (i.e. before <c>NotifyIcon.Visible = true</c>). Anything
/// that appears later is a candidate for Phase-2 orphan matching.
///
/// Returns null on failure (access denied, etc.) — callers should
/// treat null as "skip Phase 2" rather than "no subkeys existed,"
/// otherwise we'd confuse every pre-existing subkey with a fresh one.
/// </summary>
internal static HashSet<string>? CaptureBaseline()
{
if (Environment.OSVersion.Version.Build < MinWin11Build) return null;
try
{
using var root = Registry.CurrentUser.OpenSubKey(KeyPath, writable: false);
if (root is null) return new HashSet<string>(StringComparer.Ordinal);
return new HashSet<string>(root.GetSubKeyNames(), StringComparer.Ordinal);
}
catch (Exception ex)
{
Logger.Warn($"TrayIconPromoter.CaptureBaseline: {ex.Message}");
return null;
}
}
/// <summary>
/// Ensure the current exe's tray icon is promoted to visible. Returns
/// true once we've identified our subkey (whether we needed to write
/// anything or not) so the caller's retry timer can stop. Returns
/// false while we're still waiting for Explorer to create or populate
/// it — caller retries.
/// </summary>
internal static bool TryPromote(string exePath, HashSet<string>? baseline)
{
if (Environment.OSVersion.Version.Build < MinWin11Build) return false;
if (string.IsNullOrWhiteSpace(exePath)) return false;
try
{
using var root = Registry.CurrentUser.OpenSubKey(KeyPath, writable: true);
if (root is null) return false;
bool identified = false;
var orphanCandidates = new List<string>();
foreach (var subName in root.GetSubKeyNames())
{
try
{
using var sub = root.OpenSubKey(subName, writable: true);
if (sub is null) continue;
var path = sub.GetValue("ExecutablePath") as string;
// Phase 1 — path match. Handles reruns and the normal case
// where Explorer populated the full schema on NIM_ADD.
if (!string.IsNullOrEmpty(path))
{
if (!string.Equals(path, exePath, StringComparison.OrdinalIgnoreCase))
continue;
identified = true;
var current = sub.GetValue("IsPromoted");
if (current is int i0 && i0 == 0)
{
Logger.Info($"TrayIconPromoter: {subName} IsPromoted=0 — respecting user's choice.");
continue;
}
if (current is int i1 && i1 == 1) continue;
sub.SetValue("IsPromoted", 1, RegistryValueKind.DWord);
Logger.Info($"TrayIconPromoter: promoted {subName} for {path}.");
continue;
}
// Phase 2 candidate — orphan subkey (IconSnapshot but no
// ExecutablePath) that appeared AFTER our NIM_ADD.
// Skip if we didn't capture a baseline (would false-claim
// any pre-existing orphan belonging to another app).
if (baseline is null) continue;
if (baseline.Contains(subName)) continue;
if (sub.GetValue("IconSnapshot") is not byte[]) continue;
orphanCandidates.Add(subName);
}
catch (Exception ex)
{
Logger.Warn($"TrayIconPromoter: subkey {subName}: {ex.Message}");
}
}
// Phase 2 commit. Only claim when exactly one orphan appeared —
// multiple = Windows-login race with other tray apps, wait for
// the next tick. Their subkeys will fill in ExecutablePath as
// Explorer does its next pass, leaving ours as the sole orphan.
if (!identified && orphanCandidates.Count == 1)
{
var subName = orphanCandidates[0];
try
{
using var sub = root.OpenSubKey(subName, writable: true);
if (sub is not null)
{
var current = sub.GetValue("IsPromoted");
if (current is int i0 && i0 == 0)
{
Logger.Info($"TrayIconPromoter: orphan {subName} IsPromoted=0 — respecting user's choice.");
identified = true;
}
else
{
sub.SetValue("ExecutablePath", exePath, RegistryValueKind.String);
sub.SetValue("IsPromoted", 1, RegistryValueKind.DWord);
Logger.Info($"TrayIconPromoter: claimed orphan {subName} → wrote ExecutablePath + IsPromoted=1 for {exePath}.");
identified = true;
}
}
}
catch (Exception ex)
{
Logger.Warn($"TrayIconPromoter: claim orphan {subName}: {ex.Message}");
}
}
else if (orphanCandidates.Count > 1)
{
Logger.Info($"TrayIconPromoter: {orphanCandidates.Count} new orphan subkeys — deferring to next tick.");
}
return identified;
}
catch (Exception ex)
{
// Registry access denied, hive locked, schema moved — anything.
// This is UX polish, never a crash surface.
Logger.Warn($"TrayIconPromoter: {ex.Message}");
return false;
}
}
}