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
data/lib/wintoast/payload.rb
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
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
|