product_tours 1.0.0 → 1.2.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +2 -2
  3. data/CHANGELOG.md +45 -1
  4. data/README.md +33 -6
  5. data/Rakefile +9 -1
  6. data/SECURITY.md +2 -2
  7. data/app/controllers/product_tours/interactions_controller.rb +20 -2
  8. data/app/controllers/product_tours/tours_controller.rb +3 -0
  9. data/app/models/product_tours/tour.rb +59 -2
  10. data/app/views/product_tours/tours/_form.html.erb +15 -0
  11. data/app/views/product_tours/tours/_tour_panel.html.erb +6 -0
  12. data/config/locales/product_tours.ar.yml +1 -0
  13. data/config/locales/product_tours.bg.yml +1 -0
  14. data/config/locales/product_tours.bn.yml +1 -0
  15. data/config/locales/product_tours.de.yml +1 -0
  16. data/config/locales/product_tours.el.yml +1 -0
  17. data/config/locales/product_tours.en.yml +1 -0
  18. data/config/locales/product_tours.es.yml +1 -0
  19. data/config/locales/product_tours.fr.yml +1 -0
  20. data/config/locales/product_tours.hi.yml +1 -0
  21. data/config/locales/product_tours.hr.yml +1 -0
  22. data/config/locales/product_tours.id.yml +1 -0
  23. data/config/locales/product_tours.it.yml +1 -0
  24. data/config/locales/product_tours.ja.yml +1 -0
  25. data/config/locales/product_tours.ko.yml +1 -0
  26. data/config/locales/product_tours.lb.yml +1 -0
  27. data/config/locales/product_tours.nl.yml +1 -0
  28. data/config/locales/product_tours.pl.yml +1 -0
  29. data/config/locales/product_tours.pt.yml +1 -0
  30. data/config/locales/product_tours.ro.yml +1 -0
  31. data/config/locales/product_tours.ru.yml +1 -0
  32. data/config/locales/product_tours.th.yml +1 -0
  33. data/config/locales/product_tours.tr.yml +1 -0
  34. data/config/locales/product_tours.uk.yml +1 -0
  35. data/config/locales/product_tours.ur.yml +1 -0
  36. data/config/locales/product_tours.vi.yml +1 -0
  37. data/config/locales/product_tours.zh-CN.yml +1 -0
  38. data/docs/ractors.md +138 -0
  39. data/lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt +2 -0
  40. data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt +17 -0
  41. data/lib/generators/product_tours/upgrade/upgrade_generator.rb +29 -0
  42. data/lib/product_tours/engine.rb +12 -0
  43. data/lib/product_tours/ractor_support.rb +519 -0
  44. data/lib/product_tours/version.rb +1 -1
  45. data/lib/product_tours/widget.js +13 -1
  46. data/lib/product_tours/widget.rb +3 -3
  47. data/lib/product_tours.rb +1 -0
  48. metadata +6 -2
@@ -0,0 +1,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ # Used only by Rails' opt-in before_ractorize hook. Normal Rails boots keep
5
+ # mutable configuration and lazy assets, including on older Ruby versions.
6
+ module RactorSupport
7
+ # Small bridges for framework paths that Rails main does not prepare yet.
8
+ # They are installed only when the host explicitly calls ractorize!.
9
+ # Rails' built-in JSON renderer is still defined from an unisolated Proc.
10
+ # Keep its JSON/JSONP and escaping behavior in a regular Ruby method.
11
+ module JsonRendering
12
+ def _render_with_renderer_json(json, options)
13
+ json_options = options.except(:callback, :content_type, :status)
14
+ json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
15
+ json = json.to_json(json_options) unless json.is_a?(String)
16
+ if options[:callback].present?
17
+ self.content_type = :js if media_type.nil? || media_type == Mime[:json]
18
+ "/**/#{options[:callback]}(#{json})"
19
+ else
20
+ self.content_type = :json if media_type.nil?
21
+ json
22
+ end
23
+ end
24
+ end
25
+
26
+ module DependencyInterlock
27
+ def interlock
28
+ return super if Ractor.main?
29
+
30
+ Ractor[:engine_dependency_interlock] ||= ActiveSupport::Dependencies::Interlock.new
31
+ end
32
+ end
33
+
34
+ module LiveThreads
35
+ private
36
+
37
+ def new_controller_thread
38
+ return super if Ractor.main?
39
+
40
+ Thread.new do
41
+ Thread.current.abort_on_exception = true
42
+ yield
43
+ end
44
+ end
45
+ end
46
+
47
+ module UniquenessCoverage
48
+ private
49
+
50
+ def covered_by_unique_index?(klass, record, attribute, scope)
51
+ return super unless frozen?
52
+
53
+ copies = Ractor[:engine_uniqueness_validators] ||= {}
54
+ copy = copies[[self, klass]] ||= dup
55
+ copy.send(:covered_by_unique_index?, klass, record, attribute, scope)
56
+ end
57
+ end
58
+
59
+ # GlobalID calls CGI.unescape without an encoding, whose default reads a
60
+ # process-wide class variable. Preserve CGI's configured default on main.
61
+ module UriUnescaping
62
+ def unescape(string, *arguments)
63
+ return super if Ractor.main?
64
+
65
+ shared_arguments = Ractor.make_shareable([string, *arguments], copy: true)
66
+ ActiveSupport::Ractors.on_main(self) { unescape(*shared_arguments) }
67
+ end
68
+ end
69
+
70
+ module CacheFreshness
71
+ def strict_freshness
72
+ return super if Ractor.main?
73
+
74
+ ActiveSupport::Ractors.on_main(self) { strict_freshness }
75
+ end
76
+ end
77
+
78
+ # Attribute decorators (including Rails enums and serialized attributes)
79
+ # are boot-time factories. Keep their original receivers and captures on
80
+ # the main Ractor, rather than changing the semantics of Rails' closures.
81
+ class Decorator
82
+ def initialize(callback)
83
+ @key = RactorSupport.register_decorator(callback)
84
+ freeze
85
+ end
86
+
87
+ def call(*arguments)
88
+ return RactorSupport.call_decorator(@key, arguments) if Ractor.main?
89
+
90
+ shared_arguments = Ractor.make_shareable(arguments, copy: true)
91
+ ActiveSupport::Ractors.on_main(self) { RactorSupport.call_decorator(@key, shared_arguments) }
92
+ end
93
+ end
94
+
95
+ def self.register_decorator(callback)
96
+ @decorators ||= []
97
+ @decorators << callback
98
+ @decorators.length - 1
99
+ end
100
+
101
+ def self.call_decorator(key, arguments)
102
+ @decorators.fetch(key).call(*arguments)
103
+ end
104
+
105
+ def self.prepare_attributes(model)
106
+ prepare_attributes(model.superclass) if model.superclass.respond_to?(:pending_attribute_modifications, true)
107
+ model.send(:pending_attribute_modifications)&.each do |modification|
108
+ next unless modification.respond_to?(:decorator) && modification.decorator.is_a?(Proc)
109
+
110
+ modification.decorator = Decorator.new(modification.decorator)
111
+ end
112
+ end
113
+
114
+ module AdapterQuoting
115
+ def quote_column_name(name)
116
+ return super if Ractor.main?
117
+
118
+ shared_name = Ractor.make_shareable(name, copy: true)
119
+ ActiveSupport::Ractors.on_main(self) { quote_column_name(shared_name) }
120
+ end
121
+
122
+ def quote_table_name(name)
123
+ return super if Ractor.main?
124
+
125
+ shared_name = Ractor.make_shareable(name, copy: true)
126
+ ActiveSupport::Ractors.on_main(self) { quote_table_name(shared_name) }
127
+ end
128
+ end
129
+
130
+ # Route arguments can contain records with native Set caches. Only their
131
+ # to_param value is needed to generate a mount prefix; keep the caller's
132
+ # original record objects in the options passed to the actual route.
133
+ def self.routing_snapshot(value)
134
+ return value if Ractor.shareable?(value)
135
+
136
+ copy = case value
137
+ when ActiveRecord::Base then value.to_param
138
+ when Hash then value.to_h { |key, item| [routing_snapshot(key), routing_snapshot(item)] }
139
+ when Array then value.map { |item| routing_snapshot(item) }
140
+ when Set then Set.new(value.map { |item| routing_snapshot(item) })
141
+ else return Ractor.make_shareable(value, copy: true)
142
+ end
143
+ Ractor.make_shareable(copy)
144
+ end
145
+
146
+ module MountedRoutes
147
+ def find_script_name(options)
148
+ return super unless defined?(Ractor.shareable_proc) && !Ractor.main?
149
+
150
+ shared_options = RactorSupport.routing_snapshot(options)
151
+ prefix, updated = ActiveSupport::Ractors.on_main(self) do
152
+ local_options = shared_options.dup
153
+ [find_script_name(local_options), local_options]
154
+ end
155
+ (shared_options.keys - updated.keys).each { |key| options.delete(key) }
156
+ updated.each do |key, value|
157
+ options[key] = value unless shared_options.key?(key) && shared_options[key] == value
158
+ end
159
+ prefix
160
+ end
161
+ end
162
+
163
+ module Digesting
164
+ def hexdigest(value)
165
+ return super if Ractor.main?
166
+
167
+ shared_value = Ractor.make_shareable(value, copy: true)
168
+ ActiveSupport::Ractors.on_main(self) { hexdigest(shared_value) }
169
+ end
170
+ end
171
+
172
+ module KeyGeneration
173
+ def generate_key(salt, key_size = 64)
174
+ return super if Ractor.main?
175
+
176
+ shared_salt = Ractor.make_shareable(salt, copy: true)
177
+ ActiveSupport::Ractors.on_main(self) { generate_key(shared_salt, key_size) }
178
+ end
179
+ end
180
+
181
+ module UploadCleanup
182
+ def open
183
+ reopen(path, 'rb+')
184
+ self
185
+ end
186
+
187
+ # Tempfile#close takes a positional unlink flag.
188
+ def close(unlink_now = false) # rubocop:disable Style/OptionalBooleanParameter
189
+ super() unless closed?
190
+ unlink if unlink_now
191
+ end
192
+
193
+ def unlink
194
+ File.unlink(path)
195
+ rescue Errno::ENOENT
196
+ nil
197
+ end
198
+
199
+ def close!
200
+ close(true)
201
+ end
202
+ end
203
+
204
+ def self.upload_tempfile(filename, _content_type)
205
+ extension = File.extname(filename.gsub("\0", '%00'))[0, 129]
206
+ Tempfile.create(['RackMultipart', extension]).extend(UploadCleanup)
207
+ end
208
+
209
+ # Nokogiri's native parser is not Ractor safe. Keep Action Text page
210
+ # processing on the main Ractor, preserving Rails' renderer, sanitizer,
211
+ # callbacks and transaction behavior rather than duplicating their logic.
212
+ def self.main_page?(env)
213
+ return false unless @action_text && !Ractor.main?
214
+
215
+ mount = ProductTours.config.mount_path.chomp('/')
216
+ request_path = env['PATH_INFO'].to_s
217
+ return false unless request_path == mount || request_path.start_with?("#{mount}/")
218
+
219
+ path = request_path.delete_prefix(mount)
220
+ path.empty? || path == '/' || path == '/tours' || path.start_with?('/tours/') || path == '/widget/tour'
221
+ end
222
+
223
+ def self.main_page(env)
224
+ headers = env.select do |_key, value|
225
+ Ractor.shareable?(value) || value.is_a?(String) || value.is_a?(Numeric)
226
+ end
227
+ shared_env = Ractor.make_shareable(headers, copy: true)
228
+ shared_input = Ractor.make_shareable(env['rack.input']&.read.to_s)
229
+ ActiveSupport::Ractors.on_main(Rails.application) do
230
+ request_env = shared_env.transform_values { |value| value.is_a?(String) ? value.dup : value }
231
+ request_env['rack.input'] = StringIO.new(shared_input)
232
+ request_env['rack.errors'] = $stderr
233
+ status, response_headers, body = call(request_env)
234
+ content = +''
235
+ begin
236
+ body.each { |part| content << part }
237
+ ensure
238
+ body.close if body.respond_to?(:close)
239
+ end
240
+ [status, response_headers, [content]]
241
+ end
242
+ end
243
+
244
+ class Middleware
245
+ def initialize(app)
246
+ @app = app
247
+ end
248
+
249
+ def call(env)
250
+ return RactorSupport.main_page(env) if RactorSupport.main_page?(env)
251
+
252
+ if (fallbacks = RactorSupport.fallbacks)
253
+ Fiber[:i18n_fallbacks] ||= fallbacks
254
+ env['rack.multipart.tempfile_factory'] ||= RactorSupport.method(:upload_tempfile) unless Ractor.main?
255
+ end
256
+ @app.call(env)
257
+ end
258
+ end
259
+
260
+ def self.fallbacks
261
+ @fallbacks
262
+ end
263
+
264
+ module StorageMetadata
265
+ def analyzed = read_store_attribute(:metadata, :analyzed)
266
+ def identified = read_store_attribute(:metadata, :identified)
267
+ def composed = read_store_attribute(:metadata, :composed)
268
+
269
+ def analyzed=(value)
270
+ write_store_attribute(:metadata, :analyzed, value)
271
+ end
272
+
273
+ def identified=(value)
274
+ write_store_attribute(:metadata, :identified, value)
275
+ end
276
+
277
+ def composed=(value)
278
+ write_store_attribute(:metadata, :composed, value)
279
+ end
280
+ end
281
+
282
+ class StorageServices
283
+ def initialize(configurations)
284
+ @configurations = Ractor.make_shareable(configurations, copy: true)
285
+ freeze
286
+ end
287
+
288
+ def fetch(name, &)
289
+ registry = if Ractor.main?
290
+ RactorSupport.storage_registry
291
+ else
292
+ Ractor[:engine_storage_services] ||= ActiveStorage::Service::Registry.new(@configurations)
293
+ end
294
+ registry.fetch(name, &)
295
+ end
296
+ end
297
+
298
+ def self.storage_registry
299
+ @storage_registry
300
+ end
301
+
302
+ module StorageChecksums
303
+ def checksum_implementation(**options)
304
+ implementation = super
305
+ if Ractor.main? || !implementation.respond_to?(:name) || !implementation.name&.start_with?('OpenSSL::Digest::')
306
+ return implementation
307
+ end
308
+
309
+ DigestFactory.new(implementation.name.delete_prefix('OpenSSL::Digest::'))
310
+ end
311
+ end
312
+
313
+ class DigestFactory
314
+ def initialize(algorithm)
315
+ @algorithm = algorithm.freeze
316
+ freeze
317
+ end
318
+
319
+ def new
320
+ OpenSSL::Digest.new(@algorithm)
321
+ end
322
+
323
+ def file(io)
324
+ new.file(io)
325
+ end
326
+
327
+ def base64digest(value)
328
+ new.base64digest(value)
329
+ end
330
+ end
331
+
332
+ def self.prepare_redirects!
333
+ base = ActionController::Base
334
+ readers = Module.new
335
+ %i[raise_on_open_redirects action_on_open_redirect action_on_path_relative_redirect].each do |key|
336
+ source = base.method(key).source_location&.first
337
+ next unless source&.end_with?('/action_controller/metal/redirecting.rb')
338
+
339
+ value = Ractor.make_shareable(base.public_send(key), copy: true)
340
+ readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : value })
341
+ end
342
+ return if readers.instance_methods(false).empty?
343
+
344
+ base.prepend(readers)
345
+ base.singleton_class.prepend(readers)
346
+ end
347
+
348
+ def self.prepare_previewers!
349
+ ActiveStorage.previewers.each do |previewer|
350
+ readers = Module.new
351
+ previewer.methods.grep(/_exists\?\z/).each do |capability|
352
+ method = previewer.method(capability)
353
+ next unless method.arity.zero? && method.source_location&.first&.include?('/active_storage/previewer/')
354
+
355
+ readers.define_method(capability, Ractor.shareable_proc do
356
+ if Ractor.main?
357
+ super()
358
+ else
359
+ ActiveSupport::Ractors.on_main(self) { public_send(capability) }
360
+ end
361
+ end)
362
+ end
363
+ previewer.singleton_class.prepend(readers) unless readers.instance_methods(false).empty?
364
+ end
365
+ end
366
+
367
+ def self.prepare_storage!
368
+ return unless defined?(ActiveStorage::Blob)
369
+
370
+ prepare_previewers!
371
+ ActiveStorage::Blob.prepend(StorageMetadata) unless ActiveStorage::Blob < StorageMetadata
372
+ %i[EXTENSIONS TYPE_EXTS TYPE_ALIASES TYPE_PARENTS ROOT_XML MAGIC].each do |name|
373
+ Ractor.make_shareable(Marcel.const_get(name)) if Marcel.const_defined?(name)
374
+ end
375
+ ActiveStorage::Service.prepend(StorageChecksums) unless ActiveStorage::Service < StorageChecksums
376
+ unless Ractor.shareable?(ActiveStorage::Blob.services)
377
+ @storage_registry = ActiveStorage::Blob.services
378
+ configurations = @storage_registry.instance_variable_get(:@configurations)
379
+ default_name = ActiveStorage::Blob.service.name
380
+ default_reader = Module.new
381
+ default_reader.define_method(:service, Ractor.shareable_proc do
382
+ Ractor.main? ? super() : services.fetch(default_name)
383
+ end)
384
+ ActiveStorage::Blob.singleton_class.prepend(default_reader)
385
+ ActiveStorage::Blob.services = StorageServices.new(configurations)
386
+ end
387
+ settings = ActiveStorage.class_variables.map { |name| name.to_s.delete_prefix('@@').to_sym }
388
+ settings.select! { |key| ActiveStorage.respond_to?(key) }
389
+ snapshot = settings.to_h { |key| [key, ActiveStorage.public_send(key)] }
390
+ checksum = snapshot[:checksum_implementation]
391
+ if checksum.is_a?(Class) && checksum.name&.start_with?('OpenSSL::Digest::')
392
+ snapshot[:checksum_implementation] = DigestFactory.new(checksum.name.delete_prefix('OpenSSL::Digest::'))
393
+ end
394
+ Ractor.make_shareable(snapshot)
395
+ readers = Module.new
396
+ settings.each do |key|
397
+ readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : snapshot.fetch(key) })
398
+ end
399
+ ActiveStorage.singleton_class.prepend(readers)
400
+ end
401
+
402
+ def self.prepare_framework!
403
+ CGI.singleton_class.prepend(UriUnescaping) unless CGI.singleton_class < UriUnescaping
404
+ Ractor.make_shareable(GlobalID.app) if defined?(GlobalID) && GlobalID.respond_to?(:app)
405
+ I18n.available_locales.each { |locale| I18n.transliterate('ractor', locale: locale) }
406
+ require 'i18n/ractorize'
407
+ require 'tempfile'
408
+ prepare_redirects!
409
+ %i[BASE36_ALPHABET BASE58_ALPHABET].each do |name|
410
+ Ractor.make_shareable(SecureRandom.const_get(name)) if SecureRandom.const_defined?(name)
411
+ end
412
+ uniqueness = ActiveRecord::Validations::UniquenessValidator
413
+ uniqueness.prepend(UniquenessCoverage) unless uniqueness < UniquenessCoverage
414
+ dependencies = ActiveSupport::Dependencies.singleton_class
415
+ dependencies.prepend(DependencyInterlock) unless dependencies < DependencyInterlock
416
+ prepare_storage!
417
+ Ractor.make_shareable(ActiveRecord::Associations::AssociationScope::INSTANCE)
418
+ Ractor.make_shareable(ActionDispatch::Http::Cache::Response::SPECIAL_KEYS)
419
+ freshness = ActionDispatch::Http::Cache::Request.singleton_class
420
+ freshness.prepend(CacheFreshness) unless freshness < CacheFreshness
421
+ fallbacks = I18n.fallbacks
422
+ I18n.available_locales.each { |locale| fallbacks[locale] }
423
+ @fallbacks = Ractor.make_shareable(fallbacks)
424
+ ActiveSupport::Digest.singleton_class.prepend(Digesting) unless ActiveSupport::Digest.singleton_class < Digesting
425
+ ActiveSupport::KeyGenerator.prepend(KeyGeneration) unless ActiveSupport::KeyGenerator < KeyGeneration
426
+ ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
427
+ adapter = pool.db_config.adapter_class.singleton_class
428
+ adapter.prepend(AdapterQuoting) unless adapter < AdapterQuoting
429
+ end
430
+ rescue LoadError => e
431
+ raise LoadError,
432
+ "Ractor requests need the upstream I18n Ractor support (see gemfiles/rails_ractor.gemfile): #{e.message}"
433
+ end
434
+
435
+ def self.prepare!
436
+ @action_text = defined?(ActionText) ? true : false
437
+ # A cache store can reference the whole middleware stack and freeze
438
+ # other mounted engines before their own hooks run. Warm every engine's
439
+ # request environment before making any of that graph shareable.
440
+ Rails.application.railties.each do |railtie|
441
+ railtie.env_config if railtie.is_a?(Rails::Engine)
442
+ end
443
+ Engine.instance.env_config
444
+ prepare_framework!
445
+ ActiveRecord::Base.descendants.each do |model|
446
+ next if model.abstract_class? || !model.table_exists?
447
+
448
+ model.reflect_on_all_associations.each { |reflection| reflection.send(:inverse_name) }
449
+ prepare_attributes(model)
450
+ Ractor.make_shareable(model.defined_enums)
451
+ model.defined_enums.each do |attribute, mapping|
452
+ method_name = attribute.pluralize
453
+ source = model.method(method_name).source_location&.first
454
+ next unless source&.end_with?('/active_record/enum.rb')
455
+
456
+ model.define_singleton_method(method_name, Ractor.shareable_proc { mapping })
457
+ end
458
+ model.define_attribute_methods
459
+ if model.respond_to?(:attachment_reflections)
460
+ model.attachment_reflections.each_value(&:named_variants)
461
+ Ractor.make_shareable(model.attachment_reflections)
462
+ end
463
+ end
464
+ ActionController::Base.descendants.each do |controller|
465
+ next unless controller.name&.start_with?('ProductTours::')
466
+
467
+ controller.prepend(LiveThreads) if defined?(ActionController::Live) && controller < ActionController::Live
468
+ renderer_source = controller.instance_method(:_render_with_renderer_json).source_location&.first
469
+ if renderer_source&.end_with?('/action_controller/metal/renderers.rb') && !(controller < JsonRendering)
470
+ controller.prepend(JsonRendering)
471
+ end
472
+ Ractor.make_shareable(controller.controller_name)
473
+ controller.etaggers = controller.etaggers.map { |callback| Ractor.shareable_proc(&callback) }.freeze
474
+ end
475
+ ProductTours.instance_variable_set(:@config, configuration(ProductTours.config))
476
+ %i[javascript dashboard_javascript dashboard_stylesheet fingerprint
477
+ dashboard_fingerprint dashboard_stylesheet_fingerprint].each do |method|
478
+ next unless Widget.respond_to?(method)
479
+
480
+ Widget.public_send(method)
481
+ end
482
+ Widget.instance_variables.each do |variable|
483
+ Widget.instance_variable_set(variable, Ractor.make_shareable(Widget.instance_variable_get(variable)))
484
+ end
485
+ end
486
+
487
+ def self.configuration_value(value)
488
+ case value
489
+ when Proc
490
+ return value if Ractor.shareable?(value)
491
+
492
+ # Like Rails callbacks, isolated callbacks use arguments and constants,
493
+ # not their initializer's implicit self. Keep lambda/proc arity intact.
494
+ value.lambda? ? Ractor.shareable_lambda(&value) : Ractor.shareable_proc(&value)
495
+ when Hash
496
+ value.to_h { |key, item| [configuration_value(key), configuration_value(item)] }.freeze
497
+ when Array
498
+ value.map { |item| configuration_value(item) }.freeze
499
+ else
500
+ Ractor.make_shareable(value, copy: true)
501
+ end
502
+ end
503
+
504
+ def self.configuration(original)
505
+ prepared = original.dup
506
+ original.instance_variables.each do |variable|
507
+ value = original.instance_variable_get(variable)
508
+ begin
509
+ shared = configuration_value(value)
510
+ prepared.instance_variable_set(variable, shared)
511
+ rescue Ractor::Error, TypeError => e
512
+ setting = variable.to_s.delete_prefix('@')
513
+ raise ArgumentError, "ProductTours.config.#{setting} cannot be shared with Ractors: #{e.message}"
514
+ end
515
+ end
516
+ Ractor.make_shareable(prepared)
517
+ end
518
+ end
519
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -193,12 +193,24 @@
193
193
  player.controls = true;
194
194
  player.playsInline = true;
195
195
  player.preload = "metadata";
196
- showFirstVideoFrame(player);
196
+ player.setAttribute("aria-label", title || config.labels.video);
197
+ if (video.poster) player.poster = video.poster;
198
+ else showFirstVideoFrame(player);
199
+ if (video.captions) player.appendChild(captionsTrack(video.captions));
197
200
  frame.appendChild(player);
198
201
  }
199
202
  return frame;
200
203
  }
201
204
 
205
+ function captionsTrack(captions) {
206
+ var track = document.createElement("track");
207
+ track.kind = "captions";
208
+ track.src = captions.url;
209
+ track.srclang = captions.srclang;
210
+ track.label = captions.label;
211
+ return track;
212
+ }
213
+
202
214
  function showFirstVideoFrame(player) {
203
215
  function seek() {
204
216
  if (!Number.isFinite(player.duration) || player.duration <= 0 || player.currentTime > 0) return;
@@ -5,9 +5,9 @@ require 'json'
5
5
 
6
6
  module ProductTours
7
7
  module Widget
8
- SOURCE = File.expand_path('widget.js', __dir__)
9
- DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__)
10
- DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
8
+ SOURCE = File.expand_path('widget.js', __dir__).freeze
9
+ DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__).freeze
10
+ DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__).freeze
11
11
  RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
12
12
 
13
13
  class << self
data/lib/product_tours.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'product_tours/version'
4
4
  require 'product_tours/configuration'
5
+ require 'product_tours/ractor_support'
5
6
  require 'product_tours/errors'
6
7
  require 'product_tours/video_resolver'
7
8
  require 'product_tours/video_metadata'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: product_tours
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -92,6 +92,7 @@ files:
92
92
  - config/locales/product_tours.vi.yml
93
93
  - config/locales/product_tours.zh-CN.yml
94
94
  - config/routes.rb
95
+ - docs/ractors.md
95
96
  - docs/screenshots/01-walkthrough.jpg
96
97
  - docs/screenshots/02-dashboard.jpg
97
98
  - docs/screenshots/03-mobile.jpg
@@ -100,6 +101,8 @@ files:
100
101
  - lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt
101
102
  - lib/generators/product_tours/install/templates/initializer.rb
102
103
  - lib/generators/product_tours/migration_helpers.rb
104
+ - lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt
105
+ - lib/generators/product_tours/upgrade/upgrade_generator.rb
103
106
  - lib/product_tours.rb
104
107
  - lib/product_tours/configuration.rb
105
108
  - lib/product_tours/content_security_policy.rb
@@ -107,6 +110,7 @@ files:
107
110
  - lib/product_tours/dashboard.js
108
111
  - lib/product_tours/engine.rb
109
112
  - lib/product_tours/errors.rb
113
+ - lib/product_tours/ractor_support.rb
110
114
  - lib/product_tours/seeds.rb
111
115
  - lib/product_tours/version.rb
112
116
  - lib/product_tours/video_metadata.rb
@@ -138,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
142
  - !ruby/object:Gem::Version
139
143
  version: '0'
140
144
  requirements: []
141
- rubygems_version: 4.0.16
145
+ rubygems_version: 4.0.20
142
146
  specification_version: 4
143
147
  summary: Self-hosted product tours and video tutorials for Rails apps.
144
148
  test_files: []