wintoast 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,501 @@
1
+ /*
2
+ * wintoast — fire-and-forget Windows toast notifications + taskbar/terminal
3
+ * progress, on the inbox WinRT and shell APIs that ship with Windows.
4
+ *
5
+ * Stateless module functions (the phylax/winclip shape, NOT the windraw Surface
6
+ * shape): every native operation is complete within one call and owns no
7
+ * resources across calls. WinRT/COM is initialized and uninitialized per call
8
+ * (balanced; RPC_E_CHANGED_MODE => proceed unowned — the toast classes are
9
+ * agile). No TypedData, no allocator, no finalizer, no leak class.
10
+ *
11
+ * Build notes (verified on Ruby 3.4 x64-mswin64_140, MSVC cl 19.x; arch-neutral
12
+ * — gates on _WIN64, uses ULONG_PTR, no _M_X64, no inline asm, no /MACHINE):
13
+ * - <ruby.h> MUST come before the Windows headers (macro/winsock hygiene).
14
+ * - Never name an identifier IN/OUT (<windows.h> uses them as SAL markers).
15
+ * - -EHsc is on (std::wstring + COM). Therefore NO rb_raise longjmp may ever
16
+ * cross a frame holding a live C++ object, un-released COM state, or an
17
+ * active try — on MSVC that longjmp is UB. So each bridge is shaped as an
18
+ * outer wrapper frame (no C++ objects, no try) that reads VALUE args and
19
+ * copies scalars; an inner C++ scope that does all std::wstring + COM work
20
+ * in status-code helpers (which release every interface and delete every
21
+ * HSTRING before returning HRESULT + the failing API name) and flags OOM in
22
+ * a catch(...) WITHOUT calling Ruby; then, ONLY after the scope closes (all
23
+ * destructors run, COM balanced, no try active), the wrapper raises.
24
+ * - String conversion is infallible on the C side: UTF-8 validity is enforced
25
+ * in the Ruby facade before any bridge runs, so MultiByteToWideChar(CP_UTF8,
26
+ * 0, ...) (no MB_ERR_INVALID_CHARS) cannot fail short of OOM.
27
+ */
28
+
29
+ #include <ruby.h>
30
+ #include <ruby/encoding.h>
31
+ #include <ruby/thread.h>
32
+
33
+ #define WIN32_LEAN_AND_MEAN
34
+ #include <windows.h>
35
+ #include <roapi.h>
36
+ #include <winstring.h>
37
+ #include <windows.ui.notifications.h>
38
+ #include <windows.data.xml.dom.h>
39
+ #include <windows.foundation.h>
40
+ #include <shobjidl_core.h>
41
+
42
+ #include <string>
43
+
44
+ /* ABI namespace aliases (the raw WinRT interfaces live under ABI::Windows::*). */
45
+ namespace WF = ABI::Windows::Foundation;
46
+ namespace WUX = ABI::Windows::Data::Xml::Dom;
47
+ namespace WUN = ABI::Windows::UI::Notifications;
48
+
49
+ using WF::IPropertyValueStatics;
50
+ using WF::DateTime;
51
+ using WUX::IXmlDocument;
52
+ using WUX::IXmlDocumentIO;
53
+ using WUN::IToastNotificationManagerStatics;
54
+ using WUN::IToastNotifier;
55
+ using WUN::IToastNotification;
56
+ using WUN::IToastNotification2;
57
+ using WUN::IToastNotificationFactory;
58
+
59
+ static VALUE mWintoast;
60
+ static VALUE eError;
61
+ static VALUE eOSError;
62
+
63
+ /* ---- small helpers ------------------------------------------------------- */
64
+
65
+ template <class T>
66
+ static void SafeRelease(T** pp) {
67
+ if (*pp) { (*pp)->Release(); *pp = nullptr; }
68
+ }
69
+
70
+ /* UTF-8 (pre-validated in Ruby) -> std::wstring. Non-raising: without
71
+ * MB_ERR_INVALID_CHARS the call cannot fail on any byte sequence (it returns an
72
+ * empty string for empty input or — only — under OOM, which the caller's
73
+ * catch(...) flags). Never call this where an rb_raise could be needed. */
74
+ static std::wstring to_utf16(VALUE str) {
75
+ long bytelen = RSTRING_LEN(str);
76
+ if (bytelen <= 0) return std::wstring();
77
+ const char* p = RSTRING_PTR(str);
78
+ int need = MultiByteToWideChar(CP_UTF8, 0, p, static_cast<int>(bytelen), nullptr, 0);
79
+ if (need <= 0) return std::wstring();
80
+ std::wstring w(static_cast<size_t>(need), L'\0');
81
+ MultiByteToWideChar(CP_UTF8, 0, p, static_cast<int>(bytelen), &w[0], need);
82
+ return w;
83
+ }
84
+
85
+ /* Raise Wintoast::OSError with the failing API name and an HRESULT/Win32 code,
86
+ * attaching @code. Called ONLY from a wrapper frame holding no live C++ object
87
+ * or COM state. `is_hr` picks the message format. */
88
+ static void raise_os(const char* what, long code, bool is_hr) {
89
+ VALUE msg = is_hr
90
+ ? rb_sprintf("wintoast: %s failed (hr=0x%08lX)", what, static_cast<unsigned long>(code))
91
+ : rb_sprintf("wintoast: %s failed (error %lu)", what, static_cast<unsigned long>(code));
92
+ VALUE exc = rb_exc_new_str(eOSError, msg);
93
+ rb_iv_set(exc, "@code", LONG2NUM(code));
94
+ rb_exc_raise(exc);
95
+ }
96
+
97
+ /* =========================================================================
98
+ * toast — _show(aumid_u8, xml_u8, expire_unix_ms_or_nil, tag_or_nil, group_or_nil)
99
+ * ========================================================================= */
100
+
101
+ /* All COM/WinRT work for Show(). Releases every interface and deletes every
102
+ * created HSTRING before returning; records the failing API name in *what.
103
+ * Never calls into Ruby. The aumid/xml/tag/group wstrings outlive every
104
+ * HSTRING reference by construction (they are function parameters). */
105
+ static HRESULT show_toast(const std::wstring& aumid, const std::wstring& xml,
106
+ bool has_expire, long long expire_ms,
107
+ bool has_tag, const std::wstring& tag,
108
+ bool has_group, const std::wstring& group,
109
+ const char** what) {
110
+ HRESULT hr = S_OK;
111
+ bool ro_owned = false;
112
+
113
+ hr = RoInitialize(RO_INIT_MULTITHREADED);
114
+ if (hr == RPC_E_CHANGED_MODE) {
115
+ hr = S_OK; /* already in an apartment; usable, not ours */
116
+ } else if (SUCCEEDED(hr)) {
117
+ ro_owned = true; /* we incremented the init count; balance it */
118
+ } else {
119
+ *what = "RoInitialize";
120
+ return hr;
121
+ }
122
+
123
+ IToastNotificationManagerStatics* mgr = nullptr;
124
+ IToastNotifier* notifier = nullptr;
125
+ IInspectable* xmlInsp = nullptr;
126
+ IXmlDocumentIO* xmlIO = nullptr;
127
+ IXmlDocument* xmlDoc = nullptr;
128
+ IToastNotificationFactory* tf = nullptr;
129
+ IToastNotification* toast = nullptr;
130
+ IToastNotification2* toast2 = nullptr;
131
+ IPropertyValueStatics* pvs = nullptr;
132
+ IInspectable* dtInsp = nullptr;
133
+ WF::IReference<DateTime>* dtRef = nullptr;
134
+
135
+ HSTRING_HEADER hMgrHdr, hAumidHdr, hXmlHdr, hTagHdr, hGroupHdr;
136
+ HSTRING hMgr = nullptr, hAumid = nullptr, hXml = nullptr, hTag = nullptr, hGroup = nullptr;
137
+
138
+ /* Activation-class name: a fast-pass string reference (no allocation). */
139
+ hr = WindowsCreateStringReference(
140
+ RuntimeClass_Windows_UI_Notifications_ToastNotificationManager,
141
+ static_cast<UINT32>(wcslen(RuntimeClass_Windows_UI_Notifications_ToastNotificationManager)),
142
+ &hMgrHdr, &hMgr);
143
+ if (SUCCEEDED(hr))
144
+ hr = WindowsCreateStringReference(aumid.c_str(), static_cast<UINT32>(aumid.size()),
145
+ &hAumidHdr, &hAumid);
146
+ if (SUCCEEDED(hr))
147
+ hr = WindowsCreateStringReference(xml.c_str(), static_cast<UINT32>(xml.size()),
148
+ &hXmlHdr, &hXml);
149
+ if (FAILED(hr)) { *what = "WindowsCreateStringReference"; goto cleanup; }
150
+
151
+ hr = RoGetActivationFactory(hMgr, __uuidof(IToastNotificationManagerStatics),
152
+ reinterpret_cast<void**>(&mgr));
153
+ if (FAILED(hr)) { *what = "RoGetActivationFactory(ToastNotificationManager)"; goto cleanup; }
154
+
155
+ /* ALWAYS the WithId overload — the parameterless one throws 0x80070490 for
156
+ * identity-less processes. */
157
+ hr = mgr->CreateToastNotifierWithId(hAumid, &notifier);
158
+ if (FAILED(hr)) { *what = "CreateToastNotifierWithId"; goto cleanup; }
159
+
160
+ /* XmlDocument: activate -> QI IXmlDocumentIO -> LoadXml -> QI IXmlDocument. */
161
+ {
162
+ HSTRING_HEADER hDocHdr; HSTRING hDoc = nullptr;
163
+ hr = WindowsCreateStringReference(
164
+ RuntimeClass_Windows_Data_Xml_Dom_XmlDocument,
165
+ static_cast<UINT32>(wcslen(RuntimeClass_Windows_Data_Xml_Dom_XmlDocument)),
166
+ &hDocHdr, &hDoc);
167
+ if (SUCCEEDED(hr)) {
168
+ IInspectable* tmp = nullptr;
169
+ hr = RoActivateInstance(hDoc, &tmp);
170
+ if (SUCCEEDED(hr)) { xmlInsp = tmp; }
171
+ }
172
+ }
173
+ if (FAILED(hr)) { *what = "RoActivateInstance(XmlDocument)"; goto cleanup; }
174
+
175
+ hr = xmlInsp->QueryInterface(__uuidof(IXmlDocumentIO), reinterpret_cast<void**>(&xmlIO));
176
+ if (FAILED(hr)) { *what = "QueryInterface(IXmlDocumentIO)"; goto cleanup; }
177
+
178
+ hr = xmlIO->LoadXml(hXml);
179
+ if (FAILED(hr)) { *what = "LoadXml"; goto cleanup; }
180
+
181
+ hr = xmlInsp->QueryInterface(__uuidof(IXmlDocument), reinterpret_cast<void**>(&xmlDoc));
182
+ if (FAILED(hr)) { *what = "QueryInterface(IXmlDocument)"; goto cleanup; }
183
+
184
+ {
185
+ HSTRING_HEADER hTNHdr; HSTRING hTN = nullptr;
186
+ hr = WindowsCreateStringReference(
187
+ RuntimeClass_Windows_UI_Notifications_ToastNotification,
188
+ static_cast<UINT32>(wcslen(RuntimeClass_Windows_UI_Notifications_ToastNotification)),
189
+ &hTNHdr, &hTN);
190
+ if (SUCCEEDED(hr))
191
+ hr = RoGetActivationFactory(hTN, __uuidof(IToastNotificationFactory),
192
+ reinterpret_cast<void**>(&tf));
193
+ if (FAILED(hr)) { *what = "RoGetActivationFactory(ToastNotification)"; goto cleanup; }
194
+ }
195
+
196
+ hr = tf->CreateToastNotification(xmlDoc, &toast);
197
+ if (FAILED(hr)) { *what = "CreateToastNotification"; goto cleanup; }
198
+
199
+ /* Tag/group (1607+): QI IToastNotification2; skipped entirely when absent. */
200
+ if (has_tag || has_group) {
201
+ hr = toast->QueryInterface(__uuidof(IToastNotification2), reinterpret_cast<void**>(&toast2));
202
+ if (FAILED(hr)) { *what = "QueryInterface(IToastNotification2)"; goto cleanup; }
203
+ if (has_tag) {
204
+ hr = WindowsCreateStringReference(tag.c_str(), static_cast<UINT32>(tag.size()),
205
+ &hTagHdr, &hTag);
206
+ if (SUCCEEDED(hr)) hr = toast2->put_Tag(hTag);
207
+ if (FAILED(hr)) { *what = "put_Tag"; goto cleanup; }
208
+ }
209
+ if (has_group) {
210
+ hr = WindowsCreateStringReference(group.c_str(), static_cast<UINT32>(group.size()),
211
+ &hGroupHdr, &hGroup);
212
+ if (SUCCEEDED(hr)) hr = toast2->put_Group(hGroup);
213
+ if (FAILED(hr)) { *what = "put_Group"; goto cleanup; }
214
+ }
215
+ }
216
+
217
+ /* Expiration: PropertyValue.CreateDateTime -> QI IReference<DateTime> ->
218
+ * put_ExpirationTime. DateTime is 100-ns ticks since 1601-01-01 UTC. */
219
+ if (has_expire) {
220
+ HSTRING_HEADER hPvHdr; HSTRING hPv = nullptr;
221
+ hr = WindowsCreateStringReference(
222
+ RuntimeClass_Windows_Foundation_PropertyValue,
223
+ static_cast<UINT32>(wcslen(RuntimeClass_Windows_Foundation_PropertyValue)),
224
+ &hPvHdr, &hPv);
225
+ if (SUCCEEDED(hr))
226
+ hr = RoGetActivationFactory(hPv, __uuidof(IPropertyValueStatics),
227
+ reinterpret_cast<void**>(&pvs));
228
+ if (FAILED(hr)) { *what = "RoGetActivationFactory(PropertyValue)"; goto cleanup; }
229
+
230
+ DateTime dt;
231
+ /* DateTime is 100-ns ticks since 1601: (epoch_ms + 11644473600000) * 10000.
232
+ * Ruby's validate_expiry already clamps expire_ms to a value whose tick fits
233
+ * int64, but a direct _show bypasses that — so saturate here too. Without
234
+ * this, a large expire_ms makes the multiply overflow int64 (UB) and hands
235
+ * the OS a garbage UniversalTime. INT64_MAX/10000 - 11644473600000 is the
236
+ * largest epoch_ms whose tick is representable. */
237
+ const long long kMaxExpireMs = (9223372036854775807LL / 10000LL) - 11644473600000LL;
238
+ long long ms = expire_ms;
239
+ if (ms > kMaxExpireMs) ms = kMaxExpireMs;
240
+ else if (ms < 0) ms = 0;
241
+ dt.UniversalTime = (ms + 11644473600000LL) * 10000LL;
242
+ hr = pvs->CreateDateTime(dt, &dtInsp);
243
+ if (FAILED(hr)) { *what = "CreateDateTime"; goto cleanup; }
244
+ hr = dtInsp->QueryInterface(__uuidof(WF::IReference<DateTime>),
245
+ reinterpret_cast<void**>(&dtRef));
246
+ if (FAILED(hr)) { *what = "QueryInterface(IReference<DateTime>)"; goto cleanup; }
247
+ hr = toast->put_ExpirationTime(dtRef);
248
+ if (FAILED(hr)) { *what = "put_ExpirationTime"; goto cleanup; }
249
+ }
250
+
251
+ hr = notifier->Show(toast);
252
+ if (FAILED(hr)) { *what = "Show"; goto cleanup; }
253
+
254
+ cleanup:
255
+ /* String references over header/headerless buffers need no WindowsDeleteString
256
+ * (fast-pass references are not owned HSTRINGs). Release interfaces in reverse. */
257
+ SafeRelease(&dtRef);
258
+ SafeRelease(&dtInsp);
259
+ SafeRelease(&pvs);
260
+ SafeRelease(&toast2);
261
+ SafeRelease(&toast);
262
+ SafeRelease(&tf);
263
+ SafeRelease(&xmlDoc);
264
+ SafeRelease(&xmlIO);
265
+ SafeRelease(&xmlInsp);
266
+ SafeRelease(&notifier);
267
+ SafeRelease(&mgr);
268
+ if (ro_owned) RoUninitialize();
269
+ return hr;
270
+ }
271
+
272
+ static VALUE wintoast_show(VALUE self, VALUE vaumid, VALUE vxml, VALUE vexpire,
273
+ VALUE vtag, VALUE vgroup) {
274
+ (void)self;
275
+ /* Outer frame: no C++ objects, no try. Read scalars only. */
276
+ bool has_expire = !NIL_P(vexpire);
277
+ long long expire_ms = has_expire ? NUM2LL(vexpire) : 0;
278
+ bool has_tag = !NIL_P(vtag);
279
+ bool has_group = !NIL_P(vgroup);
280
+
281
+ HRESULT hr = S_OK;
282
+ const char* what = NULL;
283
+ bool oom = false;
284
+
285
+ { /* inner C++ scope: every std::wstring + interface pointer lives here */
286
+ try {
287
+ std::wstring aumid = to_utf16(vaumid);
288
+ std::wstring xml = to_utf16(vxml);
289
+ std::wstring tag = has_tag ? to_utf16(vtag) : std::wstring();
290
+ std::wstring group = has_group ? to_utf16(vgroup) : std::wstring();
291
+ hr = show_toast(aumid, xml, has_expire, expire_ms,
292
+ has_tag, tag, has_group, group, &what);
293
+ } catch (...) {
294
+ oom = true; /* std::bad_alloc from a wstring: flag only */
295
+ }
296
+ } /* scope closes: destructors run, COM balanced, try no longer active */
297
+
298
+ if (oom) rb_raise(rb_eNoMemError, "wintoast: out of memory building toast");
299
+ if (FAILED(hr)) raise_os(what ? what : "Show", static_cast<long>(hr), /*is_hr*/ true);
300
+ return Qnil;
301
+ }
302
+
303
+ /* =========================================================================
304
+ * register! / unregister! — HKCU\Software\Classes\AppUserModelId\<aumid>
305
+ * ========================================================================= */
306
+
307
+ static LSTATUS do_register(const std::wstring& aumid, const std::wstring& dn,
308
+ bool has_icon, const std::wstring& icon, const char** what) {
309
+ std::wstring path = L"Software\\Classes\\AppUserModelId\\" + aumid;
310
+ HKEY hkey = nullptr;
311
+ LSTATUS st = RegCreateKeyExW(HKEY_CURRENT_USER, path.c_str(), 0, nullptr,
312
+ REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &hkey, nullptr);
313
+ if (st != ERROR_SUCCESS) { *what = "RegCreateKeyEx"; return st; }
314
+
315
+ /* REG_SZ byte length includes the terminating NUL. */
316
+ st = RegSetValueExW(hkey, L"DisplayName", 0, REG_SZ,
317
+ reinterpret_cast<const BYTE*>(dn.c_str()),
318
+ static_cast<DWORD>((dn.size() + 1) * sizeof(WCHAR)));
319
+ if (st != ERROR_SUCCESS) { *what = "RegSetValueEx(DisplayName)"; RegCloseKey(hkey); return st; }
320
+
321
+ if (has_icon) {
322
+ st = RegSetValueExW(hkey, L"IconUri", 0, REG_SZ,
323
+ reinterpret_cast<const BYTE*>(icon.c_str()),
324
+ static_cast<DWORD>((icon.size() + 1) * sizeof(WCHAR)));
325
+ if (st != ERROR_SUCCESS) { *what = "RegSetValueEx(IconUri)"; RegCloseKey(hkey); return st; }
326
+ }
327
+
328
+ RegCloseKey(hkey);
329
+ return ERROR_SUCCESS;
330
+ }
331
+
332
+ static VALUE wintoast_register(VALUE self, VALUE vaumid, VALUE vdn, VALUE vicon) {
333
+ (void)self;
334
+ bool has_icon = !NIL_P(vicon);
335
+ LSTATUS st = ERROR_SUCCESS;
336
+ const char* what = NULL;
337
+ bool oom = false;
338
+
339
+ {
340
+ try {
341
+ std::wstring aumid = to_utf16(vaumid);
342
+ std::wstring dn = to_utf16(vdn);
343
+ std::wstring icon = has_icon ? to_utf16(vicon) : std::wstring();
344
+ st = do_register(aumid, dn, has_icon, icon, &what);
345
+ } catch (...) {
346
+ oom = true;
347
+ }
348
+ }
349
+
350
+ if (oom) rb_raise(rb_eNoMemError, "wintoast: out of memory in register!");
351
+ if (st != ERROR_SUCCESS) raise_os(what ? what : "RegCreateKeyEx", static_cast<long>(st), /*is_hr*/ false);
352
+ return Qtrue;
353
+ }
354
+
355
+ static VALUE wintoast_unregister(VALUE self, VALUE vaumid) {
356
+ (void)self;
357
+ LSTATUS st = ERROR_SUCCESS;
358
+ bool oom = false;
359
+
360
+ {
361
+ try {
362
+ std::wstring path = L"Software\\Classes\\AppUserModelId\\" + to_utf16(vaumid);
363
+ st = RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str());
364
+ } catch (...) {
365
+ oom = true;
366
+ }
367
+ }
368
+
369
+ if (oom) rb_raise(rb_eNoMemError, "wintoast: out of memory in unregister!");
370
+ if (st == ERROR_SUCCESS) return Qtrue;
371
+ if (st == ERROR_FILE_NOT_FOUND) return Qfalse;
372
+ raise_os("RegDeleteTree", static_cast<long>(st), /*is_hr*/ false);
373
+ return Qnil; /* unreachable */
374
+ }
375
+
376
+ /* =========================================================================
377
+ * progress — _progress(state_int, percent_int)
378
+ * state_int uses OSC wire values directly: 0 clear, 1 normal, 2 error,
379
+ * 3 indeterminate, 4 paused. percent_int 0..100 (pre-clamped in Ruby).
380
+ * ========================================================================= */
381
+
382
+ /* No-GVL console write. Touches only this stack struct (built entirely in C from
383
+ * two ints BEFORE the GVL is released) — never reads a VALUE or RSTRING_PTR, so
384
+ * GC compaction cannot bite. The classic-conhost select-mode freeze can park
385
+ * WriteConsoleW indefinitely; releasing the GVL keeps every OTHER Ruby thread
386
+ * running. The ubf (RUBY_UBF_IO) does NOT cancel a select-mode-parked write —
387
+ * exact Kernel#puts parity; documented, not worse. */
388
+ typedef struct {
389
+ HANDLE h;
390
+ WCHAR buf[64];
391
+ DWORD len;
392
+ DWORD written;
393
+ BOOL ok;
394
+ } osc_write_t;
395
+
396
+ static void* osc_write_fn(void* p) {
397
+ osc_write_t* w = static_cast<osc_write_t*>(p);
398
+ w->ok = WriteConsoleW(w->h, w->buf, w->len, &w->written, nullptr);
399
+ return nullptr;
400
+ }
401
+
402
+ /* Returns true iff the OSC bytes were written to a real console. */
403
+ static bool console_leg(unsigned state, unsigned percent) {
404
+ HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
405
+ if (h == INVALID_HANDLE_VALUE || h == nullptr) return false;
406
+
407
+ DWORD mode = 0;
408
+ if (!GetConsoleMode(h, &mode)) return false; /* redirected/absent: never pollute pipes */
409
+
410
+ if (!(mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING)) {
411
+ if (!SetConsoleMode(h, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
412
+ return false; /* no VT support: skip OSC, no raise */
413
+ }
414
+
415
+ osc_write_t w;
416
+ w.h = h;
417
+ w.ok = FALSE;
418
+ w.written = 0;
419
+ /* ESC ] 9 ; 4 ; <state> ; <pct> BEL — bounded; %u of two small ints. */
420
+ int n = swprintf(w.buf, sizeof(w.buf) / sizeof(WCHAR), L"\x1b]9;4;%u;%u\x07", state, percent);
421
+ if (n <= 0) return false;
422
+ w.len = static_cast<DWORD>(n);
423
+
424
+ rb_thread_call_without_gvl(osc_write_fn, &w, RUBY_UBF_IO, nullptr);
425
+ return w.ok && w.written == w.len;
426
+ }
427
+
428
+ /* Returns true iff every ITaskbarList3 call succeeded against a non-NULL console
429
+ * window. No raise on any failure (no shell / hidden ConPTY window / hung
430
+ * explorer all contribute a false). */
431
+ static bool taskbar_leg(unsigned state, unsigned percent) {
432
+ HWND hwnd = GetConsoleWindow();
433
+ if (!hwnd) return false;
434
+
435
+ bool co_owned = false;
436
+ HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
437
+ if (hr == RPC_E_CHANGED_MODE) {
438
+ /* already in an apartment; usable, not ours */
439
+ } else if (SUCCEEDED(hr)) {
440
+ co_owned = true;
441
+ } else {
442
+ return false;
443
+ }
444
+
445
+ ITaskbarList3* tbl = nullptr;
446
+ bool ok = false;
447
+ hr = CoCreateInstance(CLSID_TaskbarList, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&tbl));
448
+ if (SUCCEEDED(hr)) hr = tbl->HrInit();
449
+ if (SUCCEEDED(hr)) {
450
+ switch (state) {
451
+ case 1: /* normal */
452
+ case 2: /* error */
453
+ case 4: /* paused */
454
+ /* value before state — SetProgressValue forces NORMAL and would
455
+ * clobber a just-set ERROR/PAUSED. */
456
+ hr = tbl->SetProgressValue(hwnd, percent, 100);
457
+ if (SUCCEEDED(hr)) {
458
+ TBPFLAG f = (state == 2) ? TBPF_ERROR : (state == 4) ? TBPF_PAUSED : TBPF_NORMAL;
459
+ hr = tbl->SetProgressState(hwnd, f);
460
+ }
461
+ break;
462
+ case 3: /* indeterminate */
463
+ hr = tbl->SetProgressState(hwnd, TBPF_INDETERMINATE);
464
+ break;
465
+ default: /* 0 = clear */
466
+ hr = tbl->SetProgressState(hwnd, TBPF_NOPROGRESS);
467
+ break;
468
+ }
469
+ ok = SUCCEEDED(hr);
470
+ }
471
+
472
+ SafeRelease(&tbl);
473
+ if (co_owned) CoUninitialize();
474
+ return ok;
475
+ }
476
+
477
+ static VALUE wintoast_progress(VALUE self, VALUE vstate, VALUE vpercent) {
478
+ (void)self;
479
+ unsigned state = static_cast<unsigned>(NUM2UINT(vstate));
480
+ unsigned percent = static_cast<unsigned>(NUM2UINT(vpercent));
481
+
482
+ /* Both legs always run; exactly one is visible per host (or neither). The
483
+ * console leg releases the GVL for the WriteConsoleW; the taskbar leg holds
484
+ * it (quick COM work). Neither raises on environmental failure. */
485
+ bool did_console = console_leg(state, percent);
486
+ bool did_taskbar = taskbar_leg(state, percent);
487
+ return (did_console || did_taskbar) ? Qtrue : Qfalse;
488
+ }
489
+
490
+ /* ------------------------------------------------------------------------- */
491
+
492
+ extern "C" void Init_wintoast(void) {
493
+ mWintoast = rb_define_module("Wintoast");
494
+ eError = rb_define_class_under(mWintoast, "Error", rb_eStandardError);
495
+ eOSError = rb_define_class_under(mWintoast, "OSError", eError);
496
+
497
+ rb_define_module_function(mWintoast, "_show", RUBY_METHOD_FUNC(wintoast_show), 5);
498
+ rb_define_module_function(mWintoast, "_register", RUBY_METHOD_FUNC(wintoast_register), 3);
499
+ rb_define_module_function(mWintoast, "_unregister", RUBY_METHOD_FUNC(wintoast_unregister), 1);
500
+ rb_define_module_function(mWintoast, "_progress", RUBY_METHOD_FUNC(wintoast_progress), 2);
501
+ }