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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wintoast
4
+ # Wintoast::Payload — the ToastGeneric XML builder.
5
+ #
6
+ # A pure function with no OS calls: it turns a title/body plus presentation
7
+ # kwargs into the exact UTF-8 XML string that Wintoast.toast hands to the WinRT
8
+ # LoadXml call. Exposed publicly so you can inspect "what would you send?" and
9
+ # as the unit-test seam for the escaping / control-char / audio-table rules.
10
+ #
11
+ # Wintoast::Payload.build("Build done", "42 files", audio: :mail)
12
+ # # => "<toast>...<binding template=\"ToastGeneric\">...</toast>"
13
+ #
14
+ # It validates only the kwargs that affect the XML (attribution:, image:,
15
+ # hero:, circle:, audio:, duration:, scenario:). aumid:/tag:/group:/expires_*
16
+ # are WinRT properties, not part of the XML, and are NOT accepted here.
17
+ module Payload
18
+ module_function
19
+
20
+ # Accepted <audio> symbols -> the ms-winsoundevent token (without the
21
+ # "ms-winsoundevent:" prefix) and whether the sound loops. These are the
22
+ # ONLY sounds Windows honors for unpackaged apps; arbitrary file paths
23
+ # silently fall back to the default sound, so they are not accepted.
24
+ SHORT_SOUNDS = {
25
+ im: "Notification.IM",
26
+ mail: "Notification.Mail",
27
+ reminder: "Notification.Reminder",
28
+ sms: "Notification.SMS"
29
+ }.freeze
30
+ private_constant :SHORT_SOUNDS
31
+
32
+ # Looping families: :alarm/:call plus :alarm2..:alarm10 / :call2..:call10.
33
+ # ms-winsoundevent uses "Looping.Alarm" (no 1) and "Looping.Alarm2".."10".
34
+ DURATIONS = %i[short long].freeze
35
+ private_constant :DURATIONS
36
+
37
+ SCENARIOS = {
38
+ alarm: "alarm",
39
+ incoming_call: "incomingCall",
40
+ urgent: "urgent"
41
+ }.freeze
42
+ private_constant :SCENARIOS
43
+
44
+ # Build the ToastGeneric XML. Returns a fresh, unfrozen UTF-8 String.
45
+ def build(title, body = nil,
46
+ attribution: nil,
47
+ image: nil,
48
+ hero: nil,
49
+ circle: false,
50
+ audio: :default,
51
+ duration: :short,
52
+ scenario: nil)
53
+ title_text = scrub_required(title, "title")
54
+ body_text = body.nil? ? nil : scrub_text(body, "body")
55
+ attr_text = attribution.nil? ? nil : scrub_text(attribution, "attribution")
56
+
57
+ unless DURATIONS.include?(duration)
58
+ raise ArgumentError, "wintoast: duration: must be :short or :long, got #{duration.inspect}"
59
+ end
60
+ unless scenario.nil? || SCENARIOS.key?(scenario)
61
+ raise ArgumentError,
62
+ "wintoast: scenario: must be nil, :alarm, :incoming_call, or :urgent, got #{scenario.inspect}"
63
+ end
64
+
65
+ image_src = validate_image(image, "image")
66
+ hero_src = validate_image(hero, "hero")
67
+ if circle && image_src.nil?
68
+ raise ArgumentError, "wintoast: circle: true requires image:"
69
+ end
70
+
71
+ audio_xml = build_audio(audio)
72
+
73
+ toast_attrs = +""
74
+ toast_attrs << %( duration="long") if duration == :long
75
+ toast_attrs << %( scenario="#{SCENARIOS[scenario]}") if scenario
76
+
77
+ xml = +"<toast#{toast_attrs}>"
78
+ xml << "<visual><binding template=\"ToastGeneric\">"
79
+ xml << "<text>#{esc(title_text)}</text>"
80
+ xml << "<text>#{esc(body_text)}</text>" if body_text
81
+ xml << "<text placement=\"attribution\">#{esc(attr_text)}</text>" if attr_text
82
+ if image_src
83
+ crop = circle ? %( hint-crop="circle") : ""
84
+ xml << %(<image placement="appLogoOverride"#{crop} src="#{esc(image_src)}"/>)
85
+ end
86
+ if hero_src
87
+ xml << %(<image placement="hero" src="#{esc(hero_src)}"/>)
88
+ end
89
+ xml << "</binding></visual>"
90
+ xml << audio_xml if audio_xml
91
+ xml << "</toast>"
92
+ xml
93
+ end
94
+
95
+ # ---- validation / escaping helpers (module_function makes them callable) --
96
+
97
+ # Validate a String argument exists, normalize to UTF-8, and reject invalid
98
+ # UTF-8 BEFORE anything else (the §3.5 rule: never let bad bytes reach the C
99
+ # boundary — they would otherwise become a silently blank toast or a longjmp
100
+ # from the C conversion helper). Returns the UTF-8 String (control chars NOT
101
+ # yet scrubbed).
102
+ def normalize_utf8(value, label)
103
+ str = String.try_convert(value)
104
+ raise TypeError, "wintoast: #{label} must be a String, got #{value.class}" if str.nil?
105
+
106
+ str = str.encode(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
107
+ unless str.valid_encoding?
108
+ raise Wintoast::Error, "wintoast: invalid UTF-8 in #{label}"
109
+ end
110
+ str
111
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
112
+ # A string tagged in another encoding that cannot transcode to UTF-8.
113
+ raise Wintoast::Error, "wintoast: invalid UTF-8 in #{label}"
114
+ end
115
+
116
+ # Strip the C0 control characters that XML 1.0 forbids even as references
117
+ # (everything < 0x20 except TAB/LF/CR). Returns a scrubbed UTF-8 String.
118
+ def scrub_text(value, label)
119
+ normalize_utf8(value, label).gsub(/[\x00-\x08\x0b\x0c\x0e-\x1f]/, "")
120
+ end
121
+
122
+ # Like scrub_text but the result must be non-empty after scrubbing. A nil
123
+ # title is the "missing/empty" case (ArgumentError per §2.2), not a
124
+ # type error.
125
+ def scrub_required(value, label)
126
+ if value.nil?
127
+ raise ArgumentError, "wintoast: #{label} must be a non-empty string"
128
+ end
129
+
130
+ text = scrub_text(value, label)
131
+ if text.empty?
132
+ raise ArgumentError, "wintoast: #{label} must be a non-empty string"
133
+ end
134
+ text
135
+ end
136
+
137
+ # XML-escape the five metacharacters. Attribute positions are always
138
+ # double-quoted, so escaping " and ' covers both text and attribute use.
139
+ def esc(str)
140
+ str.gsub("&", "&amp;")
141
+ .gsub("<", "&lt;")
142
+ .gsub(">", "&gt;")
143
+ .gsub('"', "&quot;")
144
+ .gsub("'", "&apos;")
145
+ end
146
+
147
+ # Validate an image:/hero: path (nil passes through). Must be an absolute
148
+ # path to an existing regular file. Returns the scrubbed UTF-8 path or nil.
149
+ def validate_image(path, label)
150
+ return nil if path.nil?
151
+
152
+ str = normalize_utf8(path, label)
153
+ unless File.absolute_path?(str) && File.file?(str)
154
+ raise ArgumentError, "wintoast: #{label}: must be an absolute path to an existing file"
155
+ end
156
+ # Control chars are illegal in XML even in attributes; scrub for safety.
157
+ str.gsub(/[\x00-\x08\x0b\x0c\x0e-\x1f]/, "")
158
+ end
159
+
160
+ # Map audio: to an <audio> element string, or nil when the OS default is
161
+ # wanted (element omitted entirely).
162
+ def build_audio(audio)
163
+ case audio
164
+ when :default
165
+ nil
166
+ when false
167
+ %(<audio silent="true"/>)
168
+ when Symbol
169
+ if SHORT_SOUNDS.key?(audio)
170
+ %(<audio src="ms-winsoundevent:#{SHORT_SOUNDS[audio]}"/>)
171
+ elsif (token = looping_sound(audio))
172
+ %(<audio src="ms-winsoundevent:#{token}" loop="true"/>)
173
+ else
174
+ raise ArgumentError, "wintoast: unknown audio: #{audio.inspect}"
175
+ end
176
+ else
177
+ raise ArgumentError, "wintoast: unknown audio: #{audio.inspect}"
178
+ end
179
+ end
180
+
181
+ # :alarm / :alarm2..:alarm10 / :call / :call2..:call10 -> the Looping token,
182
+ # or nil if the symbol isn't a looping sound. :alarm1/:call1 and :alarm11+
183
+ # are rejected (Windows names them "Alarm" and "Alarm2".."Alarm10").
184
+ def looping_sound(sym)
185
+ m = /\A(alarm|call)([2-9]|10)?\z/.match(sym.to_s)
186
+ return nil unless m
187
+
188
+ base = m[1] == "alarm" ? "Alarm" : "Call"
189
+ "Notification.Looping.#{base}#{m[2]}"
190
+ end
191
+
192
+ private_class_method :normalize_utf8, :scrub_text, :scrub_required, :esc,
193
+ :validate_image, :build_audio, :looping_sound
194
+ end
195
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wintoast
4
+ VERSION = "0.1.0"
5
+ end
data/lib/wintoast.rb ADDED
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wintoast/version"
4
+ require "wintoast/wintoast" # native ext: defines _show/_register/_unregister/_progress + errors
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
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wintoast
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vcvars
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.1.1
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.1.1
74
+ description: |
75
+ wintoast pops native Windows toast notifications from plain Ruby scripts using
76
+ the WinRT notification API that ships with Windows — no Windows App SDK, no
77
+ packaging, no COM activation server, no elevation — and drives progress on the
78
+ taskbar button (ITaskbarList3) and the Windows Terminal tab (OSC 9;4) at the
79
+ same time, so progress shows up under classic conhost AND modern terminals.
80
+
81
+ API: Wintoast.toast (title/body/attribution, app logo + hero images, system
82
+ sounds, duration, scenario, expiration, tag/group), Wintoast.register! /
83
+ unregister! (opt-in, reversible, per-user HKCU AppUserModelId branding — no
84
+ shortcut, no admin), Wintoast.progress / progress_clear, and
85
+ Wintoast::Payload.build for inspecting the exact toast XML. Windows MSVC
86
+ (mswin) Ruby only.
87
+ executables: []
88
+ extensions:
89
+ - ext/wintoast/extconf.rb
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - LICENSE.txt
94
+ - README.md
95
+ - ext/wintoast/extconf.rb
96
+ - ext/wintoast/wintoast.cpp
97
+ - lib/wintoast.rb
98
+ - lib/wintoast/payload.rb
99
+ - lib/wintoast/version.rb
100
+ homepage: https://github.com/main-path/wintoast
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ homepage_uri: https://github.com/main-path/wintoast
105
+ source_code_uri: https://github.com/main-path/wintoast
106
+ changelog_uri: https://github.com/main-path/wintoast/blob/main/CHANGELOG.md
107
+ bug_tracker_uri: https://github.com/main-path/wintoast/issues
108
+ rubygems_mfa_required: 'true'
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '3.0'
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.6.9
124
+ specification_version: 4
125
+ summary: Fire-and-forget Windows toast notifications and taskbar/terminal progress,
126
+ via inbox WinRT and shell APIs.
127
+ test_files: []