windoo 1.0.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGES.md +9 -0
  3. data/LICENSE.txt +177 -0
  4. data/README.md +222 -0
  5. data/lib/windoo/base_classes/array_manager.rb +335 -0
  6. data/lib/windoo/base_classes/criteria_manager.rb +327 -0
  7. data/lib/windoo/base_classes/criterion.rb +226 -0
  8. data/lib/windoo/base_classes/json_object.rb +472 -0
  9. data/lib/windoo/configuration.rb +221 -0
  10. data/lib/windoo/connection/actions.rb +152 -0
  11. data/lib/windoo/connection/attributes.rb +156 -0
  12. data/lib/windoo/connection/connect.rb +402 -0
  13. data/lib/windoo/connection/constants.rb +55 -0
  14. data/lib/windoo/connection/token.rb +489 -0
  15. data/lib/windoo/connection.rb +92 -0
  16. data/lib/windoo/converters.rb +31 -0
  17. data/lib/windoo/exceptions.rb +34 -0
  18. data/lib/windoo/mixins/api_collection.rb +408 -0
  19. data/lib/windoo/mixins/constants.rb +43 -0
  20. data/lib/windoo/mixins/default_connection.rb +75 -0
  21. data/lib/windoo/mixins/immutable.rb +34 -0
  22. data/lib/windoo/mixins/loading.rb +38 -0
  23. data/lib/windoo/mixins/patch/component.rb +102 -0
  24. data/lib/windoo/mixins/software_title/extension_attribute.rb +106 -0
  25. data/lib/windoo/mixins/utility.rb +23 -0
  26. data/lib/windoo/objects/capability.rb +82 -0
  27. data/lib/windoo/objects/capability_manager.rb +52 -0
  28. data/lib/windoo/objects/component.rb +99 -0
  29. data/lib/windoo/objects/component_criteria_manager.rb +26 -0
  30. data/lib/windoo/objects/component_criterion.rb +66 -0
  31. data/lib/windoo/objects/extension_attribute.rb +149 -0
  32. data/lib/windoo/objects/kill_app.rb +92 -0
  33. data/lib/windoo/objects/kill_app_manager.rb +89 -0
  34. data/lib/windoo/objects/patch.rb +235 -0
  35. data/lib/windoo/objects/patch_manager.rb +240 -0
  36. data/lib/windoo/objects/requirement.rb +85 -0
  37. data/lib/windoo/objects/requirement_manager.rb +52 -0
  38. data/lib/windoo/objects/software_title.rb +407 -0
  39. data/lib/windoo/validate.rb +548 -0
  40. data/lib/windoo/version.rb +15 -0
  41. data/lib/windoo/zeitwerk_config.rb +158 -0
  42. data/lib/windoo.rb +56 -0
  43. metadata +141 -0
@@ -0,0 +1,489 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+
7
+ # frozen_string_literal: true
8
+
9
+ module Windoo
10
+
11
+ class Connection
12
+
13
+ # A token used for a connection to the Title Editor API
14
+ class Token
15
+
16
+ AUTH_RSRC = 'auth'
17
+
18
+ NEW_TOKEN_RSRC = "#{AUTH_RSRC}/tokens"
19
+
20
+ REFRESH_RSRC = "#{AUTH_RSRC}/keepalive"
21
+
22
+ CURRENT_STATUS_RSRC = "#{AUTH_RSRC}/current"
23
+
24
+ # Seconds before expiration that the token will automatically refresh
25
+ REFRESH_BUFFER = 300
26
+
27
+ # Used bu the last_refresh_result method
28
+ REFRESH_RESULTS = {
29
+ refreshed: 'Refreshed',
30
+ refreshed_pw: 'Refresh failed, but new token created with cached pw',
31
+ refresh_failed: 'Refresh failed, could not create new token with cached pw',
32
+ refresh_failed_no_pw_fallback: 'Refresh failed, but pw_fallback was false',
33
+ expired_refreshed: 'Expired, but new token created with cached pw',
34
+ expired_failed: 'Expired, could not create new token with cached pw',
35
+ expired_no_pw_fallback: 'Expired, but pw_fallback was false'
36
+ }.freeze
37
+
38
+ # @return [String] The user who generated this token
39
+ attr_reader :user
40
+
41
+ # @return [Integer] The user id of the @user on the server
42
+ attr_reader :user_id
43
+
44
+ # @return [Array<String>] The permissions of the @user
45
+ attr_reader :scope
46
+ alias permissions scope
47
+
48
+ # @return [String] the SSL version being used
49
+ attr_reader :ssl_version
50
+
51
+ # @return [Boolean] are we verifying SSL certs?
52
+ attr_reader :verify_cert
53
+ alias verify_cert? verify_cert
54
+
55
+ # @return [Hash] the ssl version and verify cert, to pass into faraday connections
56
+ attr_reader :ssl_options
57
+
58
+ # @return [String] The token data
59
+ attr_reader :token
60
+ alias token_string token
61
+ alias auth_token token
62
+
63
+ # @return [URI] The base API url, e.g. https://yourserver.appcatalog.jamfcloud.com
64
+ attr_reader :base_url
65
+
66
+ # @return [Time] when was this Windoo::TitleServer::Connection::Token originally created?
67
+ attr_reader :creation_time
68
+ alias login_time creation_time
69
+
70
+ # @return [Time] when was this token last refreshed?
71
+ attr_reader :last_refresh
72
+
73
+ # @return [Time]
74
+ attr_reader :expires
75
+ alias expiration expires
76
+
77
+ # @return [Boolean] does this token automatically refresh itself before
78
+ # expiring?
79
+ attr_reader :keep_alive
80
+ alias keep_alive? keep_alive
81
+
82
+ # @return [Boolean] Should the provided passwd be cached in memory, to be
83
+ # used to generate a new token, if a normal refresh fails?
84
+ attr_reader :pw_fallback
85
+ alias pw_fallback? pw_fallback
86
+
87
+ # @return [Faraday::Response] The response object from instantiating
88
+ # a new Token object by creating a new token or validating a token
89
+ # string. This is not updated when refreshing a token, only when
90
+ # calling Token.new
91
+ attr_reader :creation_http_response
92
+
93
+ # @return [Time] The time the server created the token
94
+ attr_reader :server_creation_time
95
+
96
+ # @return [String] The tenantID of this server connection
97
+ attr_reader :tenantId
98
+ alias tenant_id tenantId
99
+
100
+ # @return [String] The fully qualified hostname of the server
101
+ # that generated this token
102
+ attr_reader :domain
103
+
104
+ # @param params [Hash] The data for creating and maintaining the token
105
+ #
106
+ # @option params [String, URI] base_url: The url for the Jamf Pro server
107
+ # including host and port, e.g. 'https://yourserver.appcatalog.jamfcloud.com'
108
+ #
109
+ # @option params [String] user: (see Connection#initialize)
110
+ #
111
+ # @option params [String] token_string: An existing valid token string.
112
+ # If provided, no need to provide 'user', which will be read from the
113
+ # server. If pw_fallback is true (the default) you will also need to provide
114
+ # the password for the user who created the token in the pw: parameter.
115
+ # If you don't, pw_fallback will be false even if you set it to true explicitly.
116
+ #
117
+ # @option params [String] pw: (see Connection#initialize)
118
+ #
119
+ # @option params [Integer] timeout: The http timeout for communication
120
+ # with the server. This is only used for token-related communication, not
121
+ # general API usage, and so need not be the same as that for the
122
+ # connection that uses this token.
123
+ #
124
+ # @option params [Boolean] keep_alive: (see Connection#connect)
125
+ #
126
+ # @option params [Boolean] pw_fallback: (see Connection#connect)
127
+ #
128
+ # @option params [String, Symbol] ssl_version: (see Connection#connect)
129
+ #
130
+ # @option params [Boolean] verify_cert: (see Connection#connect)
131
+ #
132
+ ###########################################
133
+ def initialize(**params)
134
+ @valid = false
135
+ parse_params(**params)
136
+
137
+ if params[:token_string]
138
+ @pw_fallback = false unless @pw
139
+ @creation_http_response = init_from_token_string params[:token_string]
140
+
141
+ elsif @user && @pw
142
+ @creation_http_response = init_from_pw
143
+
144
+ else
145
+ raise ArgumentError, 'Must provide either user: & pw: or token_string:'
146
+ end
147
+
148
+ start_keep_alive if @keep_alive
149
+ @creation_time = Time.now
150
+ end # init
151
+
152
+ # Initialize from password
153
+ # @return [Faraday::Response] the response from checking the status,
154
+ # which might be used to set @creation_http_response
155
+ #################################
156
+ def init_from_pw
157
+ resp = token_connection(NEW_TOKEN_RSRC).post
158
+
159
+ if resp.success?
160
+ @token = resp.body[:token]
161
+ parse_token_status
162
+ @last_refresh = Time.now
163
+ resp
164
+ elsif resp.status == 401
165
+ raise Windoo::AuthenticationError, 'Incorrect user name or password'
166
+ else
167
+ # TODO: better error reporting here
168
+ puts
169
+ puts resp.status
170
+ puts resp.body
171
+ puts
172
+ raise Windoo::ConnectionError, "An error occurred while authenticating: #{resp.body}"
173
+ end
174
+ ensure
175
+ @pw = nil unless @pw_fallback
176
+ end # init_from_pw
177
+
178
+ # Initialize from token string
179
+ # @return [Faraday::Response] the response from checking the status,
180
+ # which might be used to set @creation_http_response
181
+ #################################
182
+ def init_from_token_string(str)
183
+ @token = str
184
+ parse_token_status
185
+
186
+ # we now know the @user who created the token string.
187
+ # if we were given a pw and expect to use it, call init_from_pw
188
+ # to validate it by getting a fresh token
189
+ return init_from_pw if @pw && @pw_fallback
190
+
191
+ # use this token to get a fresh one with the full
192
+ # 15 min lifespan
193
+ refresh
194
+ end # init_from_token_string
195
+
196
+ #################################
197
+ def host
198
+ base_url.host
199
+ end
200
+
201
+ # @return [Integer]
202
+ #################################
203
+ def port
204
+ base_url.port
205
+ end
206
+
207
+ # @return [Boolean]
208
+ #################################
209
+ def expired?
210
+ return unless @expires
211
+
212
+ Time.now >= @expires
213
+ end
214
+
215
+ # when is the next rerefresh going to happen, if we are set to keep alive?
216
+ #
217
+ # @return [Time, nil] the time of the next scheduled refresh, or nil if not keep_alive?
218
+ def next_refresh
219
+ return unless keep_alive?
220
+
221
+ @expires - REFRESH_BUFFER
222
+ end
223
+
224
+ # how many secs until the next refresh?
225
+ # will return 0 during the actual refresh process.
226
+ #
227
+ # @return [Float, nil] Seconds until the next scheduled refresh, or nil if not keep_alive?
228
+ #
229
+ def secs_to_refresh
230
+ return unless keep_alive?
231
+
232
+ secs = next_refresh - Time.now
233
+ secs.negative? ? 0 : secs
234
+ end
235
+
236
+ # Returns e.g. "1 week 6 days 23 hours 49 minutes 56 seconds"
237
+ #
238
+ # @return [String, nil]
239
+ def time_to_refresh
240
+ return unless keep_alive?
241
+
242
+ secs_to_refresh.pix_humanize_secs
243
+ end
244
+
245
+ # @return [Float]
246
+ #################################
247
+ def secs_remaining
248
+ return unless @expires
249
+
250
+ @expires - Time.now
251
+ end
252
+
253
+ # @return [String] e.g. "1 week 6 days 23 hours 49 minutes 56 seconds"
254
+ #################################
255
+ def time_remaining
256
+ return unless @expires
257
+
258
+ secs_remaining.pix_humanize_secs
259
+ end
260
+
261
+ # @return [Boolean]
262
+ #################################
263
+ def valid?
264
+ @valid =
265
+ if expired?
266
+ false
267
+ elsif !@token
268
+ false
269
+ else
270
+ token_connection(CURRENT_STATUS_RSRC, token: token).post.success?
271
+ end
272
+ end
273
+
274
+ # What happened the last time we tried to refresh?
275
+ # See REFRESH_RESULTS
276
+ #
277
+ # @return [String, nil] result or nil if never refreshed
278
+ #################################
279
+ def last_refresh_result
280
+ REFRESH_RESULTS[@last_refresh_result]
281
+ end
282
+
283
+ # Use this token to get a fresh one. If a pw is provided
284
+ # try to use it to get a new token if a proper refresh fails.
285
+ #
286
+ # @param pw [String] Optional password to use if token refresh fails.
287
+ # Must be the correct passwd or the token's user (obviously)
288
+ #
289
+ # @return [Faraday::Response] the response from checking the status,
290
+ # which might be used to set @creation_http_response
291
+ #
292
+ #################################
293
+ def refresh
294
+ # already expired?
295
+ if expired?
296
+ # try the passwd if we have it
297
+ return refresh_with_pw(:expired_refreshed, :expired_failed) if @pw
298
+
299
+ # no passwd fallback? no chance!
300
+ @last_refresh_result = :expired_no_pw_fallback
301
+ raise Windoo::InvalidTokenError, 'Token has expired'
302
+ end
303
+
304
+ # Now try a normal refresh of our non-expired token
305
+ refresh_resp = token_connection(REFRESH_RSRC, token: token).post
306
+
307
+ if refresh_resp.success?
308
+ @token = refresh_resp.body[:token]
309
+ parse_token_status
310
+ @last_refresh = Time.now
311
+ return refresh_resp
312
+ end
313
+
314
+ # if we're here, the normal refresh failed, so try the pw
315
+ return refresh_with_pw(:refreshed_pw, :refresh_failed) if @pw
316
+
317
+ # if we're here, no pw = no chance!
318
+ @last_refresh_result = :refresh_failed_no_pw_fallback
319
+ raise Windoo::InvalidTokenError, 'An error occurred while refreshing the token'
320
+ end
321
+
322
+ # Make this token invalid
323
+ #################################
324
+ def invalidate
325
+ stop_keep_alive
326
+ @valid = false
327
+ @token = nil
328
+ @pw = nil
329
+ end
330
+ alias destroy invalidate
331
+
332
+ # creates a thread that loops forever, sleeping most of the time, but
333
+ # waking up every 60 seconds to see if the token is expiring in the
334
+ # next REFRESH_BUFFER seconds.
335
+ #
336
+ # If so, the token is refreshed, and we keep looping and sleeping.
337
+ #
338
+ # Sets @keep_alive_thread to the Thread object
339
+ #
340
+ # @return [void]
341
+ #################################
342
+ def start_keep_alive
343
+ return if @keep_alive_thread
344
+ raise 'Token expired, cannot refresh' if expired?
345
+
346
+ @keep_alive_thread =
347
+ Thread.new do
348
+ loop do
349
+ sleep 60
350
+ begin
351
+ next if secs_remaining > REFRESH_BUFFER
352
+
353
+ refresh
354
+ rescue StandardError
355
+ # TODO: Some kind of error reporting
356
+ next
357
+ end
358
+ end # loop
359
+ end # thread
360
+ @keep_alive_thread.name = "Windoo keep_alive #{tenantId} (#{@login_time})"
361
+ end # start_keep_alive
362
+
363
+ # Kills the @keep_alive_thread, if it exists, and sets
364
+ # @keep_alive_thread to nil
365
+ #
366
+ # @return [void]
367
+ #
368
+ def stop_keep_alive
369
+ return unless @keep_alive_thread
370
+
371
+ @keep_alive_thread.kill if @keep_alive_thread.alive?
372
+ @keep_alive_thread = nil
373
+ end
374
+
375
+ # Invalidate this token by stopping any keepalive thread and
376
+ # setting most values to nil or :disconnected
377
+ #
378
+ # @return [void]
379
+ #
380
+ def disconnect
381
+ stop_keep_alive
382
+ @token = nil
383
+ @pw = nil
384
+ @pw_fallback = nil
385
+ @keep_alive = nil
386
+ @creation_http_response = nil
387
+ @expires = Time.now
388
+ @scope = nil
389
+ @user = :disconnected
390
+ @user_id = 0
391
+ @valid = false
392
+ @domain = :disconnected
393
+ @tenantId = :disconnected
394
+ end
395
+
396
+ # Private instance methods
397
+ #################################
398
+ private
399
+
400
+ # set values from params & defaults
401
+ ###########################################
402
+ def parse_params(**params)
403
+ # This process of deleting suffixes will leave in place any
404
+ # URL paths before the the CAPI_RSRC_BASE or JPAPI_RSRC_BASE
405
+ # e.g. https://my.jamf.server:8443/some/path/before/api
406
+ # as is the case at some on-prem sites.
407
+ baseurl = params[:base_url].to_s.dup
408
+ baseurl.delete_suffix! '/'
409
+ @base_url = URI.parse baseurl
410
+
411
+ @timeout = params[:timeout] || Windoo::Connection::DFT_TIMEOUT
412
+
413
+ @user = params[:user]
414
+
415
+ # @pw will be deleted after use if pw_fallback is false
416
+ # It is stored as base64 merely for visual security in irb sessions
417
+ # and the like.
418
+ @pw = Base64.encode64 params[:pw] if params[:pw].is_a? String
419
+
420
+ @pw_fallback = params[:pw_fallback].instance_of?(FalseClass) ? false : true
421
+
422
+ @ssl_version = params[:ssl_version] || Jamf::Connection::DFT_SSL_VERSION
423
+ @verify_cert = (params[:verify_cert] != false)
424
+ @ssl_options = { version: @ssl_version, verify: @verify_cert }
425
+
426
+ @keep_alive = (params[:keep_alive] != false)
427
+ end
428
+
429
+ # Parse the response from the CURRENT_STATUS_RSRC to set
430
+ # ome attributes from the current token
431
+ #
432
+ # @return [Faraday::Response] the response from checking the status,
433
+ # which might be used to set @creation_http_response
434
+ ####################################
435
+ def parse_token_status
436
+ resp = token_connection(CURRENT_STATUS_RSRC, token: token).post
437
+ raise Windoo::InvalidTokenError, 'Token is not valid' unless resp.success?
438
+
439
+ @server_creation_time = Time.at resp.body[:iat]
440
+ @expires = Time.at resp.body[:exp]
441
+ @user = resp.body[:user]
442
+ @user_id = resp.body[:id]
443
+ @scope = resp.body[:scope]
444
+ @domain = resp.body[:domain]
445
+ @tenantId = resp.body[:tenantId]
446
+ @valid = true
447
+
448
+ resp
449
+ end
450
+
451
+ # refresh a token using the pw cached when @pw_fallback is true
452
+ #
453
+ # @param success [Sumbol] the key from REFRESH_RESULTS to use when successful
454
+ # @param failure [Sumbol] the key from REFRESH_RESULTS to use when not successful
455
+ # @return [Faraday::Response] the response from checking the status,
456
+ # which might be used to set @creation_http_response
457
+ #################################
458
+ def refresh_with_pw(success, failure)
459
+ resp = init_from_pw
460
+ @last_refresh_result = success
461
+ resp
462
+ rescue StandardError => e
463
+ @last_refresh_result = failure
464
+ raise e, "#{e}. Status: :#{REFRESH_RESULTS[failure]}"
465
+ end
466
+
467
+ # a generic, one-time Faraday connection for token
468
+ # acquision & manipulation
469
+ #################################
470
+ def token_connection(rsrc, token: nil)
471
+ Faraday.new("#{base_url}/#{rsrc}", ssl: @ssl_options) do |con|
472
+ con.request :json
473
+ con.response :json, parser_options: { symbolize_names: true }
474
+ con.options[:timeout] = @timeout
475
+ con.options[:open_timeout] = @timeout
476
+ if token
477
+ con.request :authorization, 'Bearer', token
478
+ else
479
+ con.request :authorization, :basic, @user, Base64.decode64(@pw)
480
+ end
481
+ con.adapter :net_http
482
+ end # Faraday.new
483
+ end # token_connection
484
+
485
+ end # class Token
486
+
487
+ end # class Connection
488
+
489
+ end # module Windoo
@@ -0,0 +1,92 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ module Windoo
11
+
12
+ # Instances of this class represent a connection to a Jamf Title Editor
13
+ class Connection
14
+
15
+ # the code for this class is broken into multiple files
16
+ # as modules, to play will with the zeitwerk loader
17
+ include Windoo::Connection::Constants
18
+ include Windoo::Connection::Attributes
19
+ include Windoo::Connection::Connect
20
+ include Windoo::Connection::Actions
21
+
22
+ # Constructor
23
+ #####################################
24
+
25
+ # Instantiate a connection object.
26
+ #
27
+ # If name: is provided it will be stored as the Connection's name attribute.
28
+ #
29
+ # if no url is provided and params are empty, or contains only
30
+ # a :name key, then you must call #connect with all the connection
31
+ # parameters before accessing a server.
32
+ #
33
+ # See {#connect} for the parameters
34
+ #
35
+ def initialize(url = nil, **params)
36
+ @name = params.delete :name
37
+ @connected = false
38
+
39
+ return if url.nil? && params.empty?
40
+
41
+ connect url, **params
42
+ end # init
43
+
44
+ # Instance methods
45
+ #####################################
46
+
47
+ # A useful string about this connection
48
+ #
49
+ # @return [String]
50
+ #
51
+ def to_s
52
+ return 'not connected' unless connected?
53
+
54
+ if name.to_s.start_with? "#{user}@"
55
+ name
56
+ else
57
+ "#{user}@#{host}:#{port}, name: #{name}"
58
+ end
59
+ end
60
+
61
+ # Only selected items are displayed with prettyprint
62
+ # otherwise its too much data in irb.
63
+ #
64
+ # @return [Array] the desired instance_variables
65
+ #
66
+ def pretty_print_instance_variables
67
+ PP_VARS
68
+ end
69
+
70
+ # @deprecated, use .token.next_refresh
71
+ def next_refresh
72
+ @token.next_refresh
73
+ end
74
+
75
+ # @deprecated, use .token.secs_to_refresh
76
+ def secs_to_refresh
77
+ @token.secs_to_refresh
78
+ end
79
+
80
+ # @deprecated, use .token.time_to_refresh
81
+ def time_to_refresh
82
+ @token.time_to_refresh
83
+ end
84
+
85
+ # is this the default connection?
86
+ def default?
87
+ self == Windoo.cnx
88
+ end
89
+
90
+ end # class Connection
91
+
92
+ end # module Windoo
@@ -0,0 +1,31 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+
6
+ # frozen_string_literal: true
7
+
8
+ module Windoo
9
+
10
+ # Methods for converting values in standard ways
11
+ # Usually used for converting between Ruby values and JSON values
12
+ # between Windoo and the Server
13
+ module Converters
14
+
15
+ # @param time [Time] a Time object to send to the API
16
+ # @return [String] The time in UTC and ISO8601 format
17
+ def self.time_to_api(time)
18
+ time.utc.iso8601
19
+ end
20
+
21
+ # @param time [#to_s] a timestamp from the API
22
+ # @return [Time] The timestamp as a Time object
23
+ def self.to_time(time)
24
+ return time if time.is_a? Time
25
+
26
+ Time.parse time.to_s
27
+ end
28
+
29
+ end # Utility
30
+
31
+ end # Windoo
@@ -0,0 +1,34 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ module Windoo
9
+
10
+ # Connections & Access
11
+
12
+ class ConnectionError < RuntimeError; end
13
+
14
+ class NotConnectedError < RuntimeError; end
15
+
16
+ class AuthenticationError < RuntimeError; end
17
+
18
+ class PermissionError < RuntimeError; end
19
+
20
+ class InvalidTokenError < RuntimeError; end
21
+
22
+ # General errors
23
+
24
+ class MissingDataError < RuntimeError; end
25
+
26
+ class InvalidDataError < RuntimeError; end
27
+
28
+ class NoSuchItemError < RuntimeError; end
29
+
30
+ class AlreadyExistsError < RuntimeError; end
31
+
32
+ class UnsupportedError < RuntimeError; end
33
+
34
+ end # module Windoo