shopify_app 12.0.4 → 13.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -6
  3. data/CHANGELOG.md +28 -0
  4. data/Gemfile +3 -0
  5. data/README.md +98 -20
  6. data/Rakefile +1 -0
  7. data/app/controllers/concerns/shopify_app/authenticated.rb +1 -1
  8. data/app/controllers/shopify_app/authenticated_controller.rb +1 -0
  9. data/app/controllers/shopify_app/callback_controller.rb +15 -11
  10. data/app/controllers/shopify_app/sessions_controller.rb +35 -9
  11. data/app/controllers/shopify_app/webhooks_controller.rb +6 -5
  12. data/config/locales/fi.yml +1 -1
  13. data/config/routes.rb +1 -0
  14. data/lib/generators/shopify_app/add_after_authenticate_job/add_after_authenticate_job_generator.rb +5 -3
  15. data/lib/generators/shopify_app/add_after_authenticate_job/templates/after_authenticate_job.rb +1 -0
  16. data/lib/generators/shopify_app/add_marketing_activity_extension/add_marketing_activity_extension_generator.rb +2 -1
  17. data/lib/generators/shopify_app/add_marketing_activity_extension/templates/marketing_activities_controller.rb +4 -4
  18. data/lib/generators/shopify_app/add_webhook/add_webhook_generator.rb +5 -4
  19. data/lib/generators/shopify_app/add_webhook/templates/webhook_job.rb +5 -0
  20. data/lib/generators/shopify_app/app_proxy_controller/app_proxy_controller_generator.rb +4 -3
  21. data/lib/generators/shopify_app/app_proxy_controller/templates/app_proxy_controller.rb +3 -3
  22. data/lib/generators/shopify_app/app_proxy_controller/templates/app_proxy_route.rb +10 -9
  23. data/lib/generators/shopify_app/controllers/controllers_generator.rb +1 -0
  24. data/lib/generators/shopify_app/home_controller/home_controller_generator.rb +4 -3
  25. data/lib/generators/shopify_app/home_controller/templates/index.html.erb +1 -1
  26. data/lib/generators/shopify_app/install/install_generator.rb +9 -8
  27. data/lib/generators/shopify_app/install/templates/embedded_app.html.erb +1 -1
  28. data/lib/generators/shopify_app/install/templates/omniauth.rb +2 -1
  29. data/lib/generators/shopify_app/install/templates/shopify_app.rb +1 -1
  30. data/lib/generators/shopify_app/install/templates/shopify_provider.rb +1 -1
  31. data/lib/generators/shopify_app/install/templates/user_agent.rb +2 -1
  32. data/lib/generators/shopify_app/routes/routes_generator.rb +1 -0
  33. data/lib/generators/shopify_app/routes/templates/routes.rb +10 -9
  34. data/lib/generators/shopify_app/shop_model/shop_model_generator.rb +5 -4
  35. data/lib/generators/shopify_app/shop_model/templates/shop.rb +2 -1
  36. data/lib/generators/shopify_app/shopify_app_generator.rb +4 -3
  37. data/lib/generators/shopify_app/user_model/templates/user.rb +2 -1
  38. data/lib/generators/shopify_app/user_model/user_model_generator.rb +5 -4
  39. data/lib/generators/shopify_app/views/views_generator.rb +1 -0
  40. data/lib/shopify_app.rb +9 -4
  41. data/lib/shopify_app/configuration.rb +20 -10
  42. data/lib/shopify_app/controller_concerns/app_proxy_verification.rb +3 -2
  43. data/lib/shopify_app/controller_concerns/embedded_app.rb +3 -2
  44. data/lib/shopify_app/controller_concerns/localization.rb +1 -0
  45. data/lib/shopify_app/controller_concerns/login_protection.rb +65 -25
  46. data/lib/shopify_app/controller_concerns/webhook_verification.rb +2 -1
  47. data/lib/shopify_app/engine.rb +1 -0
  48. data/lib/shopify_app/jobs/scripttags_manager_job.rb +1 -1
  49. data/lib/shopify_app/jobs/webhooks_manager_job.rb +1 -1
  50. data/lib/shopify_app/managers/scripttags_manager.rb +4 -3
  51. data/lib/shopify_app/managers/webhooks_manager.rb +4 -3
  52. data/lib/shopify_app/middleware/same_site_cookie_middleware.rb +5 -38
  53. data/lib/shopify_app/session/in_memory_session_store.rb +7 -3
  54. data/lib/shopify_app/session/in_memory_shop_session_store.rb +14 -0
  55. data/lib/shopify_app/session/in_memory_user_session_store.rb +14 -0
  56. data/lib/shopify_app/session/jwt.rb +48 -0
  57. data/lib/shopify_app/session/null_user_session_store.rb +22 -0
  58. data/lib/shopify_app/session/session_repository.rb +36 -14
  59. data/lib/shopify_app/session/session_storage.rb +1 -10
  60. data/lib/shopify_app/session/shop_session_storage.rb +42 -0
  61. data/lib/shopify_app/session/user_session_storage.rb +42 -0
  62. data/lib/shopify_app/test_helpers/all.rb +1 -0
  63. data/lib/shopify_app/test_helpers/webhook_verification_helper.rb +16 -0
  64. data/lib/shopify_app/utils.rb +6 -5
  65. data/lib/shopify_app/version.rb +2 -1
  66. data/package-lock.json +1231 -1210
  67. data/package.json +1 -1
  68. data/shopify_app.gemspec +12 -8
  69. data/yarn.lock +3 -3
  70. metadata +32 -11
  71. data/lib/shopify_app/session/storage_strategies/shop_storage_strategy.rb +0 -23
  72. data/lib/shopify_app/session/storage_strategies/user_storage_strategy.rb +0 -24
@@ -1,5 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  class User < ActiveRecord::Base
2
- include ShopifyApp::SessionStorage
3
+ include ShopifyApp::UserSessionStorage
3
4
 
4
5
  def api_version
5
6
  ShopifyApp.configuration.api_version
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators/base'
2
3
  require 'rails/generators/active_record'
3
4
 
@@ -8,19 +9,19 @@ module ShopifyApp
8
9
  source_root File.expand_path('../templates', __FILE__)
9
10
 
10
11
  def create_user_model
11
- copy_file 'user.rb', 'app/models/user.rb'
12
+ copy_file('user.rb', 'app/models/user.rb')
12
13
  end
13
14
 
14
15
  def create_user_migration
15
- migration_template 'db/migrate/create_users.erb', 'db/migrate/create_users.rb'
16
+ migration_template('db/migrate/create_users.erb', 'db/migrate/create_users.rb')
16
17
  end
17
18
 
18
19
  def update_shopify_app_initializer
19
- gsub_file 'config/initializers/shopify_app.rb', 'ShopifyApp::InMemorySessionStore', 'User'
20
+ gsub_file('config/initializers/shopify_app.rb', 'ShopifyApp::InMemoryUserSessionStore', 'User')
20
21
  end
21
22
 
22
23
  def create_user_fixtures
23
- copy_file 'users.yml', 'test/fixtures/users.yml'
24
+ copy_file('users.yml', 'test/fixtures/users.yml')
24
25
  end
25
26
 
26
27
  private
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators/base'
2
3
 
3
4
  module ShopifyApp
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'shopify_app/version'
2
3
 
3
4
  # deps
@@ -44,9 +45,13 @@ module ShopifyApp
44
45
  require 'shopify_app/middleware/same_site_cookie_middleware'
45
46
 
46
47
  # session
47
- require 'shopify_app/session/storage_strategies/shop_storage_strategy'
48
- require 'shopify_app/session/storage_strategies/user_storage_strategy'
49
- require 'shopify_app/session/session_storage'
50
- require 'shopify_app/session/session_repository'
51
48
  require 'shopify_app/session/in_memory_session_store'
49
+ require 'shopify_app/session/in_memory_shop_session_store'
50
+ require 'shopify_app/session/in_memory_user_session_store'
51
+ require 'shopify_app/session/jwt'
52
+ require 'shopify_app/session/null_user_session_store'
53
+ require 'shopify_app/session/session_repository'
54
+ require 'shopify_app/session/session_storage'
55
+ require 'shopify_app/session/shop_session_storage'
56
+ require 'shopify_app/session/user_session_storage'
52
57
  end
@@ -1,6 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class Configuration
3
-
4
4
  # Shopify App settings. These values should match the configuration
5
5
  # for the app in your Shopify Partners page. Change your settings in
6
6
  # `config/initializers/shopify_app.rb`
@@ -14,14 +14,11 @@ module ShopifyApp
14
14
  attr_accessor :webhooks
15
15
  attr_accessor :scripttags
16
16
  attr_accessor :after_authenticate_job
17
- attr_reader :session_repository
18
- attr_accessor :per_user_tokens
19
- alias_method :per_user_tokens?, :per_user_tokens
20
17
  attr_accessor :api_version
21
18
 
22
19
  # customise urls
23
20
  attr_accessor :root_url
24
- attr_accessor :login_url
21
+ attr_writer :login_url
25
22
 
26
23
  # customise ActiveJob queue names
27
24
  attr_accessor :scripttags_manager_queue_name
@@ -37,14 +34,16 @@ module ShopifyApp
37
34
  attr_accessor :webhook_jobs_namespace
38
35
 
39
36
  # allow enabling of same site none on cookies
40
- attr_accessor :enable_same_site_none
37
+ attr_writer :enable_same_site_none
38
+
39
+ # allow enabling jwt headers for authentication
40
+ attr_accessor :allow_jwt_authentication
41
41
 
42
42
  def initialize
43
43
  @root_url = '/'
44
44
  @myshopify_domain = 'myshopify.com'
45
45
  @scripttags_manager_queue_name = Rails.application.config.active_job.queue_name
46
46
  @webhooks_manager_queue_name = Rails.application.config.active_job.queue_name
47
- @per_user_tokens = false
48
47
  @disable_webpacker = ENV['SHOPIFY_APP_DISABLE_WEBPACKER'].present?
49
48
  end
50
49
 
@@ -52,9 +51,20 @@ module ShopifyApp
52
51
  @login_url || File.join(@root_url, 'login')
53
52
  end
54
53
 
55
- def session_repository=(klass)
56
- @session_repository = klass
57
- ShopifyApp::SessionRepository.storage = klass
54
+ def user_session_repository=(klass)
55
+ ShopifyApp::SessionRepository.user_storage = klass
56
+ end
57
+
58
+ def user_session_repository
59
+ ShopifyApp::SessionRepository.user_storage
60
+ end
61
+
62
+ def shop_session_repository=(klass)
63
+ ShopifyApp::SessionRepository.shop_storage = klass
64
+ end
65
+
66
+ def shop_session_repository
67
+ ShopifyApp::SessionRepository.shop_storage
58
68
  end
59
69
 
60
70
  def has_webhooks?
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module AppProxyVerification
3
4
  extend ActiveSupport::Concern
@@ -8,7 +9,7 @@ module ShopifyApp
8
9
  end
9
10
 
10
11
  def verify_proxy_request
11
- return head :forbidden unless query_string_valid?(request.query_string)
12
+ return head(:forbidden) unless query_string_valid?(request.query_string)
12
13
  end
13
14
 
14
15
  private
@@ -26,7 +27,7 @@ module ShopifyApp
26
27
  end
27
28
 
28
29
  def calculated_signature(query_hash_without_signature)
29
- sorted_params = query_hash_without_signature.collect{|k,v| "#{k}=#{Array(v).join(',')}"}.sort.join
30
+ sorted_params = query_hash_without_signature.collect { |k, v| "#{k}=#{Array(v).join(',')}" }.sort.join
30
31
 
31
32
  OpenSSL::HMAC.hexdigest(
32
33
  OpenSSL::Digest.new('sha256'),
@@ -1,11 +1,12 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module EmbeddedApp
3
4
  extend ActiveSupport::Concern
4
5
 
5
6
  included do
6
7
  if ShopifyApp.configuration.embedded_app?
7
- after_action :set_esdk_headers
8
- layout 'embedded_app'
8
+ after_action(:set_esdk_headers)
9
+ layout('embedded_app')
9
10
  end
10
11
  end
11
12
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module Localization
3
4
  extend ActiveSupport::Concern
@@ -11,56 +11,94 @@ module ShopifyApp
11
11
 
12
12
  included do
13
13
  after_action :set_test_cookie
14
- rescue_from ActiveResource::UnauthorizedAccess, :with => :close_session
14
+ rescue_from ActiveResource::UnauthorizedAccess, with: :close_session
15
15
  end
16
16
 
17
- def shopify_session
18
- return redirect_to_login unless shop_session
17
+ def activate_shopify_session
18
+ return redirect_to_login if current_shopify_session.blank?
19
19
  clear_top_level_oauth_cookie
20
20
 
21
21
  begin
22
- ShopifyAPI::Base.activate_session(shop_session)
22
+ ShopifyAPI::Base.activate_session(current_shopify_session)
23
23
  yield
24
24
  ensure
25
25
  ShopifyAPI::Base.clear_session
26
26
  end
27
27
  end
28
28
 
29
- def shop_session
30
- if ShopifyApp.configuration.per_user_tokens?
31
- return unless session[:shopify_user]
32
- @shop_session ||= ShopifyApp::SessionRepository.retrieve(session[:shopify_user]['id'])
33
- else
34
- return unless session[:shopify]
35
- @shop_session ||= ShopifyApp::SessionRepository.retrieve(session[:shopify])
29
+ def current_shopify_session
30
+ @current_shopify_session ||= begin
31
+ user_session || shop_session
36
32
  end
37
33
  end
38
34
 
35
+ def user_session
36
+ user_session_by_jwt || user_session_by_cookie
37
+ end
38
+
39
+ def user_session_by_jwt
40
+ return unless ShopifyApp.configuration.allow_jwt_authentication
41
+ return unless jwt_shopify_user_id
42
+ ShopifyApp::SessionRepository.retrieve_user_session_by_shopify_user_id(jwt_shopify_user_id)
43
+ end
44
+
45
+ def user_session_by_cookie
46
+ return unless session[:user_id].present?
47
+ ShopifyApp::SessionRepository.retrieve_user_session(session[:user_id])
48
+ end
49
+
50
+ def shop_session
51
+ shop_session_by_jwt || shop_session_by_cookie
52
+ end
53
+
54
+ def shop_session_by_jwt
55
+ return unless ShopifyApp.configuration.allow_jwt_authentication
56
+ return unless jwt_shopify_domain
57
+ ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(jwt_shopify_domain)
58
+ end
59
+
60
+ def shop_session_by_cookie
61
+ return unless session[:shop_id].present?
62
+ ShopifyApp::SessionRepository.retrieve_shop_session(session[:shop_id])
63
+ end
64
+
39
65
  def login_again_if_different_user_or_shop
40
- if ShopifyApp.configuration.per_user_tokens?
41
- valid_session_data = session[:user_session].present? && params[:session].present? # session data was sent/stored correctly
42
- sessions_do_not_match = session[:user_session] != params[:session] # current user is different from stored user
66
+ if session[:user_session].present? && params[:session].present? # session data was sent/stored correctly
67
+ clear_session = session[:user_session] != params[:session] # current user is different from stored user
43
68
 
44
- if valid_session_data && sessions_do_not_match
45
- clear_session = true
46
- end
47
69
  end
48
70
 
49
- if shop_session && params[:shop] && params[:shop].is_a?(String) && (shop_session.domain != params[:shop])
71
+ if current_shopify_session &&
72
+ params[:shop] && params[:shop].is_a?(String) &&
73
+ (current_shopify_session.domain != params[:shop])
50
74
  clear_session = true
51
75
  end
52
76
 
53
77
  if clear_session
54
- clear_shop_session
78
+ clear_shopify_session
55
79
  redirect_to_login
56
80
  end
57
81
  end
58
82
 
59
83
  protected
60
84
 
85
+ def jwt_shopify_domain
86
+ return unless jwt
87
+ @jwt_shopify_domain ||= JWT.new(jwt).shopify_domain
88
+ end
89
+
90
+ def jwt_shopify_user_id
91
+ return unless jwt
92
+ @jwt_user_id ||= JWT.new(jwt).shopify_user_id
93
+ end
94
+
95
+ def jwt
96
+ @jwt ||= authenticate_with_http_token { |token| token }
97
+ end
98
+
61
99
  def redirect_to_login
62
100
  if request.xhr?
63
- head :unauthorized
101
+ head(:unauthorized)
64
102
  else
65
103
  if request.get?
66
104
  path = request.path
@@ -76,12 +114,13 @@ module ShopifyApp
76
114
  end
77
115
 
78
116
  def close_session
79
- clear_shop_session
117
+ clear_shopify_session
80
118
  redirect_to(login_url_with_optional_shop)
81
119
  end
82
120
 
83
- def clear_shop_session
84
- session[:shopify] = nil
121
+ def clear_shopify_session
122
+ session[:shop_id] = nil
123
+ session[:user_id] = nil
85
124
  session[:shopify_domain] = nil
86
125
  session[:shopify_user] = nil
87
126
  session[:user_session] = nil
@@ -123,9 +162,10 @@ module ShopifyApp
123
162
 
124
163
  def fullpage_redirect_to(url)
125
164
  if ShopifyApp.configuration.embedded_app?
126
- render 'shopify_app/shared/redirect', layout: false, locals: { url: url, current_shopify_domain: current_shopify_domain }
165
+ render('shopify_app/shared/redirect', layout: false,
166
+ locals: { url: url, current_shopify_domain: current_shopify_domain })
127
167
  else
128
- redirect_to url
168
+ redirect_to(url)
129
169
  end
130
170
  end
131
171
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  module WebhookVerification
3
4
  extend ActiveSupport::Concern
@@ -11,7 +12,7 @@ module ShopifyApp
11
12
 
12
13
  def verify_request
13
14
  data = request.raw_post
14
- return head :unauthorized unless hmac_valid?(data)
15
+ return head(:unauthorized) unless hmac_valid?(data)
15
16
  end
16
17
 
17
18
  def hmac_valid?(data)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class Engine < Rails::Engine
3
4
  engine_name 'shopify_app'
@@ -1,6 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class ScripttagsManagerJob < ActiveJob::Base
3
-
4
4
  queue_as do
5
5
  ShopifyApp.configuration.scripttags_manager_queue_name
6
6
  end
@@ -1,6 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class WebhooksManagerJob < ActiveJob::Base
3
-
4
4
  queue_as do
5
5
  ShopifyApp.configuration.webhooks_manager_queue_name
6
6
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class ScripttagsManager
3
4
  class CreationFailed < StandardError; end
@@ -43,7 +44,7 @@ module ShopifyApp
43
44
  def destroy_scripttags
44
45
  scripttags = expanded_scripttags
45
46
  ShopifyAPI::ScriptTag.all.each do |tag|
46
- ShopifyAPI::ScriptTag.delete(tag.id) if is_required_scripttag?(scripttags, tag)
47
+ ShopifyAPI::ScriptTag.delete(tag.id) if required_scripttag?(scripttags, tag)
47
48
  end
48
49
 
49
50
  @current_scripttags = nil
@@ -55,8 +56,8 @@ module ShopifyApp
55
56
  self.class.build_src(required_scripttags, shop_domain)
56
57
  end
57
58
 
58
- def is_required_scripttag?(scripttags, tag)
59
- scripttags.map{ |w| w[:src] }.include? tag.src
59
+ def required_scripttag?(scripttags, tag)
60
+ scripttags.map { |w| w[:src] }.include?(tag.src)
60
61
  end
61
62
 
62
63
  def create_scripttag(attributes)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class WebhooksManager
3
4
  class CreationFailed < StandardError; end
@@ -31,7 +32,7 @@ module ShopifyApp
31
32
 
32
33
  def destroy_webhooks
33
34
  ShopifyAPI::Webhook.all.to_a.each do |webhook|
34
- ShopifyAPI::Webhook.delete(webhook.id) if is_required_webhook?(webhook)
35
+ ShopifyAPI::Webhook.delete(webhook.id) if required_webhook?(webhook)
35
36
  end
36
37
 
37
38
  @current_webhooks = nil
@@ -39,8 +40,8 @@ module ShopifyApp
39
40
 
40
41
  private
41
42
 
42
- def is_required_webhook?(webhook)
43
- required_webhooks.map{ |w| w[:address] }.include? webhook.address
43
+ def required_webhook?(webhook)
44
+ required_webhooks.map { |w| w[:address] }.include?(webhook.address)
44
45
  end
45
46
 
46
47
  def create_webhook(attributes)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ShopifyApp
2
3
  class SameSiteCookieMiddleware
3
4
  COOKIE_SEPARATOR = "\n"
@@ -11,14 +12,15 @@ module ShopifyApp
11
12
  user_agent = env['HTTP_USER_AGENT']
12
13
 
13
14
  if headers && headers['Set-Cookie'] &&
14
- !SameSiteCookieMiddleware.same_site_none_incompatible?(user_agent) &&
15
- ShopifyApp.configuration.enable_same_site_none
15
+ BrowserSniffer.new(user_agent).same_site_none_compatible? &&
16
+ ShopifyApp.configuration.enable_same_site_none &&
17
+ Rack::Request.new(env).ssl?
16
18
 
17
19
  set_cookies = headers['Set-Cookie']
18
20
  .split(COOKIE_SEPARATOR)
19
21
  .compact
20
22
  .map do |cookie|
21
- cookie << '; Secure' if not cookie =~ /;\s*secure/i
23
+ cookie << '; Secure' unless cookie =~ /;\s*secure/i
22
24
  cookie << '; SameSite=None' unless cookie =~ /;\s*samesite=/i
23
25
  cookie
24
26
  end
@@ -28,40 +30,5 @@ module ShopifyApp
28
30
 
29
31
  [status, headers, body]
30
32
  end
31
-
32
- def self.same_site_none_incompatible?(user_agent)
33
- sniffer = BrowserSniffer.new(user_agent)
34
-
35
- webkit_same_site_bug?(sniffer) || drops_unrecognized_same_site_cookies?(sniffer)
36
- rescue
37
- true
38
- end
39
-
40
- def self.webkit_same_site_bug?(sniffer)
41
- (sniffer.os == :ios && sniffer.os_version.match(/^([0-9]|1[12])[\.\_]/)) ||
42
- (sniffer.os == :mac && sniffer.browser == :safari && sniffer.os_version.match(/^10[\.\_]14/))
43
- end
44
-
45
- def self.drops_unrecognized_same_site_cookies?(sniffer)
46
- (chromium_based?(sniffer) && sniffer.major_browser_version >= 51 && sniffer.major_browser_version <= 66) ||
47
- (uc_browser?(sniffer) && !uc_browser_version_at_least?(sniffer: sniffer, major: 12, minor: 13, build: 2))
48
- end
49
-
50
- def self.chromium_based?(sniffer)
51
- sniffer.browser_name.downcase.match(/chrom(e|ium)/)
52
- end
53
-
54
- def self.uc_browser?(sniffer)
55
- sniffer.user_agent.downcase.match(/uc\s?browser/)
56
- end
57
-
58
- def self.uc_browser_version_at_least?(sniffer:, major:, minor:, build:)
59
- digits = sniffer.browser_version.split('.').map(&:to_i)
60
- return false unless digits.count >= 3
61
-
62
- return digits[0] > major if digits[0] != major
63
- return digits[1] > minor if digits[1] != minor
64
- digits[2] >= build
65
- end
66
33
  end
67
34
  end