wintoast 0.1.0 → 0.1.1
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.
- checksums.yaml +4 -4
- data/README.md +11 -335
- data/lib/wintoast.rb +4 -270
- metadata +10 -92
- data/CHANGELOG.md +0 -40
- data/ext/wintoast/extconf.rb +0 -36
- data/ext/wintoast/wintoast.cpp +0 -501
- data/lib/wintoast/payload.rb +0 -195
- data/lib/wintoast/version.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 779c2f38a8971d52dcd72fd4790bb390349e7e75bc121360d969cdef2726a7a7
|
|
4
|
+
data.tar.gz: 6bec442db8cfd2cf320198afe50feffb524d167382c96e08d4af87c94f9be467
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2530fb41929d590ffdc3fd35e478c20f04ed27ce2cd96ca20bebbe698bcc25d26c95e3871055c1eb460f0f3a1b403b2c5f423d4052e0d7f1bef281c66a6727c
|
|
7
|
+
data.tar.gz: a9eecb5b2b46e459c8c83ac460fc6289d2749d032b95c281a8d8bd94569b0c9c0df96b4e10c8cc72b65ed8b1aa0709c1dc32e77d6402cca77e21d78de2c0be33
|
data/README.md
CHANGED
|
@@ -1,335 +1,11 @@
|
|
|
1
|
-
# wintoast
|
|
2
|
-
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
NuGet runtime, an MSIX singleton package, and a bootstrapper — against this
|
|
13
|
-
suite's OS-libraries-only rule. `wintoast` uses the **inbox** WinRT notification
|
|
14
|
-
API (`Windows.UI.Notifications`, shipped with Windows since 10240) via
|
|
15
|
-
`RoGetActivationFactory`, plus `ITaskbarList3` and the terminal OSC 9;4 progress
|
|
16
|
-
sequence. Nothing to install but the gem.
|
|
17
|
-
|
|
18
|
-
> Not to be confused with [mohabouje/WinToast], a C++ library — different
|
|
19
|
-
> ecosystem, same idea.
|
|
20
|
-
|
|
21
|
-
```ruby
|
|
22
|
-
require "wintoast"
|
|
23
|
-
|
|
24
|
-
Wintoast.toast("Backup finished", "1,204 files in 38 s")
|
|
25
|
-
# => nil (a banner pops; it persists in the Notification Center)
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
| What | API |
|
|
29
|
-
|---|---|
|
|
30
|
-
| Pop a toast | `Wintoast.toast(title, body, ...)` |
|
|
31
|
-
| Brand it (opt-in) | `Wintoast.register!` / `Wintoast.unregister!` |
|
|
32
|
-
| Taskbar + tab progress | `Wintoast.progress` / `Wintoast.progress_clear` |
|
|
33
|
-
| Inspect the XML | `Wintoast::Payload.build` |
|
|
34
|
-
|
|
35
|
-
## Requirements
|
|
36
|
-
|
|
37
|
-
- **Windows 10 1809+ or Windows 11** with a native **MSVC (mswin)** Ruby
|
|
38
|
-
(`x64-mswin64`). On a MinGW/UCRT Ruby this gem is not supported — its
|
|
39
|
-
`extconf.rb` will say so.
|
|
40
|
-
- Visual Studio 2017+ or the Build Tools with the **Desktop development with
|
|
41
|
-
C++** workload (for `cl.exe` + the Windows SDK headers/libs).
|
|
42
|
-
- x64. arm64-mswin is expected to work (all code is arch-neutral) but is
|
|
43
|
-
untested and unsupported until an arm64-mswin Ruby distribution exists.
|
|
44
|
-
|
|
45
|
-
> Building uses [`vcvars`](https://rubygems.org/gems/vcvars) to load the MSVC
|
|
46
|
-
> toolchain automatically — no "Developer Command Prompt" needed. If a build
|
|
47
|
-
> fails, run `vcvars doctor`.
|
|
48
|
-
|
|
49
|
-
## Install
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
gem install wintoast
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
## Quick start
|
|
56
|
-
|
|
57
|
-
**Zero setup — works on a stock machine (branded "Windows PowerShell"):**
|
|
58
|
-
|
|
59
|
-
```ruby
|
|
60
|
-
require "wintoast"
|
|
61
|
-
|
|
62
|
-
Wintoast.toast("Backup finished", "1,204 files in 38 s")
|
|
63
|
-
# => nil (a banner pops; it persists in the Notification Center)
|
|
64
|
-
|
|
65
|
-
# Soften the borrowed branding with an attribution line:
|
|
66
|
-
Wintoast.toast("Backup finished", "1,204 files in 38 s", attribution: "via backup.rb")
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
**Own branding — one-time, per-user, reversible, no admin:**
|
|
70
|
-
|
|
71
|
-
```ruby
|
|
72
|
-
AUMID = Wintoast.register!(aumid: "Acme.BackupTool", display_name: "Acme Backup",
|
|
73
|
-
icon: "C:/Acme/backup.png") # => "Acme.BackupTool"
|
|
74
|
-
|
|
75
|
-
Wintoast.toast("Backup finished", aumid: AUMID,
|
|
76
|
-
image: "C:/Acme/backup.png", circle: true,
|
|
77
|
-
audio: :mail, duration: :long,
|
|
78
|
-
expires_in: 3600, tag: "backup", group: "acme")
|
|
79
|
-
# A later toast with tag: "backup", group: "acme" REPLACES this one in Action Center.
|
|
80
|
-
|
|
81
|
-
Wintoast.unregister!(aumid: "Acme.BackupTool") # => true
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
**Progress around a work loop (taskbar under conhost, tab ring under Windows Terminal):**
|
|
85
|
-
|
|
86
|
-
```ruby
|
|
87
|
-
begin
|
|
88
|
-
files.each_with_index do |f, i|
|
|
89
|
-
process(f)
|
|
90
|
-
Wintoast.progress(i + 1, of: files.size)
|
|
91
|
-
end
|
|
92
|
-
Wintoast.toast("Done", "#{files.size} files processed")
|
|
93
|
-
rescue => e
|
|
94
|
-
Wintoast.progress(100, state: :error) # red bar
|
|
95
|
-
Wintoast.toast("Failed", e.message, audio: :reminder)
|
|
96
|
-
raise
|
|
97
|
-
ensure
|
|
98
|
-
Wintoast.progress_clear
|
|
99
|
-
end
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
**Failure paths, demonstrated:**
|
|
103
|
-
|
|
104
|
-
```ruby
|
|
105
|
-
Wintoast.toast("hi", image: "logo.png")
|
|
106
|
-
# => ArgumentError: wintoast: image: must be an absolute path to an existing file
|
|
107
|
-
|
|
108
|
-
Wintoast.toast("hi", aumid: "Not.Registered.Anywhere")
|
|
109
|
-
# => nil — and NOTHING is displayed. Unregistered AUMIDs are silently dropped by
|
|
110
|
-
# Windows (no error, no Action Center entry). Use register! or the default AUMID.
|
|
111
|
-
|
|
112
|
-
Wintoast.progress(50, state: :indeterminate)
|
|
113
|
-
# => ArgumentError: wintoast: state: :indeterminate ignores value — pass value nil
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
## The one trap you must know about
|
|
117
|
-
|
|
118
|
-
**An unregistered AUMID is silently dropped by Windows.** `Show()` returns
|
|
119
|
-
success, but no banner pops, nothing lands in the Notification Center, and there
|
|
120
|
-
is no error, no event-log breadcrumb — nothing. This is the platform's behavior,
|
|
121
|
-
not the gem's, and it is structurally undetectable.
|
|
122
|
-
|
|
123
|
-
What "registered" means — one of:
|
|
124
|
-
|
|
125
|
-
- a **Start-Menu shortcut** carrying an `AppUserModelID` (what most installed
|
|
126
|
-
apps have), **or**
|
|
127
|
-
- a per-user **registry key** `HKCU\Software\Classes\AppUserModelId\<aumid>`
|
|
128
|
-
with a `DisplayName` — exactly what `Wintoast.register!` writes, **or**
|
|
129
|
-
- **package identity** (MSIX) — out of scope here.
|
|
130
|
-
|
|
131
|
-
`SetCurrentProcessExplicitAppUserModelID` is **not** registration: it stamps the
|
|
132
|
-
process AUMID for taskbar grouping / Jump Lists, and does nothing for
|
|
133
|
-
notifications. (A future `winshell` concern, not this gem's.)
|
|
134
|
-
|
|
135
|
-
That is why `Wintoast.toast` defaults to **Windows PowerShell's** AUMID
|
|
136
|
-
(`Wintoast::POWERSHELL_AUMID`): it is registered on every Windows box via
|
|
137
|
-
PowerShell's Start-Menu shortcut, so `Wintoast.toast("hi")` visibly works on a
|
|
138
|
-
stock machine. The trade-offs of borrowing it:
|
|
139
|
-
|
|
140
|
-
- the toast header reads **"Windows PowerShell"** (soften it with
|
|
141
|
-
`attribution:`, or switch to your own AUMID via `register!`);
|
|
142
|
-
- the per-app Settings toggle is **shared** — turning off "Windows PowerShell"
|
|
143
|
-
notifications kills every tool borrowing the default. `register!` gives you a
|
|
144
|
-
private toggle.
|
|
145
|
-
|
|
146
|
-
`Wintoast.toast` **never touches the registry.** Registration is an explicit,
|
|
147
|
-
consented, reversible act. There is deliberately **no `registered?`** query: it
|
|
148
|
-
could only check the registry route and would lie `false` for the common
|
|
149
|
-
shortcut-registered case (including the default AUMID).
|
|
150
|
-
|
|
151
|
-
## Toasts
|
|
152
|
-
|
|
153
|
-
```ruby
|
|
154
|
-
Wintoast.toast(title, body = nil,
|
|
155
|
-
aumid: Wintoast::POWERSHELL_AUMID,
|
|
156
|
-
attribution: nil, # small "via ..." line
|
|
157
|
-
image: nil, # absolute path -> appLogoOverride
|
|
158
|
-
hero: nil, # absolute path -> hero (364x180 @100%)
|
|
159
|
-
circle: false, # hint-crop="circle" on image:
|
|
160
|
-
audio: :default, # see table; false => silent
|
|
161
|
-
duration: :short, # :short | :long
|
|
162
|
-
scenario: nil, # nil | :alarm | :incoming_call | :urgent
|
|
163
|
-
expires_at: nil, # Time -> ExpirationTime (OS caps at 3 days)
|
|
164
|
-
expires_in: nil, # Numeric seconds from now (mutually exclusive)
|
|
165
|
-
tag: nil, # String <= 64 chars (dedup/replace key)
|
|
166
|
-
group: nil) -> nil # String <= 64 chars
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
**A normal return (`nil`) means the OS ACCEPTED the toast — NOT that it was
|
|
170
|
-
displayed.** Banners can be suppressed undetectably by Focus Assist /
|
|
171
|
-
Do-Not-Disturb (the toast still lands in the Notification Center), the per-app
|
|
172
|
-
toggle, or the `NoToastApplicationNotification` group policy. The return value is
|
|
173
|
-
`nil`, not `true`/`self`, precisely because the gem cannot honestly promise
|
|
174
|
-
display.
|
|
175
|
-
|
|
176
|
-
**Audio** (`audio:`) — only the system sounds Windows honors for unpackaged
|
|
177
|
-
apps; arbitrary file paths silently fall back to the default sound and are not
|
|
178
|
-
accepted:
|
|
179
|
-
|
|
180
|
-
| value | effect |
|
|
181
|
-
|---|---|
|
|
182
|
-
| `:default` | OS default sound (no `<audio>` element) |
|
|
183
|
-
| `false` | silent |
|
|
184
|
-
| `:im` `:mail` `:reminder` `:sms` | the matching `ms-winsoundevent` sound |
|
|
185
|
-
| `:alarm`, `:alarm2`..`:alarm10` | looping alarm sounds |
|
|
186
|
-
| `:call`, `:call2`..`:call10` | looping call sounds |
|
|
187
|
-
|
|
188
|
-
Looping sounds only *audibly* loop when the toast is pinned by
|
|
189
|
-
`scenario: :alarm` / `:incoming_call` (a short alarm sound is still valid).
|
|
190
|
-
|
|
191
|
-
**Scenario** (`scenario:`) — `:alarm` and `:incoming_call` pin the toast and
|
|
192
|
-
loop audio; `:urgent` breaks through Do-Not-Disturb (Windows 11 build 22546+;
|
|
193
|
-
older builds ignore the attribute and show a normal toast). `:reminder` is
|
|
194
|
-
deliberately **not** accepted — without a button it is ignored by Windows, i.e.
|
|
195
|
-
useless for fire-and-forget.
|
|
196
|
-
|
|
197
|
-
**Expiration** (`expires_at:` Time / `expires_in:` seconds, mutually exclusive)
|
|
198
|
-
maps to `ExpirationTime` (removal from the Notification Center). Local toasts are
|
|
199
|
-
retained **at most 3 days** regardless; larger values pass through and are
|
|
200
|
-
clamped by the OS. `expires_in <= 0` raises; a past `expires_at` passes through
|
|
201
|
-
(the OS treats it as already expired).
|
|
202
|
-
|
|
203
|
-
**Tag / group** (`tag:`, `group:`, each ≤ 64 chars) map to
|
|
204
|
-
`IToastNotification2.Tag/Group`: a later toast with the same tag (+ group) under
|
|
205
|
-
the same AUMID **replaces** the earlier one in the Action Center. Pure
|
|
206
|
-
pass-through; there is no history/removal API in v1.
|
|
207
|
-
|
|
208
|
-
**Images** (`image:`, `hero:`) are **local absolute paths only** (http(s) images
|
|
209
|
-
need package identity and are rejected by the absolute-path check). A
|
|
210
|
-
relative/missing path raises `ArgumentError` so a typo fails loudly instead of
|
|
211
|
-
rendering an imageless toast. Paths are emitted as plain absolute paths (no
|
|
212
|
-
`file://` URI), sidestepping percent-encoding of spaces and Unicode.
|
|
213
|
-
|
|
214
|
-
Desktop apps **cannot schedule** toasts (`ScheduledToastNotification` needs
|
|
215
|
-
package identity) — out of scope.
|
|
216
|
-
|
|
217
|
-
## Progress
|
|
218
|
-
|
|
219
|
-
```ruby
|
|
220
|
-
Wintoast.progress(value = nil, of: 100, state: nil) -> true | false
|
|
221
|
-
Wintoast.progress_clear -> true | false
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
Every call drives **both** progress surfaces, unconditionally — exactly one
|
|
225
|
-
takes effect per host, the other no-ops (the strategy blessed in
|
|
226
|
-
[microsoft/terminal#14268]):
|
|
227
|
-
|
|
228
|
-
1. **OSC 9;4** written to the console — the Windows Terminal tab ring + WT-window
|
|
229
|
-
taskbar progress (also ConEmu and a growing set of terminals). Emitted **only
|
|
230
|
-
when STDOUT is a real console** (never pollutes redirected/piped output).
|
|
231
|
-
2. **`ITaskbarList3`** on `GetConsoleWindow()` — classic-conhost taskbar
|
|
232
|
-
progress.
|
|
233
|
-
|
|
234
|
-
| call | meaning |
|
|
235
|
-
|---|---|
|
|
236
|
-
| `progress(50)` / `progress(7, of: 23)` | determinate, green |
|
|
237
|
-
| `progress(50, state: :error)` | determinate, red |
|
|
238
|
-
| `progress(50, state: :paused)` | determinate, yellow |
|
|
239
|
-
| `progress(state: :indeterminate)` | marquee / ring (value must be nil) |
|
|
240
|
-
| `progress(nil)` / `progress(:clear)` / `progress_clear` | remove |
|
|
241
|
-
|
|
242
|
-
Host matrix:
|
|
243
|
-
|
|
244
|
-
| host | taskbar (ITaskbarList3) | tab/taskbar (OSC 9;4) |
|
|
245
|
-
|---|---|---|
|
|
246
|
-
| classic conhost | ✓ | swallowed |
|
|
247
|
-
| Windows Terminal | accepts, invisible (hidden ConPTY window) | ✓ |
|
|
248
|
-
| Windows Terminal + redirected stdout | accepts, invisible | ✗ (no OSC to a pipe) |
|
|
249
|
-
| ConEmu | — | ✓ |
|
|
250
|
-
| no console (rubyw / detached) | — | — → returns `false` |
|
|
251
|
-
|
|
252
|
-
**The return value means accepted, not visible** — the same honesty rule as
|
|
253
|
-
`toast`'s `nil`. `true` means at least one OS surface accepted the update (OSC
|
|
254
|
-
bytes reached a real console, or every `ITaskbarList3` call succeeded against a
|
|
255
|
-
non-NULL console window). Under a ConPTY host the taskbar leg accepts against a
|
|
256
|
-
hidden window that can never render, so e.g. running under Windows Terminal with
|
|
257
|
-
stdout redirected returns `true` with nothing visible. `false` is guaranteed
|
|
258
|
-
only when no surface accepted at all.
|
|
259
|
-
|
|
260
|
-
**Environmental failure is a `false`, never an exception** — a progress bar must
|
|
261
|
-
not be able to crash the app. Only argument misuse raises `ArgumentError`
|
|
262
|
-
(`of: 0`, a missing value for a determinate state, a value for `:indeterminate`,
|
|
263
|
-
an unknown state, a non-Numeric value). Overshoot like `progress(101)` is
|
|
264
|
-
**clamped**, not raised.
|
|
265
|
-
|
|
266
|
-
**Clearing on exit is the caller's job** — put `Wintoast.progress_clear` in an
|
|
267
|
-
`ensure`. v1 installs no `at_exit` hook.
|
|
268
|
-
|
|
269
|
-
Two console side effects, both standard CLI behavior: progress enables
|
|
270
|
-
`ENABLE_VIRTUAL_TERMINAL_PROCESSING` once when missing and leaves it on
|
|
271
|
-
(per-call restore would race other writers); and under a **classic conhost in
|
|
272
|
-
select mode** (the user is holding a text selection) the OSC write can block.
|
|
273
|
-
The GVL is released so every *other* Ruby thread keeps running, but the parked
|
|
274
|
-
write itself is **not interruptible** (`Thread#kill` / `Timeout` will not break
|
|
275
|
-
it) — exact parity with `Kernel#puts`, whose console write is equally stuck.
|
|
276
|
-
|
|
277
|
-
## Library API
|
|
278
|
-
|
|
279
|
-
```ruby
|
|
280
|
-
Wintoast.toast(title, body = nil, **opts) # => nil (accepted, not necessarily shown)
|
|
281
|
-
Wintoast.register!(aumid:, display_name:, icon: nil) # => aumid String (HKCU branding)
|
|
282
|
-
Wintoast.unregister!(aumid:) # => true | false (false = wasn't there)
|
|
283
|
-
Wintoast.progress(value = nil, of: 100, state: nil) # => true | false (accepted, not visible)
|
|
284
|
-
Wintoast.progress_clear # => true | false
|
|
285
|
-
Wintoast::Payload.build(title, body = nil, **opts) # => String (debugging: the exact XML sent)
|
|
286
|
-
Wintoast::VERSION # => "0.1.0"
|
|
287
|
-
Wintoast::POWERSHELL_AUMID # the registered-everywhere default AUMID
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
## How it works
|
|
291
|
-
|
|
292
|
-
Toasts are sent through the **inbox** WinRT API activated directly via
|
|
293
|
-
`RoGetActivationFactory` — no Windows App SDK, no C++/WinRT codegen, no NuGet.
|
|
294
|
-
The C++ extension assembles the `ToastGeneric` XML in Ruby, hands it to
|
|
295
|
-
`Windows.Data.Xml.Dom.XmlDocument.LoadXml`, builds a `ToastNotification`, and
|
|
296
|
-
calls `ToastNotifier.Show` — all synchronous, milliseconds.
|
|
297
|
-
|
|
298
|
-
`register!` writes **only** `HKCU\Software\Classes\AppUserModelId\<aumid>`
|
|
299
|
-
(`DisplayName`, optional `IconUri`) — per-user, no elevation, no Start-Menu
|
|
300
|
-
shortcut, no HKLM, no COM activator. `unregister!` deletes that key tree.
|
|
301
|
-
|
|
302
|
-
Progress drives both `ITaskbarList3` and OSC 9;4 every call.
|
|
303
|
-
|
|
304
|
-
Every native operation is **stateless and complete within one call** — WinRT/COM
|
|
305
|
-
is initialized and uninitialized per call, owning no resources across calls. That
|
|
306
|
-
makes every API **idempotent and thread-safe**: call them from any thread,
|
|
307
|
-
concurrently, with no locks.
|
|
308
|
-
|
|
309
|
-
## Errors
|
|
310
|
-
|
|
311
|
-
```
|
|
312
|
-
StandardError
|
|
313
|
-
└─ Wintoast::Error misuse / non-OS failures (e.g. invalid UTF-8)
|
|
314
|
-
└─ Wintoast::OSError OS API failures; #code => Integer
|
|
315
|
-
(HRESULT for COM/WinRT, Win32 code for registry)
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
Plain argument-shape problems raise Ruby's own `ArgumentError` / `TypeError`.
|
|
319
|
-
|
|
320
|
-
These are **not** errors (by design — the platform cannot report them):
|
|
321
|
-
|
|
322
|
-
- a toast sent to an unregistered AUMID is silently dropped;
|
|
323
|
-
- Focus Assist / Do-Not-Disturb suppresses the banner (the toast still reaches
|
|
324
|
-
the Notification Center);
|
|
325
|
-
- a per-app or policy toggle disables toasts;
|
|
326
|
-
- `progress` returns `false` when no console surface accepted.
|
|
327
|
-
|
|
328
|
-
## License
|
|
329
|
-
|
|
330
|
-
[MIT](LICENSE.txt).
|
|
331
|
-
|
|
332
|
-
[BurntToast]: https://github.com/Windos/BurntToast
|
|
333
|
-
[Windows App SDK]: https://learn.microsoft.com/windows/apps/windows-app-sdk/
|
|
334
|
-
[mohabouje/WinToast]: https://github.com/mohabouje/WinToast
|
|
335
|
-
[microsoft/terminal#14268]: https://github.com/microsoft/terminal/discussions/14268
|
|
1
|
+
# wintoast (discontinued)
|
|
2
|
+
|
|
3
|
+
**wintoast is no longer maintained.** This final release (0.1.1) contains no
|
|
4
|
+
code: installing it compiles nothing, and `require "wintoast"` raises a
|
|
5
|
+
`LoadError` explaining the discontinuation.
|
|
6
|
+
|
|
7
|
+
Use win_toaster instead.
|
|
8
|
+
|
|
9
|
+
Earlier releases remain on RubyGems only because RubyGems does not allow
|
|
10
|
+
versions older than 30 days to be yanked. They are unmaintained and not
|
|
11
|
+
recommended.
|
data/lib/wintoast.rb
CHANGED
|
@@ -1,270 +1,4 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require "wintoast/payload"
|
|
6
|
-
|
|
7
|
-
# wintoast — fire-and-forget Windows toast notifications and taskbar/terminal
|
|
8
|
-
# progress, built on the inbox WinRT and shell APIs that already ship with
|
|
9
|
-
# Windows. No Windows App SDK, no packaging, no COM activation server, no
|
|
10
|
-
# elevation, nothing to install but the gem.
|
|
11
|
-
#
|
|
12
|
-
# require "wintoast"
|
|
13
|
-
#
|
|
14
|
-
# Wintoast.toast("Backup finished", "1,204 files in 38 s") # => nil (a banner pops)
|
|
15
|
-
# Wintoast.progress(50) # => true/false
|
|
16
|
-
# Wintoast.progress_clear # => true/false
|
|
17
|
-
#
|
|
18
|
-
# A normal return from #toast means the OS ACCEPTED the toast — NOT that it was
|
|
19
|
-
# displayed. Silent suppression (unregistered AUMID, Focus Assist, per-app
|
|
20
|
-
# toggle, group policy) is undetectable by design of the platform; the gem never
|
|
21
|
-
# pretends otherwise. See the README "The one trap you must know about".
|
|
22
|
-
module Wintoast
|
|
23
|
-
# Misuse / non-OS failures (e.g. invalid UTF-8). OS API failures are OSError.
|
|
24
|
-
class Error < StandardError; end
|
|
25
|
-
|
|
26
|
-
# OS API failure. #code is an Integer: the HRESULT for COM/WinRT, or the Win32
|
|
27
|
-
# error code for registry calls. Attached in C via rb_iv_set(exc, "@code", ...).
|
|
28
|
-
class OSError < Error
|
|
29
|
-
def code = @code
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# The AUMID of Windows PowerShell's Start-Menu shortcut — registered on every
|
|
33
|
-
# supported Windows box, so toasts sent with it always render (branded
|
|
34
|
-
# "Windows PowerShell"). The zero-setup default for Wintoast.toast.
|
|
35
|
-
POWERSHELL_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
|
|
36
|
-
|
|
37
|
-
module_function
|
|
38
|
-
|
|
39
|
-
# Fire-and-forget toast notification. Returns nil, always — a normal return
|
|
40
|
-
# means the OS accepted the toast, not that it was displayed (silent drops are
|
|
41
|
-
# undetectable). See §2.2 of the spec / the README for every kwarg.
|
|
42
|
-
#
|
|
43
|
-
# Raises ArgumentError / TypeError on bad arguments, Wintoast::Error on invalid
|
|
44
|
-
# UTF-8, and Wintoast::OSError (with #code = HRESULT) on a WinRT API failure.
|
|
45
|
-
def toast(title, body = nil,
|
|
46
|
-
aumid: POWERSHELL_AUMID,
|
|
47
|
-
attribution: nil,
|
|
48
|
-
image: nil,
|
|
49
|
-
hero: nil,
|
|
50
|
-
circle: false,
|
|
51
|
-
audio: :default,
|
|
52
|
-
duration: :short,
|
|
53
|
-
scenario: nil,
|
|
54
|
-
expires_at: nil,
|
|
55
|
-
expires_in: nil,
|
|
56
|
-
tag: nil,
|
|
57
|
-
group: nil)
|
|
58
|
-
# Build the XML first: it validates title/body/attribution/image/hero/
|
|
59
|
-
# circle/audio/duration/scenario and normalizes + UTF-8-checks every text
|
|
60
|
-
# field, all in pure Ruby BEFORE any C bridge runs (the §3.5 regime).
|
|
61
|
-
xml = Payload.build(title, body,
|
|
62
|
-
attribution: attribution, image: image, hero: hero,
|
|
63
|
-
circle: circle, audio: audio, duration: duration,
|
|
64
|
-
scenario: scenario)
|
|
65
|
-
|
|
66
|
-
aumid_u8 = validate_aumid_arg(aumid)
|
|
67
|
-
expire_ms = validate_expiry(expires_at, expires_in)
|
|
68
|
-
tag_u8 = validate_label(tag, "tag")
|
|
69
|
-
group_u8 = validate_label(group, "group")
|
|
70
|
-
|
|
71
|
-
_show(aumid_u8, xml, expire_ms, tag_u8, group_u8)
|
|
72
|
-
nil
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Opt-in, reversible, per-user branding: writes ONLY
|
|
76
|
-
# HKCU\Software\Classes\AppUserModelId\<aumid> {DisplayName, IconUri?}.
|
|
77
|
-
# No elevation, no Start-Menu shortcut, no HKLM, no COM activator. Returns the
|
|
78
|
-
# aumid String. Raises ArgumentError on format violations, Wintoast::OSError
|
|
79
|
-
# (#code = Win32 error) on registry failures.
|
|
80
|
-
def register!(aumid:, display_name:, icon: nil)
|
|
81
|
-
aumid_u8 = validate_registry_aumid(aumid)
|
|
82
|
-
|
|
83
|
-
dn = String.try_convert(display_name)
|
|
84
|
-
raise TypeError, "wintoast: display_name: must be a String" if dn.nil?
|
|
85
|
-
|
|
86
|
-
dn = normalize_text(dn, "display_name")
|
|
87
|
-
raise ArgumentError, "wintoast: display_name: must be non-empty" if dn.empty?
|
|
88
|
-
|
|
89
|
-
icon_u8 =
|
|
90
|
-
if icon.nil?
|
|
91
|
-
nil
|
|
92
|
-
else
|
|
93
|
-
ic = normalize_text(icon, "icon")
|
|
94
|
-
unless File.absolute_path?(ic) && File.file?(ic)
|
|
95
|
-
raise ArgumentError, "wintoast: icon: must be an absolute path to an existing file"
|
|
96
|
-
end
|
|
97
|
-
ic
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
_register(aumid_u8, dn, icon_u8)
|
|
101
|
-
aumid_u8
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Delete the HKCU AppUserModelId key tree for the given aumid. Idempotent:
|
|
105
|
-
# returns false (no raise) when the key was not present. Only unregister AUMIDs
|
|
106
|
-
# YOU registered — deleting another app's HKCU key breaks its toasts.
|
|
107
|
-
def unregister!(aumid:)
|
|
108
|
-
_unregister(validate_registry_aumid(aumid))
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Drive taskbar + terminal progress. Returns true if at least one OS surface
|
|
112
|
-
# accepted the update, false if none did (no console at all, or a console whose
|
|
113
|
-
# taskbar leg also failed). Accepted != visible. Environmental failure is a
|
|
114
|
-
# false, never an exception — only argument misuse raises ArgumentError.
|
|
115
|
-
#
|
|
116
|
-
# progress(50) determinate, green
|
|
117
|
-
# progress(7, of: 23) determinate from a ratio
|
|
118
|
-
# progress(50, state: :error) determinate, red
|
|
119
|
-
# progress(50, state: :paused) determinate, yellow
|
|
120
|
-
# progress(state: :indeterminate) marquee/ring (value must be nil)
|
|
121
|
-
# progress(nil) / progress(:clear) remove
|
|
122
|
-
def progress(value = nil, of: 100, state: nil)
|
|
123
|
-
unless of.is_a?(Numeric) && of.positive?
|
|
124
|
-
raise ArgumentError, "wintoast: of: must be a positive number, got #{of.inspect}"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# :indeterminate is value-less (the marquee ignores any percent). value must
|
|
128
|
-
# be nil; a supplied value (Numeric or :clear) is misuse. Checked first so
|
|
129
|
-
# progress(state: :indeterminate) is NOT mistaken for a clear.
|
|
130
|
-
if state == :indeterminate
|
|
131
|
-
unless value.nil?
|
|
132
|
-
raise ArgumentError, "wintoast: state: :indeterminate ignores value — pass value nil"
|
|
133
|
-
end
|
|
134
|
-
return _progress(3, 0)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Clearing: value nil or :clear, and (because clear has no color) no state:.
|
|
138
|
-
if value.nil? || value == :clear
|
|
139
|
-
unless state.nil?
|
|
140
|
-
raise ArgumentError, "wintoast: clearing progress takes no state:, got #{state.inspect}"
|
|
141
|
-
end
|
|
142
|
-
return _progress(0, 0)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Determinate: requires a Numeric value.
|
|
146
|
-
unless value.is_a?(Numeric)
|
|
147
|
-
raise ArgumentError, "wintoast: progress value must be a number, got #{value.inspect}"
|
|
148
|
-
end
|
|
149
|
-
state_int =
|
|
150
|
-
case state
|
|
151
|
-
when nil, :normal then 1
|
|
152
|
-
when :error then 2
|
|
153
|
-
when :paused then 4
|
|
154
|
-
else
|
|
155
|
-
raise ArgumentError, "wintoast: unknown state: #{state.inspect}"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
_progress(state_int, pct(value, of))
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Remove progress. Equivalent to progress(nil).
|
|
162
|
-
def progress_clear = _progress(0, 0)
|
|
163
|
-
|
|
164
|
-
# ---- validation helpers (private) ----------------------------------------
|
|
165
|
-
|
|
166
|
-
# Normalize any String to UTF-8 and reject invalid UTF-8 in pure Ruby (§3.5).
|
|
167
|
-
def normalize_text(value, label)
|
|
168
|
-
str = String.try_convert(value)
|
|
169
|
-
raise TypeError, "wintoast: #{label} must be a String, got #{value.class}" if str.nil?
|
|
170
|
-
|
|
171
|
-
str = str.encode(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
|
|
172
|
-
unless str.valid_encoding?
|
|
173
|
-
raise Wintoast::Error, "wintoast: invalid UTF-8 in #{label}"
|
|
174
|
-
end
|
|
175
|
-
str
|
|
176
|
-
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
177
|
-
raise Wintoast::Error, "wintoast: invalid UTF-8 in #{label}"
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# aumid: for toast() — must be a non-empty String (StringValue semantics).
|
|
181
|
-
def validate_aumid_arg(aumid)
|
|
182
|
-
str = String.try_convert(aumid)
|
|
183
|
-
raise TypeError, "wintoast: aumid: must be a String, got #{aumid.class}" if str.nil?
|
|
184
|
-
|
|
185
|
-
str = normalize_text(str, "aumid")
|
|
186
|
-
raise ArgumentError, "wintoast: aumid: must not be empty" if str.empty?
|
|
187
|
-
str
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# aumid: for register!/unregister! — the documented AUMID format: 1..128
|
|
191
|
-
# chars, no spaces, no control chars/NUL. Backslashes allowed (nested keys).
|
|
192
|
-
def validate_registry_aumid(aumid)
|
|
193
|
-
str = String.try_convert(aumid)
|
|
194
|
-
raise TypeError, "wintoast: aumid: must be a String, got #{aumid.class}" if str.nil?
|
|
195
|
-
|
|
196
|
-
str = normalize_text(str, "aumid")
|
|
197
|
-
if str.empty? || str.length > 128
|
|
198
|
-
raise ArgumentError, "wintoast: aumid: must be 1..128 characters, got #{str.length}"
|
|
199
|
-
end
|
|
200
|
-
if str.include?(" ")
|
|
201
|
-
raise ArgumentError, "wintoast: aumid: must not contain spaces"
|
|
202
|
-
end
|
|
203
|
-
if str.match?(/[\x00-\x1f]/)
|
|
204
|
-
raise ArgumentError, "wintoast: aumid: must not contain control characters"
|
|
205
|
-
end
|
|
206
|
-
str
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# tag:/group: -> nil or a 1..64-char String. Empty or > 64 raises.
|
|
210
|
-
def validate_label(value, label)
|
|
211
|
-
return nil if value.nil?
|
|
212
|
-
|
|
213
|
-
str = String.try_convert(value)
|
|
214
|
-
raise TypeError, "wintoast: #{label}: must be a String, got #{value.class}" if str.nil?
|
|
215
|
-
|
|
216
|
-
str = normalize_text(str, label)
|
|
217
|
-
if str.empty? || str.length > 64
|
|
218
|
-
raise ArgumentError, "wintoast: #{label}: must be 1..64 characters, got #{str.length}"
|
|
219
|
-
end
|
|
220
|
-
str
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# The epoch-ms whose WinRT DateTime tick — (ms + 11644473600000) * 10000, in
|
|
224
|
-
# 100-ns units since 1601 — still fits a signed int64. The C bridge performs
|
|
225
|
-
# exactly that multiply (wintoast.cpp), so any ms past this point would overflow
|
|
226
|
-
# int64 (undefined behavior) and hand the OS a garbage UniversalTime. The OS
|
|
227
|
-
# caps toast retention at 3 days anyway, so a far-future expiry is already
|
|
228
|
-
# meaningless; clamping here is observably correct and removes the UB window.
|
|
229
|
-
MAX_EXPIRE_MS = (2**63 - 1) / 10_000 - 11_644_473_600_000
|
|
230
|
-
private_constant :MAX_EXPIRE_MS
|
|
231
|
-
|
|
232
|
-
# expires_at:/expires_in: -> the absolute Unix epoch milliseconds, or nil.
|
|
233
|
-
# Mutually exclusive. expires_in must be a positive Numeric (seconds from now);
|
|
234
|
-
# expires_at must be a Time (past times pass through — the OS treats them as
|
|
235
|
-
# already expired; clocks skew, so we don't police it). The result is clamped
|
|
236
|
-
# to MAX_EXPIRE_MS so the C-side tick multiply can never overflow int64.
|
|
237
|
-
def validate_expiry(expires_at, expires_in)
|
|
238
|
-
if expires_at && expires_in
|
|
239
|
-
raise ArgumentError, "wintoast: pass only one of expires_at:/expires_in:"
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
if expires_in
|
|
243
|
-
unless expires_in.is_a?(Numeric) && expires_in.positive?
|
|
244
|
-
raise ArgumentError, "wintoast: expires_in: must be a positive number of seconds"
|
|
245
|
-
end
|
|
246
|
-
ms = ((Time.now.to_f + expires_in.to_f) * 1000.0).round
|
|
247
|
-
return ms.clamp(0, MAX_EXPIRE_MS)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
if expires_at
|
|
251
|
-
unless expires_at.is_a?(Time)
|
|
252
|
-
raise ArgumentError, "wintoast: expires_at: must be a Time, got #{expires_at.class}"
|
|
253
|
-
end
|
|
254
|
-
ms = (expires_at.to_f * 1000.0).round
|
|
255
|
-
return ms.clamp(0, MAX_EXPIRE_MS)
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
nil
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
# Percent = round(value/of * 100), clamped 0..100. Overshoot is a routine
|
|
262
|
-
# rounding artifact in app loops, so it is clamped, not raised.
|
|
263
|
-
def pct(value, of)
|
|
264
|
-
((value.to_f / of.to_f) * 100.0).round.clamp(0, 100)
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
private_class_method :_show, :_register, :_unregister, :_progress
|
|
268
|
-
private_class_method :normalize_text, :validate_aumid_arg, :validate_registry_aumid,
|
|
269
|
-
:validate_label, :validate_expiry, :pct
|
|
270
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
raise LoadError, "wintoast is discontinued and this release contains no code. Use win_toaster instead." \
|
|
4
|
+
" Earlier wintoast releases are unmaintained and not recommended."
|