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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +335 -0
- data/ext/wintoast/extconf.rb +36 -0
- data/ext/wintoast/wintoast.cpp +501 -0
- data/lib/wintoast/payload.rb +195 -0
- data/lib/wintoast/version.rb +5 -0
- data/lib/wintoast.rb +270 -0
- metadata +127 -0
|
@@ -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("&", "&")
|
|
141
|
+
.gsub("<", "<")
|
|
142
|
+
.gsub(">", ">")
|
|
143
|
+
.gsub('"', """)
|
|
144
|
+
.gsub("'", "'")
|
|
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
|
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: []
|