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.
- checksums.yaml +4 -4
- data/AGENTS.md +2 -2
- data/CHANGELOG.md +45 -1
- data/README.md +33 -6
- data/Rakefile +9 -1
- data/SECURITY.md +2 -2
- data/app/controllers/product_tours/interactions_controller.rb +20 -2
- data/app/controllers/product_tours/tours_controller.rb +3 -0
- data/app/models/product_tours/tour.rb +59 -2
- data/app/views/product_tours/tours/_form.html.erb +15 -0
- data/app/views/product_tours/tours/_tour_panel.html.erb +6 -0
- data/config/locales/product_tours.ar.yml +1 -0
- data/config/locales/product_tours.bg.yml +1 -0
- data/config/locales/product_tours.bn.yml +1 -0
- data/config/locales/product_tours.de.yml +1 -0
- data/config/locales/product_tours.el.yml +1 -0
- data/config/locales/product_tours.en.yml +1 -0
- data/config/locales/product_tours.es.yml +1 -0
- data/config/locales/product_tours.fr.yml +1 -0
- data/config/locales/product_tours.hi.yml +1 -0
- data/config/locales/product_tours.hr.yml +1 -0
- data/config/locales/product_tours.id.yml +1 -0
- data/config/locales/product_tours.it.yml +1 -0
- data/config/locales/product_tours.ja.yml +1 -0
- data/config/locales/product_tours.ko.yml +1 -0
- data/config/locales/product_tours.lb.yml +1 -0
- data/config/locales/product_tours.nl.yml +1 -0
- data/config/locales/product_tours.pl.yml +1 -0
- data/config/locales/product_tours.pt.yml +1 -0
- data/config/locales/product_tours.ro.yml +1 -0
- data/config/locales/product_tours.ru.yml +1 -0
- data/config/locales/product_tours.th.yml +1 -0
- data/config/locales/product_tours.tr.yml +1 -0
- data/config/locales/product_tours.uk.yml +1 -0
- data/config/locales/product_tours.ur.yml +1 -0
- data/config/locales/product_tours.vi.yml +1 -0
- data/config/locales/product_tours.zh-CN.yml +1 -0
- data/docs/ractors.md +138 -0
- data/lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt +2 -0
- data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt +17 -0
- data/lib/generators/product_tours/upgrade/upgrade_generator.rb +29 -0
- data/lib/product_tours/engine.rb +12 -0
- data/lib/product_tours/ractor_support.rb +519 -0
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.js +13 -1
- data/lib/product_tours/widget.rb +3 -3
- data/lib/product_tours.rb +1 -0
- 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
|
data/lib/product_tours/widget.js
CHANGED
|
@@ -193,12 +193,24 @@
|
|
|
193
193
|
player.controls = true;
|
|
194
194
|
player.playsInline = true;
|
|
195
195
|
player.preload = "metadata";
|
|
196
|
-
|
|
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;
|
data/lib/product_tours/widget.rb
CHANGED
|
@@ -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
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.
|
|
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.
|
|
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: []
|