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.
@@ -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("&", "&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
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Wintoast
4
- VERSION = "0.1.0"
5
- end