pezza_action_push_web 0.1.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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +299 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/action_push_web.js +424 -0
  6. data/app/assets/javascripts/components/action_push_web.js +7 -0
  7. data/app/assets/javascripts/components/denied.js +24 -0
  8. data/app/assets/javascripts/components/granted.js +86 -0
  9. data/app/assets/javascripts/components/request.js +55 -0
  10. data/app/assets/stylesheets/action_push_web/application.css +15 -0
  11. data/app/controllers/action_push_web/subscriptions_controller.rb +19 -0
  12. data/app/helpers/action_push_web/application_helper.rb +22 -0
  13. data/app/jobs/action_push_web/notification_job.rb +48 -0
  14. data/app/models/action_push_web/subscription.rb +13 -0
  15. data/db/migrate/20250907213606_create_action_push_web_subscriptions.rb +13 -0
  16. data/lib/action_push_web/engine.rb +36 -0
  17. data/lib/action_push_web/errors.rb +8 -0
  18. data/lib/action_push_web/notification.rb +63 -0
  19. data/lib/action_push_web/payload_encryption.rb +86 -0
  20. data/lib/action_push_web/pool.rb +46 -0
  21. data/lib/action_push_web/pusher.rb +85 -0
  22. data/lib/action_push_web/subscription_notification.rb +13 -0
  23. data/lib/action_push_web/vapid_key.rb +38 -0
  24. data/lib/action_push_web/vapid_key_generator.rb +19 -0
  25. data/lib/action_push_web/version.rb +3 -0
  26. data/lib/action_push_web.rb +37 -0
  27. data/lib/generators/action_push_web/install/install_generator.rb +51 -0
  28. data/lib/generators/action_push_web/install/templates/app/jobs/application_push_web_notification_job.rb.tt +7 -0
  29. data/lib/generators/action_push_web/install/templates/app/models/application_push_subscription.rb.tt +4 -0
  30. data/lib/generators/action_push_web/install/templates/app/models/application_push_web_notification.rb.tt +12 -0
  31. data/lib/generators/action_push_web/install/templates/app/views/pwa/service-worker.js +30 -0
  32. data/lib/generators/action_push_web/install/templates/config/push.yml.tt +22 -0
  33. data/lib/pezza_action_push_web.rb +4 -0
  34. data/lib/tasks/action_push_web_tasks.rake +4 -0
  35. metadata +160 -0
@@ -0,0 +1,48 @@
1
+ module ActionPushWeb
2
+ class NotificationJob < ActiveJob::Base
3
+ self.log_arguments = false
4
+
5
+ class_attribute :report_job_retries, default: false
6
+
7
+ discard_on ActiveJob::DeserializationError
8
+
9
+ class << self
10
+ def retry_options
11
+ Rails.version >= "8.1" ? { report: report_job_retries } : {}
12
+ end
13
+
14
+ # Exponential backoff starting from a minimum of 1 minute, capped at 60m as suggested by FCM:
15
+ # https://firebase.google.com/docs/cloud-messaging/scale-fcm#errors
16
+ #
17
+ # | Executions | Delay (rounded minutes) |
18
+ # |------------|-------------------------|
19
+ # | 1 | 1 |
20
+ # | 2 | 2 |
21
+ # | 3 | 4 |
22
+ # | 4 | 8 |
23
+ # | 5 | 16 |
24
+ # | 6 | 32 |
25
+ # | 7 | 60 (cap) |
26
+ def exponential_backoff_delay(executions)
27
+ base_wait = 1.minute
28
+ delay = base_wait * (2**(executions - 1))
29
+ jitter = 0.15
30
+ jitter_delay = rand * delay * jitter
31
+
32
+ [ delay + jitter_delay, 60.minutes ].min
33
+ end
34
+ end
35
+
36
+ with_options retry_options do
37
+ retry_on PushServiceError, attempts: 20
38
+ end
39
+
40
+ with_options wait: ->(executions) { exponential_backoff_delay(executions) }, attempts: 6, **retry_options do
41
+ retry_on TooManyRequestsError, ResponseError
42
+ end
43
+
44
+ def perform(notification_class, notification_attributes, subscription)
45
+ notification_class.constantize.new(**notification_attributes).deliver_to(subscription)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,13 @@
1
+ module ActionPushWeb
2
+ class Subscription < ApplicationRecord
3
+ include ActiveSupport::Rescuable
4
+
5
+ rescue_from(TokenError) { destroy! }
6
+
7
+ belongs_to :owner, polymorphic: true, optional: true
8
+
9
+ def push(notification)
10
+ ActionPushWeb.push(SubscriptionNotification.new(notification:, subscription: self))
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ class CreateActionPushWebSubscriptions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :action_push_web_subscriptions do |t|
4
+ t.belongs_to :owner, polymorphic: true
5
+ t.string :endpoint, null: false
6
+ t.string :auth_key, null: false
7
+ t.string :p256dh_key, null: false
8
+ t.string :user_agent
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ module ActionPushWeb
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ActionPushWeb
4
+ config.autoload_once_paths = %W[
5
+ #{root}/app/helpers
6
+ ]
7
+
8
+ config.action_push_web = ActiveSupport::OrderedOptions.new
9
+
10
+ initializer "action_push_web.config" do |app|
11
+ app.paths.add "config/push", with: "config/push.yml"
12
+ end
13
+
14
+ initializer "action_push_web.pool" do |app|
15
+ at_exit { ActionPushWeb.pool.shutdown }
16
+ end
17
+
18
+ initializer "action_push_web.assets" do
19
+ if Rails.application.config.respond_to?(:assets)
20
+ Rails.application.config.assets.precompile += %w[action_push_web.js]
21
+ end
22
+ end
23
+
24
+ initializer "action_push_web.importmap", before: "importmap" do |app|
25
+ if Rails.application.respond_to?(:importmap)
26
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
27
+ end
28
+ end
29
+
30
+ initializer "action_push_web.helpers", before: :load_config_initializers do
31
+ ActiveSupport.on_load(:action_controller_base) do
32
+ helper ActionPushWeb::Engine.helpers
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,8 @@
1
+ module ActionPushWeb
2
+ class TokenError < StandardError; end
3
+ class UnauthorizedError < StandardError; end
4
+ class PayloadTooLargeError < StandardError; end
5
+ class TooManyRequestsError < StandardError; end
6
+ class PushServiceError < StandardError; end
7
+ class ResponseError < StandardError; end
8
+ end
@@ -0,0 +1,63 @@
1
+ module ActionPushWeb
2
+ class Notification
3
+ extend ActiveModel::Callbacks
4
+
5
+ attr_accessor :title, :body, :path, :context, :urgency, :silent, :badge
6
+ attr_writer :icon_path
7
+
8
+ define_model_callbacks :delivery
9
+
10
+ class_attribute :queue_name, default: ActiveJob::Base.default_queue_name
11
+ class_attribute :enabled, default: !Rails.env.test?
12
+ class_attribute :application
13
+
14
+ class << self
15
+ def queue_as(name)
16
+ self.queue_name = name
17
+ end
18
+ end
19
+
20
+ def initialize(title:, path: nil, body: nil, icon_path: nil, urgency: nil, badge: nil, silent: nil, **context)
21
+ @title = title
22
+ @path = path
23
+ @body = body.to_s
24
+ @icon_path = icon_path
25
+ @urgency = urgency
26
+ @silent = silent
27
+ @badge = badge
28
+ @context = context
29
+ end
30
+
31
+ def deliver_to(subscription)
32
+ if enabled
33
+ run_callbacks(:delivery) { subscription.push(self) }
34
+ end
35
+ end
36
+
37
+ def deliver_later_to(subscriptions)
38
+ Array(subscriptions).each do |subscription|
39
+ ApplicationPushWebNotificationJob.set(queue: queue_name).
40
+ perform_later(self.class.name, self.as_json, subscription)
41
+ end
42
+ end
43
+
44
+ def icon_path
45
+ @icon_path.presence || config.fetch(:icon_path, nil)
46
+ end
47
+
48
+ def urgency
49
+ (@urgency.presence || config.fetch(:urgency, :normal)).to_s
50
+ end
51
+
52
+ def as_json
53
+ { title:, body:, path:, icon_path:, urgency:,
54
+ silent:, badge:, **context }.compact
55
+ end
56
+
57
+ private
58
+
59
+ def config
60
+ @config ||= ActionPushWeb.config_for(application)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,86 @@
1
+ module ActionPushWeb
2
+ class PayloadEncryption
3
+ def initialize(message:, p256dh_key:, auth_key:)
4
+ @message = message
5
+ @p256dh_key = p256dh_key
6
+ @auth_key = auth_key
7
+ end
8
+
9
+ def encrypt
10
+ serverkey16bn = [ server_public_key_bn.to_s(16) ].pack("H*")
11
+
12
+ rs = encrypted_payload.bytesize
13
+ raise ArgumentError, "encrypted payload is too big" if rs > 4096
14
+
15
+ aes128gcmheader = "#{salt}" + [ rs ].pack("N*") +
16
+ [ serverkey16bn.bytesize ].pack("C*") + serverkey16bn
17
+
18
+ aes128gcmheader + encrypted_payload
19
+ end
20
+
21
+ attr_reader :message, :p256dh_key, :auth_key
22
+
23
+ def group_name = "prime256v1"
24
+ def hash = "SHA256"
25
+
26
+ def salt
27
+ @salt ||= Random.new.bytes(16)
28
+ end
29
+
30
+ def server
31
+ @server ||= OpenSSL::PKey::EC.generate(group_name)
32
+ end
33
+
34
+ def server_public_key_bn = server.public_key.to_bn
35
+
36
+ def group
37
+ @group ||= OpenSSL::PKey::EC::Group.new(group_name)
38
+ end
39
+
40
+ def client_public_key_bn
41
+ @client_public_key_bn ||= OpenSSL::BN.new(Base64.urlsafe_decode64(p256dh_key), 2)
42
+ end
43
+
44
+ def client_public_key
45
+ @client_public_key ||= OpenSSL::PKey::EC::Point.new(group, client_public_key_bn)
46
+ end
47
+
48
+ def shared_secret
49
+ @shared_secret ||= server.dh_compute_key(client_public_key)
50
+ end
51
+
52
+ def prk
53
+ @prk ||= OpenSSL::KDF.hkdf(shared_secret,
54
+ salt: Base64.urlsafe_decode64(auth_key),
55
+ info: "WebPush: info\0" + client_public_key_bn.to_s(2) + server_public_key_bn.to_s(2),
56
+ hash:, length: 32)
57
+ end
58
+
59
+ def content_encryption_key
60
+ @content_encryption_key ||= OpenSSL::KDF.hkdf(
61
+ prk, salt:, info: "Content-Encoding: aes128gcm\0", hash:, length: 16
62
+ )
63
+ end
64
+
65
+ def nonce
66
+ @nonce ||= OpenSSL::KDF.hkdf(
67
+ prk, salt:, info: "Content-Encoding: nonce\0", hash:, length: 12
68
+ )
69
+ end
70
+
71
+ def encrypted_payload
72
+ @encrypted_payload ||= begin
73
+ cipher = OpenSSL::Cipher.new("aes-128-gcm")
74
+ cipher.encrypt
75
+ cipher.key = content_encryption_key
76
+ cipher.iv = nonce
77
+ text = cipher.update(message)
78
+ padding = cipher.update("\2\0")
79
+ e_text = text + padding + cipher.final
80
+ e_tag = cipher.auth_tag
81
+
82
+ e_text + e_tag
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,46 @@
1
+ module ActionPushWeb
2
+ class Pool
3
+ attr_reader :delivery_pool, :invalidation_pool, :connection
4
+
5
+ def initialize(delivery_pool: Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000),
6
+ invalidation_pool: Concurrent::FixedThreadPool.new(1))
7
+ @delivery_pool = delivery_pool
8
+ @invalidation_pool = invalidation_pool
9
+ @connection = Net::HTTP::Persistent.new(name: "action_push_web", pool_size: 150)
10
+ end
11
+
12
+ def enqueue(notification, config:)
13
+ delivery_pool.post do
14
+ deliver(notification, config)
15
+ end
16
+ rescue Concurrent::RejectedExecutionError
17
+ end
18
+
19
+ def shutdown
20
+ connection.shutdown
21
+ shutdown_pool(delivery_pool)
22
+ shutdown_pool(invalidation_pool)
23
+ end
24
+
25
+ private
26
+
27
+ def deliver(notification, config)
28
+ Pusher.new(config, notification).push(connection:)
29
+ rescue TokenError => error
30
+ invalidate_subscription_later(notification.subscription, error)
31
+ end
32
+
33
+ def invalidate_subscription_later(subscription, error)
34
+ invalidation_pool.post do
35
+ subscription.rescue_with_handler(error)
36
+ rescue Exception => e
37
+ Rails.logger.error "Error in ActionPushWeb::Pool.invalidate_subscription_later: #{e.class} #{e.message}"
38
+ end
39
+ end
40
+
41
+ def shutdown_pool(pool)
42
+ pool.shutdown
43
+ pool.kill unless pool.wait_for_termination(1)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,85 @@
1
+ module ActionPushWeb
2
+ class Pusher
3
+ def initialize(config, notification)
4
+ @config = config
5
+ @notification = notification
6
+ end
7
+
8
+ def push(connection:)
9
+ request = Net::HTTP::Post.new(uri.request_uri, headers).tap do
10
+ it.body = payload
11
+ end
12
+
13
+ connection.request(uri, request).tap { handle_response(it) }
14
+ rescue OpenSSL::OpenSSLError
15
+ raise TokenError.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :config, :notification
21
+
22
+ delegate :title, :body, :icon_path, :path, :silent, :badge, :endpoint, :p256dh_key,
23
+ :auth_key, to: :notification
24
+
25
+ def message
26
+ JSON.generate title:, options: { body:, icon: icon_path, silent:, badge:, data: { path: } }
27
+ end
28
+
29
+ def payload
30
+ @payload ||= PayloadEncryption.new(message:, p256dh_key:, auth_key:).encrypt
31
+ end
32
+
33
+ def vapid_identification
34
+ config.slice(:public_key, :private_key).compact
35
+ end
36
+
37
+ def uri
38
+ @uri ||= URI.parse(endpoint)
39
+ end
40
+
41
+ def headers
42
+ headers = {}
43
+ headers["Content-Type"] = "application/octet-stream"
44
+ headers["Urgency"] = notification.urgency
45
+ headers["Ttl"] = config.fetch(:ttl, 60 * 60 * 24 * 7 * 4).to_s
46
+ headers["Content-Encoding"] = "aes128gcm"
47
+ headers["Content-Length"] = payload.length.to_s
48
+ headers["Authorization"] = vapid_authorization
49
+
50
+ headers
51
+ end
52
+
53
+ def vapid_authorization
54
+ vapid_key = VapidKey.new(config[:public_key], config[:private_key])
55
+
56
+ jwt = JWT.encode(jwt_payload, vapid_key.ec_key,
57
+ "ES256", { "typ": "JWT", "alg": "ES256" })
58
+
59
+ "vapid t=#{jwt},k=#{vapid_key.public_key_for_push_header}"
60
+ end
61
+
62
+ def jwt_payload
63
+ { aud: uri.scheme + "://" + uri.host,
64
+ exp: Time.now.to_i + config.fetch(:expiration, 12 * 60 * 60),
65
+ sub: config.fetch(:subject, "mailto:sender@example.com") }
66
+ end
67
+
68
+ def handle_response(response)
69
+ if response.is_a?(Net::HTTPGone) || response.is_a?(Net::HTTPNotFound) # 410 || 404
70
+ raise TokenError.new
71
+ elsif response.is_a?(Net::HTTPUnauthorized) || response.is_a?(Net::HTTPForbidden) || # 401, 403
72
+ response.is_a?(Net::HTTPBadRequest) && response.message == "UnauthorizedRegistration" # 400, Google FCM
73
+ raise UnauthorizedError.new
74
+ elsif response.is_a?(Net::HTTPRequestEntityTooLarge) # 413
75
+ raise PayloadTooLargeError.new
76
+ elsif response.is_a?(Net::HTTPTooManyRequests) # 429, try again later!
77
+ raise TooManyRequestsError.new
78
+ elsif response.is_a?(Net::HTTPServerError) # 5xx
79
+ raise PushServiceError.new
80
+ elsif !response.is_a?(Net::HTTPSuccess) # unknown/unhandled response error
81
+ raise ResponseError.new
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,13 @@
1
+ module ActionPushWeb
2
+ class SubscriptionNotification
3
+ def initialize(notification:, subscription:)
4
+ @notification = notification
5
+ @subscription = subscription
6
+ end
7
+
8
+ attr_reader :notification, :subscription
9
+
10
+ delegate_missing_to :notification
11
+ delegate :endpoint, :p256dh_key, :auth_key, to: :subscription
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ module ActionPushWeb
2
+ class VapidKey
3
+ attr_reader :public_key, :private_key
4
+
5
+ def initialize(public_key, private_key)
6
+ @public_key = public_key
7
+ @private_key = private_key
8
+ end
9
+
10
+ # For request header (unpadded)
11
+ def public_key_for_push_header
12
+ public_key.delete("=")
13
+ end
14
+
15
+ def ec_key
16
+ @ec_key ||= begin
17
+ group = OpenSSL::PKey::EC::Group.new("prime256v1")
18
+ public_point = OpenSSL::PKey::EC::Point.new(group, decode_base64url_to_big_number(public_key))
19
+ priv_bn = decode_base64url_to_big_number(private_key)
20
+
21
+ asn1 = OpenSSL::ASN1::Sequence([
22
+ OpenSSL::ASN1::Integer(1),
23
+ OpenSSL::ASN1::OctetString(priv_bn.to_s(2)),
24
+ OpenSSL::ASN1::ObjectId("prime256v1", 0, :EXPLICIT),
25
+ OpenSSL::ASN1::BitString(public_point.to_octet_string(:uncompressed), 1, :EXPLICIT)
26
+ ])
27
+
28
+ OpenSSL::PKey::EC.new(asn1.to_der)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def decode_base64url_to_big_number(str)
35
+ OpenSSL::BN.new(Base64.urlsafe_decode64(str), 2)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,19 @@
1
+ module ActionPushWeb
2
+ class VapidKeyGenerator
3
+ def initialize
4
+ @ec_key = OpenSSL::PKey::EC.generate("prime256v1")
5
+ end
6
+
7
+ def private_key
8
+ Base64.urlsafe_encode64(ec_key.private_key.to_s(2))
9
+ end
10
+
11
+ def public_key
12
+ Base64.urlsafe_encode64(ec_key.public_key.to_bn.to_s(2))
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :ec_key
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ module ActionPushWeb
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,37 @@
1
+ require "zeitwerk"
2
+ require "action_push_web/engine"
3
+ require "action_push_web/errors"
4
+ require "net/http"
5
+ require "net/http/persistent"
6
+ require "jwt"
7
+
8
+ loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
9
+ loader.ignore("#{__dir__}/generators")
10
+ loader.ignore("#{__dir__}/action_push_web/errors.rb")
11
+ loader.setup
12
+
13
+ module ActionPushWeb
14
+ def self.pool
15
+ @pool ||= Pool.new
16
+ end
17
+
18
+ def self.pool=(pool)
19
+ @pool = pool
20
+ end
21
+
22
+ def self.config_for(application)
23
+ platform_config = Rails.application.config_for(:push)[:web]
24
+ raise "ActionPushWeb: 'web' platform is not configured" unless platform_config.present?
25
+
26
+ if application.present?
27
+ notification_config = platform_config.fetch(application.to_sym, {})
28
+ platform_config.fetch(:application, {}).merge(notification_config)
29
+ else
30
+ platform_config
31
+ end
32
+ end
33
+
34
+ def self.push(notification)
35
+ pool.enqueue(notification, config: self.config_for(notification.application))
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ class ActionPushWeb::InstallGenerator < Rails::Generators::Base
2
+ source_root File.expand_path("templates", __dir__)
3
+
4
+ APPLICATION_LAYOUT_PATH = Rails.root.join("app/views/layouts/application.html.erb")
5
+
6
+ def copy_files
7
+ template "app/models/application_push_subscription.rb"
8
+ template "app/models/application_push_web_notification.rb"
9
+ template "app/jobs/application_push_web_notification_job.rb"
10
+ template "app/views/pwa/service-worker.js"
11
+ route "mount ActionPushWeb::Engine => \"/action_push_web\""
12
+
13
+ if Rails.root.join("config/push.yml").exist?
14
+ append_to_file "config/push.yml", File.read("#{self.class.source_root}/config/push.yml.tt").split("\n")[1..].join("\n").prepend("\n")
15
+ else
16
+ template "config/push.yml"
17
+ end
18
+
19
+ if Rails.root.join("app/javascript/application.js").exist?
20
+ append_to_file "app/javascript/application.js", %(import "action_push_web"\n)
21
+ end
22
+
23
+ if Rails.root.join("package.json").exist? && Rails.root.join("bun.config.js").exist?
24
+ # run "bun add action_push_web"
25
+ elsif Rails.root.join("package.json").exist?
26
+ # run "yarn add action_push_web"
27
+ end
28
+
29
+ if APPLICATION_LAYOUT_PATH.exist?
30
+ say "Add action push web meta tag in application layout"
31
+ insert_into_file APPLICATION_LAYOUT_PATH.to_s, "\n <%= action_push_web_key_tag %>", before: /\s*<\/head>/
32
+ else
33
+ say "Default application.html.erb is missing!", :red
34
+ say " Add <%= action_push_web_key_tag %> within the <head> tag in your custom layout."
35
+ end
36
+
37
+ rails_command "railties:install:migrations FROM=action_push_web",
38
+ inline: true
39
+
40
+ vapid_key = ActionPushWeb::VapidKeyGenerator.new
41
+
42
+ puts "\n"
43
+ puts <<~MSG
44
+ Add this entry to the credentials of the target environment:#{' '}
45
+
46
+ action_push_web:
47
+ public_key: #{vapid_key.public_key}
48
+ private_key: #{vapid_key.private_key}
49
+ MSG
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ class ApplicationPushWebNotificationJob < ActionPushWeb::NotificationJob
2
+ # Enable logging job arguments (default: false)
3
+ # self.log_arguments = true
4
+
5
+ # Report job retries via the `Rails.error` reporter (default: false)
6
+ # self.report_job_retries = true
7
+ end
@@ -0,0 +1,4 @@
1
+ class ApplicationPushSubscription < ActionPushWeb::Subscription
2
+ # Customize TokenError handling (default: destroy!)
3
+ # rescue_from (ActionPushWeb::TokenError) { Rails.logger.error("Subscription #{id} token is invalid") }
4
+ end
@@ -0,0 +1,12 @@
1
+ class ApplicationPushWebNotification < ActionPushWeb::Notification
2
+ # Set a custom job queue_name
3
+ # queue_as :realtime
4
+
5
+ # Controls whether push notifications are enabled (default: !Rails.env.test?)
6
+ # self.enabled = Rails.env.production?
7
+
8
+ # Define a custom callback to modify or abort the notification before it is sent
9
+ # before_delivery do |notification|
10
+ # throw :abort if Notification.find(notification.context[:notification_id]).expired?
11
+ # end
12
+ end
@@ -0,0 +1,30 @@
1
+ self.addEventListener("push", async (event) => {
2
+ const data = await event.data.json()
3
+ event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
4
+ })
5
+
6
+ async function showNotification({ title, options }) {
7
+ return self.registration.showNotification(title, options)
8
+ }
9
+
10
+ async function updateBadgeCount({ data: { badge } }) {
11
+ return self.navigator.setAppBadge?.(badge || 0)
12
+ }
13
+
14
+ self.addEventListener("notificationclick", (event) => {
15
+ event.notification.close()
16
+
17
+ const url = new URL(event.notification.data.path, self.location.origin).href
18
+ event.waitUntil(openURL(url))
19
+ })
20
+
21
+ async function openURL(url) {
22
+ const clients = await self.clients.matchAll({ type: "window" })
23
+ const focused = clients.find((client) => client.focused)
24
+
25
+ if (focused) {
26
+ await focused.navigate(url)
27
+ } else {
28
+ await self.clients.openWindow(url)
29
+ }
30
+ }
@@ -0,0 +1,22 @@
1
+ shared:
2
+ web:
3
+ public_key: <%%= Rails.application.credentials.action_push_web.public_key %>
4
+ private_key: <%%= Rails.application.credentials.action_push_web.private_key %>
5
+
6
+ # Change the request timeout (default: 30).
7
+ # request_timeout: 60
8
+
9
+ # Change the ttl (default: 2419200).
10
+ # ttl: 60
11
+
12
+ # Change the expiration (default: 43200).
13
+ # expiration: 60
14
+
15
+ # Change the subject (default: mailto:sender@example.com).
16
+ # subject: mailto:support@my-domain.com
17
+
18
+ # Change the urgency (default: normal). You also choose to set this at the notification level.
19
+ # urgency: high
20
+
21
+ # Change the icon path (default: nil). You also choose to set this at the notification level.
22
+ # icon_path: https://example.com/icon.png
@@ -0,0 +1,4 @@
1
+ require "action_push_web"
2
+
3
+ module PezzaActionPushWeb
4
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :action_push_web do
3
+ # # Task goes here
4
+ # end